diff --git a/server/routers/filesystem.py b/server/routers/filesystem.py index cdf9bc536..819ddf84f 100644 --- a/server/routers/filesystem.py +++ b/server/routers/filesystem.py @@ -124,6 +124,23 @@ def get_blocked_paths() -> frozenset[Path]: return frozenset(blocked) +@functools.lru_cache(maxsize=1) +def get_home_dir() -> Path | None: + """Get the resolved home directory of the user running the server.""" + try: + return Path.home().resolve() + except (OSError, RuntimeError): + return None + + +def _is_within(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False + + def is_path_blocked(path: Path) -> bool: """Check if a path is in the blocked list.""" try: @@ -133,6 +150,17 @@ def is_path_blocked(path: Path) -> bool: blocked_paths = get_blocked_paths() + # The user's own home directory is always browsable, even when it lives + # under a blocked system path. This matters when the server runs as root + # (common in WSL and containers), where home is /root (issue #159). + # Inside home, only blocked paths nested within home (the sensitive + # credential directories such as ~/.ssh) apply. + home = get_home_dir() + if home is not None and _is_within(resolved, home): + blocked_paths = frozenset( + b for b in blocked_paths if b != home and _is_within(b, home) + ) + # Check if path is exactly a blocked path or inside one for blocked in blocked_paths: try: diff --git a/test_filesystem_blocklist.py b/test_filesystem_blocklist.py new file mode 100644 index 000000000..5d73d7f6d --- /dev/null +++ b/test_filesystem_blocklist.py @@ -0,0 +1,80 @@ +""" +Tests for the filesystem browser blocklist (server/routers/filesystem.py). + +Regression coverage for issue #159: when the server runs as root (WSL, +containers) the home directory is /root, which is also a blocked system +path. The user's own home must stay browsable while sensitive +subdirectories inside it remain blocked. +""" + +import asyncio +import sys +from pathlib import Path + +import pytest + +from server.routers import filesystem + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + """Point Path.home() at a temp dir and register it as a blocked system path.""" + home = tmp_path / "root" + home.mkdir() + (home / ".ssh").mkdir() + (home / "projects").mkdir() + + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setattr(filesystem, "LINUX_BLOCKED", {str(home), str(tmp_path / "etc")}) + monkeypatch.setattr(filesystem, "MACOS_BLOCKED", {str(home), str(tmp_path / "etc")}) + monkeypatch.setattr(filesystem, "WINDOWS_BLOCKED", {str(home), str(tmp_path / "etc")}) + filesystem.get_blocked_paths.cache_clear() + filesystem.get_home_dir.cache_clear() + yield home + filesystem.get_blocked_paths.cache_clear() + filesystem.get_home_dir.cache_clear() + + +def test_home_is_not_blocked_even_if_listed_as_system_path(fake_home): + assert not filesystem.is_path_blocked(fake_home) + + +def test_subdirectory_of_home_is_allowed(fake_home): + assert not filesystem.is_path_blocked(fake_home / "projects") + assert not filesystem.is_path_blocked(fake_home / "projects" / "new-app") + + +def test_sensitive_directory_in_home_still_blocked(fake_home): + assert filesystem.is_path_blocked(fake_home / ".ssh") + assert filesystem.is_path_blocked(fake_home / ".ssh" / "keys") + + +def test_other_system_paths_still_blocked(fake_home): + assert filesystem.is_path_blocked(fake_home.parent / "etc") + assert filesystem.is_path_blocked(fake_home.parent / "etc" / "passwd") + + +def test_home_nested_under_blocked_path_is_allowed(tmp_path, monkeypatch): + """A home like /var/lib/svc under blocked /var must remain browsable.""" + var = tmp_path / "var" + home = var / "lib" / "svc" + home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + attr = {"win32": "WINDOWS_BLOCKED", "darwin": "MACOS_BLOCKED"}.get(sys.platform, "LINUX_BLOCKED") + monkeypatch.setattr(filesystem, attr, {str(var)}) + filesystem.get_blocked_paths.cache_clear() + filesystem.get_home_dir.cache_clear() + try: + assert not filesystem.is_path_blocked(home) + assert filesystem.is_path_blocked(var) + assert filesystem.is_path_blocked(var / "log") + finally: + filesystem.get_blocked_paths.cache_clear() + filesystem.get_home_dir.cache_clear() + + +def test_list_directory_defaults_to_home(fake_home): + result = asyncio.run(filesystem.list_directory(path=None, show_hidden=False)) + assert result.current_path == fake_home.as_posix() + names = [e.name for e in result.entries] + assert "projects" in names