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" },