"""Path traversal defence for BrowserService.validate_path. The browser endpoint is the single most security-critical filesystem entry point in the app: it serves file contents and folder listings to the WebUI. A bypass here = arbitrary read of any file the server process can see. The current implementation signals rejection by *raising* (ValueError for traversal/NUL/unknown folder, FileNotFoundError for non-existent absolute paths). Either rejection mode is acceptable — these tests assert that the adversarial input never returns a path *inside* the configured base. """ from __future__ import annotations import tempfile from pathlib import Path from unittest.mock import patch import pytest from media_server.services.browser_service import BrowserService @pytest.fixture def tmp_media_folder(): """A real temp dir registered as a media folder for the test duration.""" with tempfile.TemporaryDirectory() as tmp: base = Path(tmp).resolve() (base / "ok.mp3").write_bytes(b"id3") (base / "sub").mkdir() (base / "sub" / "nested.mp3").write_bytes(b"id3") from media_server.config import MediaFolderConfig folders = {"test": MediaFolderConfig(path=str(base), label="Test", enabled=True)} with patch("media_server.services.browser_service.settings.media_folders", folders): yield base def _is_rejected(folder_id: str, rel: str) -> bool: """Helper: True iff validate_path either raises or returns None.""" try: result = BrowserService.validate_path(folder_id, rel) except (ValueError, FileNotFoundError, OSError): return True return result is None def test_validate_path_accepts_a_real_file(tmp_media_folder: Path): p = BrowserService.validate_path("test", "ok.mp3") assert p is not None assert p.is_file() # Defence-in-depth: resolved path must live inside the base. assert tmp_media_folder in p.resolve().parents or p.resolve().parent == tmp_media_folder def test_validate_path_accepts_nested(tmp_media_folder: Path): p = BrowserService.validate_path("test", "sub/nested.mp3") assert p is not None def test_unknown_folder_rejected(tmp_media_folder: Path): assert _is_rejected("ghost", "ok.mp3") def test_dotdot_traversal_rejected(tmp_media_folder: Path): assert _is_rejected("test", "../etc/passwd") assert _is_rejected("test", "..\\..\\Windows\\System32") assert _is_rejected("test", "sub/../../etc/passwd") def test_absolute_path_rejected(tmp_media_folder: Path): assert _is_rejected("test", "/etc/passwd") assert _is_rejected("test", "C:\\Windows\\System32") assert _is_rejected("test", "C:/Windows") def test_unc_path_rejected(tmp_media_folder: Path): assert _is_rejected("test", "\\\\server\\share") assert _is_rejected("test", "//server/share") def test_null_byte_rejected(tmp_media_folder: Path): assert _is_rejected("test", "ok.mp3\x00.png") assert _is_rejected("test", "sub\x00/nested.mp3")