Phase B.5 audit of commits a2b47db/a48653c/b3da58c (Trust-1 — write
path allowlist), 9653e25 (Trust-5 — transcribe_file extension allowlist
+ size cap), and ed449cc (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; b3da58c is 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>