"""The host-browser sandbox. Two independent gates, both in :mod:`services.device_service`: * ``_is_allowed`` — is the logical path under one of ``ALLOWED_BROWSE_ROOTS``? * ``_real_root`` — maps the logical path into the container's view, and refuses anything landing inside StackPilot's own ``DATA_DIR``. The second gate exists because the API deliberately never hands out what lives there (agent tokens come back as a bool, destination secrets come back masked), so the file browser must not be the way around that — for admins either. """ from __future__ import annotations import pytest @pytest.fixture def sandbox(monkeypatch): """Pin the sandbox settings so the tests don't depend on deployment config.""" from config import settings from services import device_service monkeypatch.setattr(settings, "DATA_DIR", "/data", raising=False) monkeypatch.setattr(settings, "HOST_ROOT_PREFIX", "", raising=False) monkeypatch.setattr( settings, "ALLOWED_BROWSE_ROOTS", ["/mnt", "/media", "/srv", "/opt", "/home"], raising=False, ) return device_service def _refused(mod, path: str) -> bool: """Whether the sandbox rejects a path, by either gate.""" if not mod._is_allowed(path): return True try: mod._real_root(path) return False except mod.BrowseError: return True @pytest.mark.parametrize( "path", [ "/data", "/data/stackpilot.db", "/data/secret_key", "/opt/../data/stackpilot.db", # traversal into it ], ) def test_own_data_dir_is_refused(sandbox, path): assert _refused(sandbox, path), f"{path} would expose StackPilot's own database" @pytest.mark.parametrize( "path", ["/etc/shadow", "/root/.ssh/id_rsa", "/var/run/docker.sock", "/proc/self/environ"], ) def test_paths_outside_the_roots_are_refused(sandbox, path): assert _refused(sandbox, path) @pytest.mark.parametrize( "path", ["/opt", "/opt/stacks/jellyfin/.env", "/srv/media", "/mnt", "/home/someone"], ) def test_allowed_roots_stay_reachable(sandbox, path): assert not _refused(sandbox, path), f"{path} should still be browsable" def test_slash_in_the_roots_opens_everything_except_the_data_dir(sandbox, monkeypatch): """A "/" entry switches the sandbox off — that is why it is not a default. It still must not open StackPilot's own data directory, since that gate is independent of the root list. """ from config import settings monkeypatch.setattr(settings, "ALLOWED_BROWSE_ROOTS", ["/"], raising=False) assert not _refused(sandbox, "/etc/shadow") assert _refused(sandbox, "/data/stackpilot.db") def test_host_root_prefix_maps_paths_into_the_container(sandbox, monkeypatch): """With the host mounted at a prefix, /data is a host path, not our own. The container's own ``/data`` becomes unreachable by any logical path in this mode, so the refusal correctly does not apply. """ from config import settings monkeypatch.setattr(settings, "HOST_ROOT_PREFIX", "/host_root", raising=False) monkeypatch.setattr(settings, "ALLOWED_BROWSE_ROOTS", ["/data", "/opt"], raising=False) assert sandbox._real_root("/data/foo") == "/host_root/data/foo" def test_file_service_shares_the_same_gate(sandbox): """``file_service`` must not have its own, weaker path check.""" from services import file_service with pytest.raises(file_service.BrowseError): file_service._safe_real("/data/stackpilot.db") with pytest.raises(file_service.BrowseError): file_service._safe_real("/etc/shadow") assert file_service._safe_real("/opt/stacks") == "/opt/stacks" @pytest.mark.parametrize("name", ["..", ".", "a/b", "a\\b", ""]) def test_child_rejects_anything_but_a_single_component(sandbox, name): """Upload and rename build paths through ``_child``; traversal dies here.""" from services import file_service with pytest.raises(file_service.BrowseError): file_service._child("/opt", name)