Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions server/routers/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
80 changes: 80 additions & 0 deletions test_filesystem_blocklist.py
Original file line number Diff line number Diff line change
@@ -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