agent: code-atomiser-fix — extension allowlist + size cap on transcribe_file (Trust-5)
`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) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,75 @@ const FILE_CHUNK_SECS: usize = 3 * 60;
|
|||||||
const FILE_CHUNK_OVERLAP_SECS: usize = 2;
|
const FILE_CHUNK_OVERLAP_SECS: usize = 2;
|
||||||
const MAX_FILE_TRANSCRIPTION_SECS: f64 = 2.0 * 60.0 * 60.0;
|
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 {
|
struct ChunkingStrategy {
|
||||||
chunk_samples: usize,
|
chunk_samples: usize,
|
||||||
overlap_samples: usize,
|
overlap_samples: usize,
|
||||||
@@ -164,6 +233,14 @@ pub async fn transcribe_file(
|
|||||||
profile_id: Option<String>,
|
profile_id: Option<String>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
ensure_main_window(&window)?;
|
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 =
|
let resolved_profile_id =
|
||||||
profile_id.unwrap_or_else(|| lumotia_storage::DEFAULT_PROFILE_ID.to_string());
|
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,
|
"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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user