From e1c2a589da797ebb0f2feb338ffedd7ae4e1831b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 16:02:50 +0900 Subject: [PATCH 1/5] fix: cap YouTube URL input length --- .jules/sentinel.md | 5 +++++ apps/desktop/src-tauri/src/main.rs | 10 ++++++++++ apps/desktop/src/lib/analysis.test.ts | 17 +++++++++++++++++ apps/desktop/src/lib/analysis.ts | 4 ++++ .../src/bandscope_analysis/youtube.py | 5 +++++ services/analysis-engine/tests/test_youtube.py | 4 +++- 6 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index c7a67127..1564102a 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. + +## 2025-06-22 - URL Parsing Length Limit +**Vulnerability:** Unbounded URL inputs across TypeScript, Rust, and Python entry points. +**Learning:** Regular expressions and URL parsers can spend avoidable CPU or memory on oversized attacker-controlled strings. +**Prevention:** Cap URL length to the product-supported maximum before handing user input to regex or URL parsers. diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2de7adde..ee47cc26 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -34,6 +34,7 @@ const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); +const MAX_YOUTUBE_URL_LENGTH: usize = 2000; impl Default for AppState { fn default() -> Self { @@ -1057,6 +1058,10 @@ async fn import_youtube_url( } fn is_supported_youtube_url(url: &str) -> bool { + if url.len() > MAX_YOUTUBE_URL_LENGTH { + return false; + } + let parsed_url = match url::Url::parse(url) { Ok(u) => u, Err(_) => return false, @@ -1366,6 +1371,11 @@ mod tests { assert!(!is_supported_youtube_url( "https://youtube.com/watch?v=abc123DEF45&v=def456GHI78" )); + let oversized_url = format!( + "https://youtube.com/watch?v=abc123DEF45&x={}", + "a".repeat(MAX_YOUTUBE_URL_LENGTH) + ); + assert!(!is_supported_youtube_url(&oversized_url)); assert!(!is_supported_youtube_url("https://youtu.be/abc123")); assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); } diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index b443b41f..2bfe063f 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -196,6 +196,23 @@ describe("analysis bridge", () => { }); }); + it("rejects oversized YouTube URLs before crossing the Tauri bridge", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn(); + + const selection = await importYoutubeUrl( + `https://youtube.com/watch?v=4ozX4yFUC34&x=${"a".repeat(2000)}` + ); + + expect(tauriWindow.__TAURI_INVOKE__).not.toHaveBeenCalled(); + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Only standard YouTube URLs are supported." + } + }); + }); + it.each([ "https://youtube.com/watch?v=too-short", "https://youtube.com/watch?v=4ozX4yFUC3!", diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index f6ee7c00..5ff53867 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -43,6 +43,7 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ "Could not prepare the local temp workspace." ]); const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; +const MAX_YOUTUBE_URL_LENGTH = 2000; /** Documented. */ export type LocalAudioSelectionResult = @@ -74,6 +75,9 @@ export function isSupportedYoutubeUrl(rawUrl: unknown): rawUrl is string { if (typeof rawUrl !== "string") { return false; } + if (rawUrl.length > MAX_YOUTUBE_URL_LENGTH) { + return false; + } let parsedUrl: URL; try { diff --git a/services/analysis-engine/src/bandscope_analysis/youtube.py b/services/analysis-engine/src/bandscope_analysis/youtube.py index aa27d6f7..c98f4e51 100644 --- a/services/analysis-engine/src/bandscope_analysis/youtube.py +++ b/services/analysis-engine/src/bandscope_analysis/youtube.py @@ -15,6 +15,7 @@ import yt_dlp # type: ignore YOUTUBE_VIDEO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{11}$") +MAX_YOUTUBE_URL_LENGTH = 2000 SUPPORTED_AUDIO_EXTENSIONS = (".opus", ".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg") YOUTUBE_DOWNLOAD_FAILED_MESSAGE = ( "Failed to download audio from YouTube. Please use a local audio file instead." @@ -32,6 +33,10 @@ def validate_url(url: str) -> bool: Returns: True if the URL is valid, False otherwise. """ + # Pragmatic upper bound to avoid spending parser/downloader work on oversized user input. + if len(url) > MAX_YOUTUBE_URL_LENGTH: + return False + try: parsed = urllib.parse.urlparse(url) if parsed.scheme != "https": diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 71d7d60d..2b13ae78 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -7,7 +7,7 @@ import pytest import yt_dlp # type: ignore -from bandscope_analysis.youtube import download_youtube_audio, validate_url +from bandscope_analysis.youtube import MAX_YOUTUBE_URL_LENGTH, download_youtube_audio, validate_url def test_validate_url() -> None: @@ -16,6 +16,7 @@ def test_validate_url() -> None: assert validate_url("https://youtu.be/abc123DEF45") is True assert validate_url("https://www.youtube.com/watch?v=abc123DEF45") is True assert validate_url("https://www.youtube.com/watch?v=abc123DEF45&t=10") is True + long_query_url = "https://youtube.com/watch?v=abc123DEF45&x=" + ("a" * MAX_YOUTUBE_URL_LENGTH) assert validate_url("https://m.youtube.com/watch?v=abc123DEF45") is False assert validate_url("https://music.youtube.com/watch?v=abc123DEF45") is False @@ -34,6 +35,7 @@ def test_validate_url() -> None: assert validate_url("https://youtube.com/watch?v=abc123DEF45&v=") is False assert validate_url("https://youtube.com/watch?v=../../../etc/passwd") is False assert validate_url("https://youtu.be/../../../etc/passwd") is False + assert validate_url(long_query_url) is False def test_validate_url_edge_cases() -> None: From 88cf33d6bf7ba8811732201d25b00bc34e524ea1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 16:07:30 +0900 Subject: [PATCH 2/5] fix: share YouTube URL length cap with input --- apps/desktop/src/App.test.tsx | 6 ++++++ apps/desktop/src/App.tsx | 2 ++ apps/desktop/src/lib/analysis.ts | 2 ++ 3 files changed, 10 insertions(+) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index c039dfba..7e2b6d8a 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -223,6 +223,12 @@ describe("App", () => { expect(sourceControls).toHaveTextContent(/Import YouTube/i); }); + it("caps the YouTube URL input before import-path validation", () => { + render(); + + expect(screen.getByRole("textbox", { name: /YouTube URL/i })).toHaveAttribute("maxlength", "2000"); + }); + it("renders the loaded song as a dark rehearsal command board", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 24f5fb09..b633b662 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -35,6 +35,7 @@ import { importYoutubeUrl, isSupportedYoutubeUrl, loadProject, + MAX_YOUTUBE_URL_LENGTH, saveProject, subscribeToAnalysisJobUpdates, selectLocalAudioSource, @@ -605,6 +606,7 @@ export function App() { type="text" placeholder={t("youtubePlaceholder")} value={youtubeUrl} + maxLength={MAX_YOUTUBE_URL_LENGTH} onChange={(e) => setYoutubeUrl(e.target.value)} disabled={analysisInFlight || isStarting || isImporting} className="h-10 flex-1 border-0 bg-transparent text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300" diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 5ff53867..bb750b34 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -45,6 +45,8 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; +export { MAX_YOUTUBE_URL_LENGTH }; + /** Documented. */ export type LocalAudioSelectionResult = | { ok: true; bootstrap: ProjectBootstrapSummary } From c9ffefe27870ae2537287a5820767dde70b5ca56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 20:02:55 +0900 Subject: [PATCH 3/5] test: align youtube url length boundary coverage --- apps/desktop/src/lib/analysis.test.ts | 13 +++++++++---- services/analysis-engine/tests/test_youtube.py | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index 2bfe063f..e3347d1f 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; -import { getAnalysisJobStatus, importYoutubeUrl, startAnalysisJob } from "./analysis"; +import { + MAX_YOUTUBE_URL_LENGTH, + getAnalysisJobStatus, + importYoutubeUrl, + startAnalysisJob +} from "./analysis"; type TauriWindow = Window & { __TAURI_INTERNALS__?: unknown; @@ -198,10 +203,10 @@ describe("analysis bridge", () => { it("rejects oversized YouTube URLs before crossing the Tauri bridge", async () => { tauriWindow.__TAURI_INVOKE__ = vi.fn(); + const urlPrefix = "https://youtube.com/watch?v=4ozX4yFUC34&x="; + const oversizedUrl = `${urlPrefix}${"a".repeat(MAX_YOUTUBE_URL_LENGTH - urlPrefix.length + 1)}`; - const selection = await importYoutubeUrl( - `https://youtube.com/watch?v=4ozX4yFUC34&x=${"a".repeat(2000)}` - ); + const selection = await importYoutubeUrl(oversizedUrl); expect(tauriWindow.__TAURI_INVOKE__).not.toHaveBeenCalled(); expect(selection).toEqual({ diff --git a/services/analysis-engine/tests/test_youtube.py b/services/analysis-engine/tests/test_youtube.py index 2b13ae78..5531ac9d 100644 --- a/services/analysis-engine/tests/test_youtube.py +++ b/services/analysis-engine/tests/test_youtube.py @@ -16,8 +16,11 @@ def test_validate_url() -> None: assert validate_url("https://youtu.be/abc123DEF45") is True assert validate_url("https://www.youtube.com/watch?v=abc123DEF45") is True assert validate_url("https://www.youtube.com/watch?v=abc123DEF45&t=10") is True - long_query_url = "https://youtube.com/watch?v=abc123DEF45&x=" + ("a" * MAX_YOUTUBE_URL_LENGTH) + url_prefix = "https://youtube.com/watch?v=abc123DEF45&x=" + max_length_url = url_prefix + ("a" * (MAX_YOUTUBE_URL_LENGTH - len(url_prefix))) + long_query_url = max_length_url + "a" + assert validate_url(max_length_url) is True assert validate_url("https://m.youtube.com/watch?v=abc123DEF45") is False assert validate_url("https://music.youtube.com/watch?v=abc123DEF45") is False assert validate_url("https://evil.youtube.com/watch?v=abc123DEF45") is False From a334e57632de3933f2ee47a36cbb4822bfa41d89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 15:20:17 +0900 Subject: [PATCH 4/5] 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 d94a966bd13ad3d4b3a47213eed9eece2e89caf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 19:45:56 +0900 Subject: [PATCH 5/5] 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: