From f1b811ae40a6d8752bf1bfce0cb83e8605ea139c Mon Sep 17 00:00:00 2001 From: Jasper Date: Sun, 2 Aug 2026 07:48:38 -0500 Subject: [PATCH] fix(telegram): contain voice file extensions (#10456) --- crates/goose/src/gateway/telegram.rs | 101 +++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 13 deletions(-) diff --git a/crates/goose/src/gateway/telegram.rs b/crates/goose/src/gateway/telegram.rs index 2f4cde6f0..8b764bf7a 100644 --- a/crates/goose/src/gateway/telegram.rs +++ b/crates/goose/src/gateway/telegram.rs @@ -256,19 +256,7 @@ impl TelegramGateway { std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?; } - let ext = mime_type - .and_then(|m| m.rsplit('/').next()) - .map(|sub| { - // Normalise common MIME sub-types to file extensions. - match sub { - "mpeg" => "mp3", - "mp4" | "x-m4a" => "m4a", - "ogg" => "ogg", - "wav" | "x-wav" => "wav", - other => other, - } - }) - .unwrap_or("ogg"); + let ext = Self::voice_file_extension(mime_type); let filename = format!("voice_{}.{ext}", uuid::Uuid::new_v4()); let path = dir.join(filename); @@ -284,6 +272,36 @@ impl TelegramGateway { Ok(path) } + fn voice_file_extension(mime_type: Option<&str>) -> String { + let media_type = mime_type + .and_then(|mime| mime.split(';').next()) + .map(str::trim) + .map(str::to_ascii_lowercase); + let subtype = media_type + .as_deref() + .and_then(|mime| mime.strip_prefix("audio/")); + + let Some(subtype) = subtype else { + return "ogg".to_string(); + }; + + match subtype { + "mpeg" => "mp3".to_string(), + "mp4" | "x-m4a" => "m4a".to_string(), + "ogg" => "ogg".to_string(), + "wav" | "x-wav" | "vnd.wave" => "wav".to_string(), + other + if other.len() <= 16 + && other.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_alphanumeric() || (index > 0 && matches!(byte, b'-' | b'_')) + }) => + { + other.to_string() + } + _ => "ogg".to_string(), + } + } + /// Build the text prompt that tells Goose about a voice message file. fn voice_prompt( path: &std::path::Path, @@ -791,6 +809,63 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn voice_file_extension_preserves_safe_audio_formats() { + let cases = [ + (Some("audio/mpeg"), "mp3"), + (Some("audio/mp4"), "m4a"), + (Some("audio/x-m4a"), "m4a"), + (Some("audio/ogg; codecs=opus"), "ogg"), + (Some("audio/x-wav"), "wav"), + (Some("audio/vnd.wave"), "wav"), + (Some("audio/flac"), "flac"), + (Some("audio/WEBM"), "webm"), + (Some("Audio/MPEG"), "mp3"), + (Some("AUDIO/WEBM"), "webm"), + ]; + + for (mime_type, expected) in cases { + assert_eq!(TelegramGateway::voice_file_extension(mime_type), expected); + } + } + + #[test] + fn voice_file_extension_rejects_filename_syntax() { + let invalid = [ + None, + Some("application/ogg"), + Some("audio/..\\..\\outside"), + Some("audio/../../outside"), + Some("audio/ogg:stream"), + Some("audio/ogg.stream"), + Some("audio/ogg\nnext"), + Some("audio/ogg; touch=outside"), + Some("audio/ogg$HOME"), + Some("audio/åudio"), + Some("audio/this-subtype-is-too-long"), + ]; + + for mime_type in invalid { + assert_eq!(TelegramGateway::voice_file_extension(mime_type), "ogg"); + } + } + + #[test] + fn save_voice_file_contains_untrusted_mime_before_pairing() { + let bytes = b"unpaired voice data"; + let path = TelegramGateway::save_voice_file(bytes, Some("audio/..\\..\\outside")).unwrap(); + + let expected_dir = std::env::temp_dir().join("goose_voice"); + assert_eq!(path.parent(), Some(expected_dir.as_path())); + let filename = path.file_name().unwrap().to_str().unwrap(); + assert!(filename.starts_with("voice_")); + assert!(filename.ends_with(".ogg")); + assert!(!filename.chars().any(|c| matches!(c, '/' | '\\' | ':'))); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + let _ = std::fs::remove_file(&path); + } + #[test] fn cleanup_preserves_recent_files() { let dir = std::env::temp_dir().join("goose_voice");