diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index e8b42e09..d100485d 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -17,6 +17,7 @@
"@tauri-apps/api": "^2.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dompurify": "^3.4.11",
"lucide-react": "^1.20.0",
"react": "^19.2.4",
"react-dom": "^19.2.7",
@@ -28,6 +29,7 @@
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
+ "@types/dompurify": "^3.0.5",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index b4a98a06..1ecbf36a 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -1,3 +1,4 @@
+import DOMPurify from "dompurify";
import { useState, useMemo, memo } from "react";
import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types";
import { RoleSwitcher } from "./RoleSwitcher";
@@ -354,9 +355,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{activeRoleAssignments.map((assignment) => (
-
{assignment.assignee}
-
{assignment.summary}
-
{formatStatusLabel(assignment.status)}
+
{DOMPurify.sanitize(assignment.assignee)}
+
{DOMPurify.sanitize(assignment.summary)}
+
{DOMPurify.sanitize(formatStatusLabel(assignment.status))}
))}
@@ -369,9 +370,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{activeRoleComments.map((comment) => (
-
{comment.author}
-
{comment.body}
-
{formatStatusLabel(comment.status)}
+
{DOMPurify.sanitize(comment.author)}
+
{DOMPurify.sanitize(comment.body)}
+
{DOMPurify.sanitize(formatStatusLabel(comment.status))}
))}
diff --git a/apps/desktop/src/setupTests.ts b/apps/desktop/src/setupTests.ts
index 8d791d27..bc898ed9 100644
--- a/apps/desktop/src/setupTests.ts
+++ b/apps/desktop/src/setupTests.ts
@@ -2,3 +2,7 @@ import { expect } from "vitest";
import * as matchers from "@testing-library/jest-dom/matchers";
expect.extend(matchers);
+
+// Mock DOMPurify to fix test failures where DOMPurify is undefined globally in tests
+import DOMPurify from 'dompurify';
+vi.stubGlobal('DOMPurify', DOMPurify);
diff --git a/package-lock.json b/package-lock.json
index 8a479475..27dd2f2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"@tauri-apps/api": "^2.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dompurify": "^3.4.11",
"lucide-react": "^1.20.0",
"react": "^19.2.4",
"react-dom": "^19.2.7",
@@ -42,6 +43,7 @@
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
+ "@types/dompurify": "^3.0.5",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@@ -2039,6 +2041,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -2080,6 +2092,13 @@
"csstype": "^3.2.2"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
@@ -2648,6 +2667,15 @@
"license": "MIT",
"peer": true
},
+ "node_modules/dompurify": {
+ "version": "3.4.11",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+ "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
diff --git a/services/analysis-engine/pyproject.toml b/services/analysis-engine/pyproject.toml
index b9c7ded1..912ac92d 100644
--- a/services/analysis-engine/pyproject.toml
+++ b/services/analysis-engine/pyproject.toml
@@ -10,7 +10,7 @@ requires-python = ">=3.12"
dependencies = [
"librosa>=0.11.0",
"numba<0.66.0",
- "numpy>=1.26.0",
+ "numpy>=1.26.4",
"soundfile>=0.13.1",
"urllib3>=2.7.0",
"yt-dlp>=2026.6.9",
diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py
index cb65e391..ab89224d 100644
--- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py
+++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py
@@ -132,7 +132,10 @@ def separate(self, audio_path: str | Path) -> AudioSeparationResult:
def _resolve_audio_file(self, audio_path: str | Path) -> Path:
"""Normalize and validate the selected source path."""
- candidate = Path(audio_path).expanduser()
+ path_str = str(audio_path)
+ if ".." in path_str:
+ raise ValueError("Path traversal sequences are not allowed")
+ candidate = Path(audio_path)
try:
path = candidate.resolve(strict=True)
except FileNotFoundError as error:
@@ -215,7 +218,7 @@ def _load_model_profile(self) -> dict[str, float]:
expected_sha256 = _BANDSPLIT_PROFILE_SHA256
if self.config.model_profile_path:
- profile_candidate = Path(self.config.model_profile_path).expanduser()
+ profile_candidate = Path(self.config.model_profile_path)
try:
profile_path = profile_candidate.resolve(strict=True)
except FileNotFoundError as error:
diff --git a/services/analysis-engine/tests/test_separation.py b/services/analysis-engine/tests/test_separation.py
index 81e27cc1..b86e0a44 100644
--- a/services/analysis-engine/tests/test_separation.py
+++ b/services/analysis-engine/tests/test_separation.py
@@ -458,3 +458,10 @@ def test_audio_stem_separator_rejects_non_finite_band_profile(tmp_path) -> None:
model_profile_sha256=checksum,
)
)
+
+
+def test_audio_stem_separator_rejects_path_traversal(tmp_path) -> None:
+ """Ensure path traversal attempts are rejected outright."""
+ separator = AudioStemSeparator(AudioSeparationConfig(target_sample_rate=8_000))
+ with pytest.raises(ValueError, match="Path traversal sequences are not allowed"):
+ separator.separate(tmp_path / ".." / "missing.wav")
diff --git a/services/analysis-engine/uv.lock b/services/analysis-engine/uv.lock
index c7ac9269..91713912 100644
--- a/services/analysis-engine/uv.lock
+++ b/services/analysis-engine/uv.lock
@@ -116,7 +116,7 @@ dev = [
requires-dist = [
{ name = "librosa", specifier = ">=0.11.0" },
{ name = "numba", specifier = "<0.66.0" },
- { name = "numpy", specifier = ">=1.26.0" },
+ { name = "numpy", specifier = ">=1.26.4" },
{ name = "soundfile", specifier = ">=0.13.1" },
{ name = "urllib3", specifier = ">=2.7.0" },
{ name = "yt-dlp", specifier = ">=2026.6.9" },