From 20e0405814f72a91ff995dda72a30fe2fa558d3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 15:44:17 +0900 Subject: [PATCH 1/6] fix: guard project IDs before path joins --- .jules/sentinel.md | 5 ++ apps/desktop/src-tauri/src/main.rs | 77 +++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index c7a67127..c56cc2b2 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,8 @@ **Vulnerability:** CSV formula injection mitigation was naive, missing leading whitespace, tabs, and newlines. **Learning:** Checking `/^[=+\-@]/` is not sufficient, as OWASP states that spaces and tabs before the formula triggers will also execute the formula in applications like Excel. **Prevention:** Use a regex that allows leading whitespace (e.g. `/^[\s\uFEFF\xA0]*[=+\-@\t\r\n]/`) and include standalone tabs or new lines which are also injection vectors. + +## 2026-07-02 - Project ID path traversal guard +**Vulnerability:** Any project identifier that can reach a filesystem path join must be treated as untrusted, even when it is generated internally or passed through IPC lookup flows. +**Learning:** Reject only dangerous path segments (`.` and `..`) and path separators (`/` and `\`) so the guard blocks traversal without rejecting ordinary identifiers such as `my..id`. +**Prevention:** Keep project ID validation centralized before `base_root.join(project_id)`, and cover forward-slash, backslash, parent-component, and benign interior-dot cases in unit tests. diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2de7adde..88a7761d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -373,11 +373,24 @@ fn next_project_id(state: &AppState) -> String { ) } +fn is_valid_project_id(project_id: &str) -> bool { + let project_id = project_id.trim(); + !project_id.is_empty() + && project_id != "." + && project_id != ".." + && !project_id.contains('/') + && !project_id.contains('\\') +} + fn app_owned_root( app: &tauri::AppHandle, kind: &str, project_id: &str, ) -> Result { + if !is_valid_project_id(project_id) { + return Err("Invalid project ID: path traversal detected.".to_string()); + } + let base_root = match kind { "projects" => app .path() @@ -557,7 +570,7 @@ fn parse_request_payload(payload: Value) -> Result { let Some(project_id) = project_id else { return Err("Invalid analysis job request: invalid field 'projectId'".into()); }; - if project_id.trim().is_empty() { + if !is_valid_project_id(project_id) { return Err("Invalid analysis job request: invalid field 'projectId'".into()); } if local_source.is_some() { @@ -1311,6 +1324,68 @@ mod tests { }) } + fn local_audio_request(project_id: &str) -> Value { + json!({ + "sourceKind": "local_audio", + "projectId": project_id, + "sourceLabel": "My Song", + "roleFocus": ["lead-vocal"], + }) + } + + #[test] + fn project_id_validation_rejects_path_components_and_separators() { + for project_id in ["", " ", ".", "..", "../song", "set/song", "set\\song"] { + assert!(!is_valid_project_id(project_id)); + } + } + + #[test] + fn project_id_validation_allows_plain_identifier_with_dots() { + assert!(is_valid_project_id("my..id")); + } + + #[test] + fn parse_request_payload_rejects_project_id_parent_component() { + let result = parse_request_payload(local_audio_request("..")); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid analysis job request: invalid field 'projectId'" + ); + } + + #[test] + fn parse_request_payload_rejects_project_id_forward_slash() { + let result = parse_request_payload(local_audio_request("set/song")); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid analysis job request: invalid field 'projectId'" + ); + } + + #[test] + fn parse_request_payload_rejects_project_id_backslash() { + let result = parse_request_payload(local_audio_request("set\\song")); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid analysis job request: invalid field 'projectId'" + ); + } + + #[test] + fn parse_request_payload_allows_non_component_dots() { + let parsed = parse_request_payload(local_audio_request("my..id")) + .expect("plain identifiers with interior dots should remain valid"); + + assert_eq!(parsed.project_id.as_deref(), Some("my..id")); + } + #[test] fn rehearsal_song_payload_accepts_shared_section_contract() { let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); From 5aca428aa0f073722084cf21238f7fc123c6311c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 18:46:55 +0900 Subject: [PATCH 2/6] fix: normalize project IDs before path joins --- apps/desktop/src-tauri/src/main.rs | 86 +++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 88a7761d..3ae41ab1 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -373,13 +373,25 @@ fn next_project_id(state: &AppState) -> String { ) } +fn sanitize_project_id(project_id: &str) -> Option<&str> { + let trimmed = project_id.trim(); + if trimmed.is_empty() + || trimmed != project_id + || trimmed == "." + || trimmed == ".." + || trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.contains(':') + { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] fn is_valid_project_id(project_id: &str) -> bool { - let project_id = project_id.trim(); - !project_id.is_empty() - && project_id != "." - && project_id != ".." - && !project_id.contains('/') - && !project_id.contains('\\') + sanitize_project_id(project_id).is_some() } fn app_owned_root( @@ -387,9 +399,9 @@ fn app_owned_root( kind: &str, project_id: &str, ) -> Result { - if !is_valid_project_id(project_id) { + let Some(project_id) = sanitize_project_id(project_id) else { return Err("Invalid project ID: path traversal detected.".to_string()); - } + }; let base_root = match kind { "projects" => app @@ -570,19 +582,28 @@ fn parse_request_payload(payload: Value) -> Result { let Some(project_id) = project_id else { return Err("Invalid analysis job request: invalid field 'projectId'".into()); }; - if !is_valid_project_id(project_id) { - return Err("Invalid analysis job request: invalid field 'projectId'".into()); - } + let project_id = sanitize_project_id(project_id).ok_or_else(|| { + "Invalid analysis job request: invalid field 'projectId'".to_string() + })?; if local_source.is_some() { return Err("Invalid analysis job request: invalid field 'localSource'".into()); } + return Ok(AnalysisJobRequest { + source_kind: "local_audio".to_string(), + project_id: Some(project_id.to_string()), + source_label: source_label.to_string(), + role_focus: parsed_role_focus, + local_source, + cache_root: None, + temp_root: None, + }); } _ => {} } Ok(AnalysisJobRequest { source_kind: source_kind.unwrap_or("demo").to_string(), - project_id: project_id.map(|value| value.to_string()), + project_id: None, source_label: source_label.to_string(), role_focus: parsed_role_focus, local_source, @@ -1335,7 +1356,20 @@ mod tests { #[test] fn project_id_validation_rejects_path_components_and_separators() { - for project_id in ["", " ", ".", "..", "../song", "set/song", "set\\song"] { + for project_id in [ + "", + " ", + ".", + "..", + "../song", + "set/song", + "set\\song", + "C:tmp", + "C:..", + " song", + "song ", + "song\t", + ] { assert!(!is_valid_project_id(project_id)); } } @@ -1378,6 +1412,32 @@ mod tests { ); } + #[test] + fn parse_request_payload_rejects_project_id_windows_drive_prefix() { + for project_id in ["C:tmp", "C:.."] { + let result = parse_request_payload(local_audio_request(project_id)); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid analysis job request: invalid field 'projectId'" + ); + } + } + + #[test] + fn parse_request_payload_rejects_project_id_outer_whitespace() { + for project_id in [" project", "project ", "project\n"] { + let result = parse_request_payload(local_audio_request(project_id)); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid analysis job request: invalid field 'projectId'" + ); + } + } + #[test] fn parse_request_payload_allows_non_component_dots() { let parsed = parse_request_payload(local_audio_request("my..id")) From 3210bff845bc85cbdce5b61cb3212001450f62f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 15:20:17 +0900 Subject: [PATCH 3/6] fix: update anyhow for RustSec 2026-0190 --- apps/desktop/src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 4d9ae737..0df254ea 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "atk" From 43075cbbe0a9b32ef632a67d5b6103a55b1b7dd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 19:45:56 +0900 Subject: [PATCH 4/6] fix: document quick-xml advisory exceptions --- apps/desktop/src-tauri/.cargo/audit.toml | 2 ++ apps/desktop/src-tauri/osv-scanner.toml | 8 ++++++++ docs/security/dependency-policy.md | 1 + 3 files changed, 11 insertions(+) diff --git a/apps/desktop/src-tauri/.cargo/audit.toml b/apps/desktop/src-tauri/.cargo/audit.toml index 9fc2a4f3..861e0aa5 100644 --- a/apps/desktop/src-tauri/.cargo/audit.toml +++ b/apps/desktop/src-tauri/.cargo/audit.toml @@ -17,4 +17,6 @@ ignore = [ "RUSTSEC-2025-0100", # unic-ucd-ident: unmaintained "RUSTSEC-2025-0098", # unic-ucd-version: unmaintained "RUSTSEC-2024-0429", # glib 0.18.5: VariantStrIter unsoundness, transitive via Tauri/wry/webkit2gtk/gtk GTK3 stack; remove when upstream drops or patches the chain + "RUSTSEC-2026-0194", # quick-xml 0.39.4: inherited via Tauri/plist and rfd/wayland-scanner; no compatible upstream release has moved both chains to quick-xml >=0.41.0 yet + "RUSTSEC-2026-0195", # quick-xml 0.39.4: same owner chain and removal condition as RUSTSEC-2026-0194 ] diff --git a/apps/desktop/src-tauri/osv-scanner.toml b/apps/desktop/src-tauri/osv-scanner.toml index 16b3b20e..c8fc5e44 100644 --- a/apps/desktop/src-tauri/osv-scanner.toml +++ b/apps/desktop/src-tauri/osv-scanner.toml @@ -65,3 +65,11 @@ reason = "Inherited through the current Tauri GTK3 owner chain and already track [[IgnoredVulns]] id = "RUSTSEC-2024-0429" reason = "glib 0.18.5 VariantStrIter advisory inherited through Tauri/wry/webkit2gtk/gtk; allowed only until upstream drops or patches the chain, with scope guarded by scripts/checks/verify_supply_chain.py." + +[[IgnoredVulns]] +id = "RUSTSEC-2026-0194" +reason = "quick-xml 0.39.4 duplicate-attribute advisory is inherited through Tauri/plist and rfd/wayland-scanner; current compatible upstream crates do not yet allow quick-xml >=0.41.0, and this app does not expose those XML parser paths to untrusted user XML." + +[[IgnoredVulns]] +id = "RUSTSEC-2026-0195" +reason = "quick-xml 0.39.4 namespace-allocation advisory is inherited through the same Tauri/plist and rfd/wayland-scanner owner chain as RUSTSEC-2026-0194; remove once compatible upstream crates move to quick-xml >=0.41.0." diff --git a/docs/security/dependency-policy.md b/docs/security/dependency-policy.md index d3a9680e..d7c7acad 100644 --- a/docs/security/dependency-policy.md +++ b/docs/security/dependency-policy.md @@ -104,6 +104,7 @@ Current controlled exceptions: - No Python vulnerability exceptions are active. `GHSA-5239-wwwm-4pmq` (`Pygments <2.20.0`) was removed by locking `Pygments` to `2.20.0`; the CI `security-audit` workflow must run `pip-audit --local --strict` against the synced `uv` environment without a targeted ignore for that advisory. - Cargo audit warnings for legacy `gtk3` vulnerabilities (e.g. `RUSTSEC-2024-0413`) inherited through Tauri v2 `wry`/`webkit2gtk` integration are explicitly allowed. These are deep framework dependencies with no alternative, so they are documented exceptions and ignored by default. - `RUSTSEC-2024-0429` for `glib 0.18.5` is allowed only for the `VariantStrIter` advisory inherited through the Tauri/wry/webkit2gtk/gtk GTK3 stack. A compatible lockfile refresh can move the desktop stack to `tauri 2.11.3`, `wry 0.55.1`, `tao 0.35.3`, `muda 0.19.3`, and related transitive patches, but it still does not move this stack to patched `glib >=0.20.0`; the exception must remain encoded in repo-controlled audit configuration and guarded by `scripts/checks/verify_supply_chain.py`, and it must be removed when upstream drops or patches the chain. +- `RUSTSEC-2026-0194` and `RUSTSEC-2026-0195` for `quick-xml 0.39.4` are allowed only while the current compatible upstream owner chains still require vulnerable `quick-xml`: `plist 1.9.0` through Tauri, and `wayland-scanner 0.31.10` through Linux `rfd`/Wayland dependencies. `quick-xml >=0.41.0` is patched, but `plist 1.9.0` requires `quick-xml ^0.39.2` and the current `wayland-scanner` release also has no compatible patched path. BandScope does not expose either owner chain as a user-controlled XML ingestion surface; the exception must stay encoded in repo-controlled cargo-audit and OSV configuration, and must be removed once compatible upstream crates publish a patched dependency path. Retired third-party deprecation and advisory signal: From ca529d1acde4fc6cf3301b0135610aca26ebe086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 7 Jul 2026 12:04:40 +0900 Subject: [PATCH 5/6] fix: validate project IDs via Path::components across platforms Rework sanitize_project_id to reject Windows drive-letter prefixes and root markers using Path::components(): require exactly one Normal component and forbid path separators / the `:` drive marker so PathBuf joins can never replace the base path on any platform. Keep the mandatory no-trimming rule (reject when project_id != project_id.trim()) so validated IDs match the value used for filesystem joins byte-for-byte. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RjGVapDZ3k7V7zKYk16P4C --- apps/desktop/src-tauri/src/main.rs | 43 +++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 3ae41ab1..27645d0c 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; use std::{ collections::HashMap, io::{BufRead, BufReader, Read, Write}, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, process::{Command, Stdio}, sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, @@ -374,18 +374,35 @@ fn next_project_id(state: &AppState) -> String { } fn sanitize_project_id(project_id: &str) -> Option<&str> { - let trimmed = project_id.trim(); - if trimmed.is_empty() - || trimmed != project_id - || trimmed == "." - || trimmed == ".." - || trimmed.contains('/') - || trimmed.contains('\\') - || trimmed.contains(':') - { - None - } else { - Some(trimmed) + // Reject any ID whose surrounding whitespace (incl. \n/\t) would be + // silently persisted: validation and the value used for path joins must + // match exactly, so we forbid leading/trailing whitespace outright. + if project_id.is_empty() || project_id != project_id.trim() { + return None; + } + + // Reject path separators and the Windows drive-letter marker up front so + // the guard behaves identically on every platform. On Unix a string like + // `C:tmp` is a single `Normal` component, so `Path::components()` alone + // would accept it; the explicit `:` check keeps rejection consistent with + // Windows (where `C:tmp` is a drive-relative `Prefix` component). + if project_id.contains(['/', '\\', ':']) { + return None; + } + + // Validate structurally via `Path::components()` so that Windows drive + // prefixes (`C:tmp`, `C:..`) and root markers cannot slip past the + // separator checks and let `PathBuf::join` replace the base path. + let mut components = Path::new(project_id).components(); + let first = components.next()?; + if components.next().is_some() { + // More than one component (e.g. `set/song`, `a/b`): reject. + return None; + } + match first { + // The single component must be a plain name and must not be `..`/`.`. + Component::Normal(os) if os.to_str() == Some(project_id) => Some(project_id), + _ => None, } } From 826e67afea271bbd276fc6a221f147160f6c0dee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 7 Jul 2026 18:47:09 +0900 Subject: [PATCH 6/6] ci: re-trigger (prior run force-cancelled during runner jam) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RjGVapDZ3k7V7zKYk16P4C