mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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>
This commit is contained in:
@@ -574,6 +574,7 @@ async fn connect_services_with_store(store: DeletionStore) -> Result<Services> {
|
||||
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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Result<(&'static str, &'static str), MediaError>> {
|
||||
#[derive(Clone, Copy)]
|
||||
enum AudioKind {
|
||||
Mp3,
|
||||
Wav,
|
||||
Ogg,
|
||||
}
|
||||
|
||||
fn classify_audio(bytes: &[u8]) -> Option<Result<AudioKind, MediaError>> {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user