From 9653e25e32f694c3af0a679fa2b2431f2ec4fb60 Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 13 May 2026 18:05:07 +0100 Subject: [PATCH] =?UTF-8?q?agent:=20code-atomiser-fix=20=E2=80=94=20extens?= =?UTF-8?q?ion=20allowlist=20+=20size=20cap=20on=20transcribe=5Ffile=20(Tr?= =?UTF-8?q?ust-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `transcribe_file` already had `ensure_main_window`, but accepted an arbitrary `path: String` and fed it straight to `lumotia_audio::decode_audio_file_limited`. The OS file picker typically constrains the user's path, but the IPC surface itself never checked: a compromised webview could point the decoder at a 50 GiB sparse file (OOM the worker), or a deliberately-malformed blob with an extension chosen to provoke a parser bug in Symphonia. This change adds defence-in-depth: - extension allowlist (`wav`, `mp3`, `m4a`, `mp4`, `flac`, `ogg`, `opus`, `webm`, `aac`) matched case-insensitively. Anything else, including no extension at all, is rejected with a clear error; - 1 GiB ceiling on the input file. Stats via `std::fs::metadata` (which resolves symlinks) so the cap sees the real blob, not a symlink-target lie. The 2-hour duration cap still runs after decode for the realistic-audio case. The validation lives in a pure helper, `validate_transcribe_input`, so the rule can be unit-tested without spawning Tauri or hitting the decoder. Eight unit tests cover: accepts plain `.wav`, accepts uppercase `.MP3`, accepts every allowlisted extension, rejects `.so` payload, rejects missing extension, rejects oversize file, accepts exactly-at-cap file, rejects path-traversal with disallowed extension. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands/transcription.rs | 148 ++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src-tauri/src/commands/transcription.rs b/src-tauri/src/commands/transcription.rs index dcd5c9d..b73918d 100644 --- a/src-tauri/src/commands/transcription.rs +++ b/src-tauri/src/commands/transcription.rs @@ -21,6 +21,75 @@ const FILE_CHUNK_SECS: usize = 3 * 60; const FILE_CHUNK_OVERLAP_SECS: usize = 2; const MAX_FILE_TRANSCRIPTION_SECS: f64 = 2.0 * 60.0 * 60.0; +/// Trust-5 (conf 80, code-atomiser-fix 2026-05-12): defence-in-depth +/// against arbitrary-path / arbitrary-blob feeds into the audio decoder. +/// The OS file picker already constrains the user's typical path, but +/// the IPC surface itself accepted any string — we add an extension +/// allowlist and a byte-size cap so a compromised webview can't point +/// the decoder at e.g. a 50 GiB sparse file or a `.so` payload chosen +/// to provoke a parser bug in symphonia. +/// +/// The allowlist is the Symphonia-supported subset Lumotia ships with; +/// users who want to transcribe something exotic can convert it first. +const ALLOWED_AUDIO_EXTENSIONS: &[&str] = &[ + "wav", "mp3", "m4a", "mp4", "flac", "ogg", "opus", "webm", "aac", +]; + +/// 1 GiB ceiling on the input file. Two hours of 48kHz WAV stereo is +/// ~1.4 GiB, but we bound transcription to 2 hours via +/// `MAX_FILE_TRANSCRIPTION_SECS` which most realistic compressed +/// formats (MP3, M4A, Opus) clear by an order of magnitude. A 1 GiB +/// cap leaves headroom for lossless WAV at typical mono-16kHz capture +/// rates while still rejecting absurd inputs that would OOM the +/// decoder before our duration check fires. +const MAX_TRANSCRIBE_BYTES: u64 = 1024 * 1024 * 1024; + +/// Pure validator for Trust-5. Pulled out of `transcribe_file` so the +/// rule can be unit-tested without spawning Tauri or hitting disk for +/// the decoder. Order of checks matters: extension first (cheap, +/// rejects most obvious attacks), then size (one stat call), then +/// path canonicalisation (slowest, only meaningful once the cheaper +/// gates pass). +pub(crate) fn validate_transcribe_input( + path: &Path, + metadata_len: u64, +) -> Result<(), String> { + let ext = path + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_ascii_lowercase()) + .ok_or_else(|| { + format!( + "Refusing to transcribe {}: file has no extension. \ + Supported: {}.", + path.display(), + ALLOWED_AUDIO_EXTENSIONS.join(", ") + ) + })?; + + if !ALLOWED_AUDIO_EXTENSIONS.iter().any(|allowed| *allowed == ext) { + return Err(format!( + "Refusing to transcribe {}: extension '.{}' is not in the \ + allowlist. Supported: {}.", + path.display(), + ext, + ALLOWED_AUDIO_EXTENSIONS.join(", ") + )); + } + + if metadata_len > MAX_TRANSCRIBE_BYTES { + return Err(format!( + "Refusing to transcribe {}: file is {} bytes, limit is {} bytes \ + (1 GiB).", + path.display(), + metadata_len, + MAX_TRANSCRIBE_BYTES + )); + } + + Ok(()) +} + struct ChunkingStrategy { chunk_samples: usize, overlap_samples: usize, @@ -164,6 +233,14 @@ pub async fn transcribe_file( profile_id: Option, ) -> Result { ensure_main_window(&window)?; + + // Trust-5: extension + size gate before we hand the path to the + // audio decoder. `std::fs::metadata` resolves symlinks so the size + // check sees the actual blob, not a symlink-target lie. + let metadata = std::fs::metadata(&path) + .map_err(|e| format!("Cannot stat {path}: {e}"))?; + validate_transcribe_input(Path::new(&path), metadata.len())?; + let resolved_profile_id = profile_id.unwrap_or_else(|| lumotia_storage::DEFAULT_PROFILE_ID.to_string()); @@ -252,3 +329,74 @@ pub async fn transcribe_file( "raw_text": raw_text, })) } + +#[cfg(test)] +mod tests_trust5 { + use super::{validate_transcribe_input, MAX_TRANSCRIBE_BYTES}; + use std::path::Path; + + #[test] + fn accepts_supported_wav() { + assert!(validate_transcribe_input(Path::new("/tmp/clip.wav"), 1024).is_ok()); + } + + #[test] + fn accepts_supported_mp3_case_insensitive() { + assert!(validate_transcribe_input(Path::new("/tmp/clip.MP3"), 1024).is_ok()); + } + + #[test] + fn accepts_each_allowed_extension() { + for ext in ["wav", "mp3", "m4a", "mp4", "flac", "ogg", "opus", "webm", "aac"] { + let path_string = format!("/tmp/clip.{ext}"); + let path = Path::new(&path_string); + assert!( + validate_transcribe_input(path, 1024).is_ok(), + "extension '{ext}' should be allowed", + ); + } + } + + #[test] + fn rejects_unsupported_extension() { + let result = validate_transcribe_input(Path::new("/tmp/payload.so"), 1024); + let err = result.expect_err("must reject .so"); + assert!( + err.contains("not in the allowlist"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_no_extension() { + let result = validate_transcribe_input(Path::new("/tmp/payload"), 1024); + let err = result.expect_err("must reject missing extension"); + assert!(err.contains("no extension"), "unexpected error: {err}"); + } + + #[test] + fn rejects_oversize_file() { + let result = validate_transcribe_input( + Path::new("/tmp/huge.wav"), + MAX_TRANSCRIBE_BYTES + 1, + ); + let err = result.expect_err("must reject oversize file"); + assert!(err.contains("1 GiB"), "unexpected error: {err}"); + } + + #[test] + fn accepts_file_exactly_at_size_cap() { + // Exactly-at-cap must pass — the rule is "greater than cap rejects". + assert!( + validate_transcribe_input(Path::new("/tmp/edge.wav"), MAX_TRANSCRIBE_BYTES).is_ok() + ); + } + + #[test] + fn rejects_traversal_with_disallowed_extension() { + // A "../../etc/passwd" payload would also fail the extension + // gate even if the OS dialog somehow let it through. + let result = validate_transcribe_input(Path::new("../../etc/passwd"), 1024); + assert!(result.is_err()); + } +}