Wren
2026-08-14 12:23:49 -04:00
parent d0d8cec5fa
commit 78467cebb9
8 changed files with 90 additions and 8 deletions
+3
View File
@@ -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,
+27
View File
@@ -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());
+1
View File
@@ -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,
+1
View File
@@ -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,
+52 -8
View File
@@ -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,
+4
View File
@@ -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,