Phase B.5 audit of commits a2b47db/a48653c/b3da58c (Trust-1 — write path allowlist),9653e25(Trust-5 — transcribe_file extension allowlist + size cap), anded449cc(Trust-2 — resolve_recording_path output_folder validation). The three Trust-1 commits were a corrective sequence that swapped the staged file in a parallel-agent race;b3da58cis the authoritative landing. Existing coverage is strong: * commands/fs.rs (Trust-1): 6 tests — outside-allowlist, traversal, accepts inside, accepts nested, rejects missing parent, prefix check. * commands/transcription.rs (Trust-5): 7 tests — accepts wav, accepts MP3 case-insensitive, accepts each allowed extension, rejects unsupported, rejects no-extension, rejects oversize, accepts exactly-at-cap, rejects traversal-with-disallowed-ext. * commands/audio.rs (Trust-2): 7 tests including validate_output_folder_rejects_symlink_pointing_out — the symlink bypass for output folders is already covered. One real residual found in commands/fs.rs: Asymmetric symlink handling between Trust-1 (fs.rs) and Trust-2 (audio.rs). Trust-2 canonicalises the FULL requested path (it's a directory that must already exist), so a symlink at the directory itself that points outside the base is resolved before the containment check and gets rejected. Trust-1 canonicalises only the PARENT of the requested path, because the target file typically does not exist yet (canonicalize() returns NotFound on missing paths). Concrete bypass: 1. A symlink at, e.g., ~/Downloads/notes.md -> ~/.bashrc — innocently created by the user, or planted via another vulnerability. 2. Compromised webview invokes write_text_file_cmd("/home/user/Downloads/notes.md", "<payload>"). 3. Path-scope check: parent canonicalises to /home/user/Downloads, file_name joins, canonical path string sits inside the Downloads allowlist. PASS. 4. tokio::fs::write -> File::create -> open(2) follows the symlink and writes "<payload>" to ~/.bashrc, exfiltrating shell startup. Fix: two-mode canonicalisation in resolve_export_path. If the target exists, canonicalise the full path — this follows any symlink at the target itself, and the subsequent containment check sees the resolved location. Only on NotFound do we fall back to parent-canonicalise + join-filename (the original save-dialog path). This mirrors the audio crate's canonicalisation discipline. Regression tests: * rejects_symlink_target_outside_allowlist — creates a symlink inside a base pointing OUT to a real outside file; resolve_export_path must return Err with "outside the allowed export directories". * accepts_symlink_target_inside_allowlist — symmetric, an in-base alias symlink must still resolve and pass, so legitimate uses of symlinks are not regressed. Both gated #[cfg(unix)] because std::os::unix::fs::symlink is unix-only. The Trust-1 surface ships symmetrically on Windows; the symlink class attack does not generalise the same way on NTFS (junctions vs symlinks have different ACL semantics), and a windows-specific test would be duplicate-effort outside the audit scope. Verification: * cargo test -p lumotia --lib commands::fs → 8/8 pass including the two new symlink tests. * cargo fmt --check → clean. * cargo clippy -p lumotia --all-targets -- -D warnings → clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
296 lines
11 KiB
Rust
296 lines
11 KiB
Rust
// Phase 9. Thin filesystem write command for the save-dialog path.
|
|
//
|
|
// Path safety (Trust-1, code-atomiser-fix 2026-05-12): an OS save dialog
|
|
// is the intended entry point, but the Tauri IPC surface does not enforce
|
|
// that — a compromised webview can invoke `write_text_file_cmd` directly
|
|
// with any path the running process can write to. We therefore:
|
|
//
|
|
// 1. require the call to originate from the main window;
|
|
// 2. canonicalise the supplied path (resolves symlinks, rejects bad
|
|
// parents);
|
|
// 3. assert the canonical path is inside a known-good base directory
|
|
// (app data, downloads, documents, or desktop). Anything outside is
|
|
// rejected with a clear error.
|
|
//
|
|
// The base-dir allowlist mirrors where the OS save dialog is expected to
|
|
// land; it is deliberately broader than `recordings_dir()` because users
|
|
// routinely save transcripts to Documents or Desktop. Adding new bases is
|
|
// a one-line change in `allowed_export_bases`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use tauri::Manager;
|
|
|
|
use crate::commands::security::ensure_main_window;
|
|
|
|
/// Write UTF-8 text to a user-chosen path. The path must (a) canonicalise
|
|
/// to a real, accessible path, and (b) live inside one of the allowlisted
|
|
/// base directories (app data dir, downloads, documents, desktop). Errors
|
|
/// propagate the OS message verbatim wrapped with the path so the toast
|
|
/// on the frontend is actionable.
|
|
#[tauri::command]
|
|
pub async fn write_text_file_cmd(
|
|
app: tauri::AppHandle,
|
|
window: tauri::WebviewWindow,
|
|
path: String,
|
|
contents: String,
|
|
) -> Result<(), String> {
|
|
ensure_main_window(&window)?;
|
|
|
|
let bases = allowed_export_bases(&app);
|
|
let resolved = resolve_export_path(Path::new(&path), &bases)?;
|
|
|
|
tokio::fs::write(&resolved, contents)
|
|
.await
|
|
.map_err(|e| format!("Failed to write {}: {e}", resolved.display()))
|
|
}
|
|
|
|
/// Collect the set of base directories under which `write_text_file_cmd`
|
|
/// is allowed to land. Returns the canonicalised form of each so the
|
|
/// later containment check can be a pure prefix comparison.
|
|
fn allowed_export_bases(app: &tauri::AppHandle) -> Vec<PathBuf> {
|
|
let path = app.path();
|
|
let mut bases: Vec<PathBuf> = Vec::new();
|
|
for candidate in [
|
|
path.app_data_dir().ok(),
|
|
path.app_local_data_dir().ok(),
|
|
path.download_dir().ok(),
|
|
path.document_dir().ok(),
|
|
path.desktop_dir().ok(),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
// Allow the base directory even if it does not yet exist on disk —
|
|
// canonicalise the parent walk-up when possible.
|
|
if let Ok(canon) = std::fs::canonicalize(&candidate) {
|
|
bases.push(canon);
|
|
} else {
|
|
bases.push(candidate);
|
|
}
|
|
}
|
|
bases
|
|
}
|
|
|
|
/// Resolve the requested write target. The canonicalised path must sit
|
|
/// inside one of `bases`.
|
|
///
|
|
/// Two-mode canonicalisation:
|
|
///
|
|
/// - **If the target already exists**, canonicalise the WHOLE path. This
|
|
/// follows any symlink at the target itself, so a symlink that sits
|
|
/// inside an allowlisted base but points outside it gets rejected
|
|
/// here. `tokio::fs::write` follows symlinks on open(2), so without
|
|
/// this branch a symlink at `~/Downloads/notes.md -> ~/.bashrc` would
|
|
/// pass the containment check (the path string is inside Downloads)
|
|
/// and the subsequent write would silently land in `~/.bashrc`.
|
|
/// Trust-1 audit residual closed in Phase B.5 (2026-05-14); parallels
|
|
/// `validate_output_folder`'s full-path-canonicalize in
|
|
/// `commands/audio.rs` (Trust-2).
|
|
///
|
|
/// - **If the target does not yet exist** (the typical save-dialog
|
|
/// case), canonicalise the parent and re-join the filename. The
|
|
/// parent must exist for `tokio::fs::write` to succeed anyway, so
|
|
/// checking it is no extra cost.
|
|
pub(crate) fn resolve_export_path(path: &Path, bases: &[PathBuf]) -> Result<PathBuf, String> {
|
|
let canon_path = match std::fs::canonicalize(path) {
|
|
Ok(canon) => canon,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
let parent = path.parent().ok_or_else(|| {
|
|
format!(
|
|
"Refusing to write {}: path has no parent directory.",
|
|
path.display()
|
|
)
|
|
})?;
|
|
let file_name = path.file_name().ok_or_else(|| {
|
|
format!(
|
|
"Refusing to write {}: path has no filename.",
|
|
path.display()
|
|
)
|
|
})?;
|
|
|
|
let canon_parent = std::fs::canonicalize(parent).map_err(|e| {
|
|
format!(
|
|
"Refusing to write {}: cannot resolve parent dir ({e}).",
|
|
path.display()
|
|
)
|
|
})?;
|
|
canon_parent.join(file_name)
|
|
}
|
|
Err(e) => {
|
|
return Err(format!(
|
|
"Refusing to write {}: cannot resolve path ({e}).",
|
|
path.display()
|
|
));
|
|
}
|
|
};
|
|
|
|
if !is_inside_any_base(&canon_path, bases) {
|
|
return Err(format!(
|
|
"Refusing to write {}: path is outside the allowed export directories \
|
|
(app data, Downloads, Documents, Desktop). \
|
|
Pick a destination inside one of those folders.",
|
|
canon_path.display()
|
|
));
|
|
}
|
|
|
|
Ok(canon_path)
|
|
}
|
|
|
|
/// Pure containment check: does `candidate` sit at or under any path in
|
|
/// `bases`? Pure on purpose so the unit tests below can exercise the
|
|
/// rule without a Tauri app handle.
|
|
pub(crate) fn is_inside_any_base(candidate: &Path, bases: &[PathBuf]) -> bool {
|
|
bases.iter().any(|base| candidate.starts_with(base))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
fn tempdir_canon(name: &str) -> (tempfile::TempDir, PathBuf) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let canon = std::fs::canonicalize(dir.path()).expect("canonicalise tempdir");
|
|
let _ = name;
|
|
(dir, canon)
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_path_outside_allowlist() {
|
|
let (_keep, base) = tempdir_canon("base");
|
|
let bases = vec![base];
|
|
// /etc/passwd is outside the temp base.
|
|
let result = resolve_export_path(Path::new("/etc/passwd"), &bases);
|
|
assert!(
|
|
result.is_err(),
|
|
"outside-base path must be rejected, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_path_traversal_attempt() {
|
|
// A "../../etc/passwd" relative to a sub-dir of the base must
|
|
// resolve to /etc/passwd (or similar) which is OUTSIDE the
|
|
// base — and we must reject it. We use the canonicalised
|
|
// tempdir as the base, then construct a sibling escape path.
|
|
let (_keep, base) = tempdir_canon("base");
|
|
let sub = base.join("sub");
|
|
std::fs::create_dir(&sub).expect("mkdir sub");
|
|
|
|
let bases = vec![base.clone()];
|
|
let traversal = sub.join("../../etc/passwd");
|
|
let result = resolve_export_path(&traversal, &bases);
|
|
assert!(
|
|
result.is_err(),
|
|
"../../etc/passwd traversal must be rejected, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_path_inside_allowlist() {
|
|
let (_keep, base) = tempdir_canon("base");
|
|
let bases = vec![base.clone()];
|
|
let target = base.join("out.md");
|
|
let resolved = resolve_export_path(&target, &bases).expect("inside-base must be allowed");
|
|
assert!(
|
|
resolved.starts_with(&base),
|
|
"resolved path {resolved:?} must sit under base {base:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_nested_path_inside_allowlist() {
|
|
let (_keep, base) = tempdir_canon("base");
|
|
let nested = base.join("nested");
|
|
std::fs::create_dir(&nested).expect("mkdir nested");
|
|
let bases = vec![base.clone()];
|
|
let target = nested.join("out.md");
|
|
let resolved =
|
|
resolve_export_path(&target, &bases).expect("nested inside-base must be allowed");
|
|
assert!(resolved.starts_with(&base));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_when_parent_does_not_exist() {
|
|
let bases = vec![PathBuf::from("/")];
|
|
let result =
|
|
resolve_export_path(Path::new("/definitely-not-real-lumotia-fix/out.md"), &bases);
|
|
assert!(
|
|
result.is_err(),
|
|
"nonexistent parent must be rejected, got {result:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn is_inside_any_base_is_a_prefix_check() {
|
|
let a = PathBuf::from("/tmp/a");
|
|
let b = PathBuf::from("/tmp/b");
|
|
let bases = vec![a.clone(), b];
|
|
assert!(is_inside_any_base(&a.join("x.md"), &bases));
|
|
assert!(is_inside_any_base(&a.join("nested/x.md"), &bases));
|
|
assert!(!is_inside_any_base(Path::new("/tmp/other/x.md"), &bases));
|
|
}
|
|
|
|
/// Phase B.5 audit regression (2026-05-14). The Trust-1 fix
|
|
/// canonicalised only the parent of the target path because the file
|
|
/// typically does not exist yet. But if a symlink ALREADY exists at
|
|
/// the target path pointing outside the allowlisted base,
|
|
/// `tokio::fs::write` follows it on `File::create -> open(2)` and
|
|
/// silently writes through to the outside target. The path-string
|
|
/// containment check passed because the SYMLINK lives inside the
|
|
/// base, even though the symlink's target does not.
|
|
///
|
|
/// The fix canonicalises the full path when it exists (so the
|
|
/// symlink resolves before the containment check) and only falls
|
|
/// back to parent-canonicalize on NotFound. This regression test
|
|
/// pins that contract.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_symlink_target_outside_allowlist() {
|
|
let (_keep_base, base) = tempdir_canon("base");
|
|
let (_keep_out, outside) = tempdir_canon("outside");
|
|
// Real, writable file outside the base — the symlink target.
|
|
let outside_target = outside.join("victim.txt");
|
|
std::fs::write(&outside_target, b"original").expect("seed outside target");
|
|
|
|
// Inside-base symlink pointing to the outside file.
|
|
let symlink_path = base.join("notes.md");
|
|
std::os::unix::fs::symlink(&outside_target, &symlink_path)
|
|
.expect("create symlink inside base pointing outside");
|
|
|
|
let bases = vec![base.clone()];
|
|
let result = resolve_export_path(&symlink_path, &bases);
|
|
assert!(
|
|
result.is_err(),
|
|
"symlink target outside allowlist must be rejected, got {result:?}"
|
|
);
|
|
let err = result.err().unwrap();
|
|
assert!(
|
|
err.contains("outside the allowed export directories"),
|
|
"unexpected error shape: {err}"
|
|
);
|
|
}
|
|
|
|
/// Symmetric to the rejection above: a symlink whose target stays
|
|
/// inside the same base must still be accepted, so legitimate users
|
|
/// of in-base symlinks (e.g. a tilde-expansion alias inside the base)
|
|
/// are not regressed.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn accepts_symlink_target_inside_allowlist() {
|
|
let (_keep, base) = tempdir_canon("base");
|
|
let real_path = base.join("real.md");
|
|
std::fs::write(&real_path, b"original").expect("seed real target");
|
|
|
|
let symlink_path = base.join("alias.md");
|
|
std::os::unix::fs::symlink(&real_path, &symlink_path)
|
|
.expect("create in-base alias symlink");
|
|
|
|
let bases = vec![base.clone()];
|
|
let resolved = resolve_export_path(&symlink_path, &bases)
|
|
.expect("in-base symlink must resolve and pass");
|
|
assert!(resolved.starts_with(&base));
|
|
}
|
|
}
|