From 78467cebb926606ffcbdbb2b438a5532fc0d0f85 Mon Sep 17 00:00:00 2001 From: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Date: Fri, 14 Aug 2026 12:23:49 -0400 Subject: [PATCH] Cap inline audio uploads at 25 MB Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- crates/buzz-deletion/src/lib.rs | 3 + crates/buzz-media/src/config.rs | 27 +++++++++ crates/buzz-media/src/storage.rs | 1 + crates/buzz-media/src/upload.rs | 1 + crates/buzz-media/src/validation.rs | 60 ++++++++++++++++--- crates/buzz-media/tests/static_creds_minio.rs | 1 + crates/buzz-relay/src/config.rs | 4 ++ .../src/commands/media_snapshot_png.rs | 1 + 8 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 4e27b85fe..07d1c91fe 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -574,6 +574,7 @@ async fn connect_services_with_store(store: DeletionStore) -> Result { max_gif_bytes: 1, max_video_bytes: 1, max_file_bytes: 1, + max_audio_bytes: 1, public_base_url: "http://localhost/media".to_string(), upload_records_enabled: false, upload_ip_header: None, @@ -1529,6 +1530,7 @@ mod tests { max_gif_bytes: 1, max_video_bytes: 1, max_file_bytes: 1, + max_audio_bytes: 1, public_base_url: "http://localhost/media".to_string(), upload_records_enabled: false, upload_ip_header: None, @@ -1570,6 +1572,7 @@ mod tests { max_gif_bytes: 1, max_video_bytes: 1, max_file_bytes: 1, + max_audio_bytes: 1, public_base_url: "http://localhost/media".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-media/src/config.rs b/crates/buzz-media/src/config.rs index 3c70e4afe..364c89430 100644 --- a/crates/buzz-media/src/config.rs +++ b/crates/buzz-media/src/config.rs @@ -41,6 +41,10 @@ fn default_max_file_bytes() -> u64 { 104_857_600 // 100 MB } +fn default_max_audio_bytes() -> u64 { + 26_214_400 // 25 MB +} + fn default_s3_region() -> String { "us-east-1".to_string() } @@ -77,6 +81,9 @@ pub struct MediaConfig { /// Maximum upload size for generic (non-image, non-video) files (bytes). Default: 100 MB. #[serde(default = "default_max_file_bytes")] pub max_file_bytes: u64, + /// Maximum upload size for MP3, WAV, and Ogg audio files (bytes). Default: 25 MB. + #[serde(default = "default_max_audio_bytes")] + pub max_audio_bytes: u64, /// Public base URL for media URLs in BlobDescriptor (must include `/media` path). pub public_base_url: String, /// Whether to write per-upload-event records under `_uploads/` @@ -124,6 +131,9 @@ impl MediaConfig { if self.max_file_bytes == 0 { return Err("max_file_bytes must be > 0".to_string()); } + if self.max_audio_bytes == 0 || self.max_audio_bytes > self.max_file_bytes { + return Err("max_audio_bytes must be > 0 and <= max_file_bytes".to_string()); + } // Fail startup on incoherent collection config instead of silently // recording nothing — an operator who set an IP header believes they // are meeting a reporting obligation. @@ -174,6 +184,7 @@ mod tests { max_gif_bytes: 1, max_video_bytes: 1, max_file_bytes: 1, + max_audio_bytes: 1, public_base_url: "http://localhost:3000/media".to_string(), upload_records_enabled: false, upload_ip_header: None, @@ -210,6 +221,22 @@ mod tests { } } + #[test] + fn audio_cap_must_be_nonzero_and_within_file_cap() { + let mut cfg = valid_config(); + cfg.max_audio_bytes = 0; + assert_eq!( + cfg.validate().unwrap_err(), + "max_audio_bytes must be > 0 and <= max_file_bytes" + ); + + cfg.max_audio_bytes = cfg.max_file_bytes + 1; + assert_eq!( + cfg.validate().unwrap_err(), + "max_audio_bytes must be > 0 and <= max_file_bytes" + ); + } + #[test] fn upload_record_knobs_default_off_and_validate() { assert!(valid_config().validate().is_ok()); diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 0f0aa7af6..32d2c4030 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -438,6 +438,7 @@ mod tests { max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, + max_audio_bytes: 104_857_600, public_base_url: "http://localhost:3000/media".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 041b2f666..bcbd230da 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -574,6 +574,7 @@ mod tests { max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, + max_audio_bytes: 104_857_600, public_base_url: "https://media.example.com".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index bf081b3d5..8af25e65c 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -295,6 +295,8 @@ fn validate_wav_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { } fn ogg_crc(bytes: &[u8]) -> u32 { + // This bitwise form is intentionally simple. Audio uploads are capped at + // 25 MB before this walk; re-price the loop before raising that ceiling. let mut crc = 0u32; for byte in bytes { crc ^= (*byte as u32) << 24; @@ -399,11 +401,16 @@ fn validate_ogg_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { .ok_or(MediaError::MetadataForbidden) } -fn validate_audio_content( - bytes: &[u8], -) -> Option> { +#[derive(Clone, Copy)] +enum AudioKind { + Mp3, + Wav, + Ogg, +} + +fn classify_audio(bytes: &[u8]) -> Option> { if bytes.starts_with(b"RIFF") { - return Some(validate_wav_metadata_free(bytes).map(|()| ("audio/wav", "wav"))); + return Some(Ok(AudioKind::Wav)); } if bytes.starts_with(b"OggS") { if infer::get(bytes).is_some_and(|kind| kind.mime_type() == "audio/opus") { @@ -411,17 +418,28 @@ fn validate_audio_content( "audio/opus".to_string(), ))); } - return Some(validate_ogg_metadata_free(bytes).map(|()| ("audio/ogg", "ogg"))); + return Some(Ok(AudioKind::Ogg)); } if bytes.starts_with(b"ID3") || bytes.starts_with(b"TAG") || bytes.starts_with(b"APETAGEX") { return Some(Err(MediaError::MetadataForbidden)); } if bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xe0 == 0xe0 { - return Some(validate_mp3_metadata_free(bytes).map(|()| ("audio/mpeg", "mp3"))); + return Some(Ok(AudioKind::Mp3)); } None } +fn validate_audio_content( + bytes: &[u8], + kind: AudioKind, +) -> Result<(&'static str, &'static str), MediaError> { + match kind { + AudioKind::Mp3 => validate_mp3_metadata_free(bytes).map(|()| ("audio/mpeg", "mp3")), + AudioKind::Wav => validate_wav_metadata_free(bytes).map(|()| ("audio/wav", "wav")), + AudioKind::Ogg => validate_ogg_metadata_free(bytes).map(|()| ("audio/ogg", "ogg")), + } +} + /// Validate uploaded bytes for the **generic file or audio** upload path. /// /// This is the catch-all path for attachments. It enforces the generic file @@ -447,8 +465,17 @@ pub fn validate_file_content( }); } - if let Some(result) = validate_audio_content(bytes) { - return result.map(|(mime, ext)| (mime.to_string(), ext.to_string())); + if let Some(kind) = classify_audio(bytes) { + let kind = kind?; + // Bound container walks (notably bitwise Ogg CRC) before they begin. + if bytes.len() as u64 > config.max_audio_bytes { + return Err(MediaError::FileTooLarge { + size: bytes.len() as u64, + max: config.max_audio_bytes, + }); + } + return validate_audio_content(bytes, kind) + .map(|(mime, ext)| (mime.to_string(), ext.to_string())); } // ISO-BMFF permits arbitrary major brands, so `infer` cannot enumerate all @@ -1239,6 +1266,7 @@ mod tests { max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, + max_audio_bytes: 104_857_600, public_base_url: String::new(), upload_records_enabled: false, upload_ip_header: None, @@ -1878,6 +1906,22 @@ mod tests { } } + #[test] + fn test_audio_cap_precedes_container_walks() { + let mut config = test_config(); + config.max_audio_bytes = 4; + for bytes in [ + b"OggSoversized malformed container".as_slice(), + b"RIFFoversized malformed container".as_slice(), + b"\xff\xfboversized malformed frames".as_slice(), + ] { + assert!(matches!( + validate_file_content(bytes, &config), + Err(MediaError::FileTooLarge { size, max: 4 }) if size == bytes.len() as u64 + )); + } + } + #[test] fn test_audio_metadata_and_non_allowlisted_formats_are_rejected() { let config = test_config(); diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index 4c8c10702..a73a70b11 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -39,6 +39,7 @@ fn minio_config() -> MediaConfig { max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, + max_audio_bytes: 104_857_600, public_base_url: "http://localhost:3000/media".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd..0fba3e9e5 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -761,6 +761,10 @@ impl Config { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(100 * 1024 * 1024), + max_audio_bytes: std::env::var("BUZZ_MAX_AUDIO_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(25 * 1024 * 1024), public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL") .unwrap_or_else(|_| "http://localhost:3000/media".to_string()), // Per-upload-event records (`_uploads/` moderation side channel). diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index bcaec6a59..06d37ea46 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -209,6 +209,7 @@ mod tests { max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, + max_audio_bytes: 104_857_600, public_base_url: String::new(), upload_records_enabled: false, upload_ip_header: None,