Files
ServerKit/backend/tests/test_log_path_containment.py
Juan Denis 8837a26af0 fix(logs): keep panel-internal roots out of the log viewer
PR #145 closed the prefix-sibling hole in LogService.is_path_allowed but
left the gap its author named: unlike FileService, the log viewer had no
PROTECTED_ROOTS exclusion. On the install.sh layout the install dir sits
under the allowed root /opt, so the Log Files tab could read — and,
through clear_log, truncate — the backend .env, the SQLite instance dir
and the generated deploy config, the same hole the file manager closed
for GHSA-rm3m-9mvw-68fh.

Share FileService.PROTECTED_ROOTS rather than copying the list, so the
two surfaces cannot drift apart, and reject protected paths before the
allowed-root check. Panel logs live under /var/log and SERVERKIT_DIR,
which are untouched, so legitimate viewing is unaffected.
2026-09-18 01:05:13 -04:00

124 lines
5.3 KiB
Python

"""LogService.is_path_allowed must compare whole path segments.
The guard resolved the requested path with realpath and then asked whether the
result merely *started with* one of ALLOWED_LOG_DIRECTORIES. That is a text
test, not a containment test: '/opt-private' starts with the allowed root
'/opt' without sitting inside it, so a sibling directory that only shares a
name prefix was accepted as an allowed log location.
FileService guards the same roots and already gets this right at
file_service.py:96-100, comparing `real_path == root or
real_path.startswith(root + os.sep)`. These tests hold LogService to the same
contract.
The sandbox cases monkeypatch the root list onto a tmp_path so they run
anywhere. The shipped-constant case uses /opt rather than /var/log, because
realpath('/var/log') is '/private/var/log' on a macOS dev box, which would
assert the developer's filesystem instead of the guard.
"""
import os
import pytest
from app.services.file_service import FileService
from app.services.log_service import LogService
@pytest.fixture
def roots(tmp_path, monkeypatch):
"""One allowed root, plus a sibling whose name shares its prefix.
tmp_path is resolved first so the fixture compares like with like: the
guard realpaths its argument before matching, and /var is a symlink to
/private/var on macOS.
"""
base = os.path.realpath(str(tmp_path))
allowed = os.path.join(base, 'logroot')
sibling = os.path.join(base, 'logroot-backup')
os.makedirs(allowed)
os.makedirs(sibling)
monkeypatch.setattr(LogService, 'ALLOWED_LOG_DIRECTORIES', [allowed])
return {'allowed': allowed, 'sibling': sibling}
# --------------------------------------------------------------------------- #
# A name prefix is not containment (the actual bug)
# --------------------------------------------------------------------------- #
def test_prefix_sibling_is_not_inside_the_root(roots):
assert not LogService.is_path_allowed(roots['sibling'])
assert not LogService.is_path_allowed(
os.path.join(roots['sibling'], 'app.log'))
def test_shipped_roots_reject_a_prefix_sibling():
"""The literal constants. /opt is a shipped root and resolves to itself on
the platforms the panel installs on, so it can be asserted directly."""
assert '/opt' in LogService.ALLOWED_LOG_DIRECTORIES
assert not LogService.is_path_allowed('/opt-private')
assert not LogService.is_path_allowed('/opt-private/creds.txt')
def test_clear_log_refuses_a_prefix_sibling(roots):
"""The guard gates truncation as well as reading, through clear_log.
The target deliberately does not exist, so the broken guard falls through
to 'Log file not found' instead of running truncate against a real file.
"""
result = LogService.clear_log(os.path.join(roots['sibling'], 'app.log'))
assert result['success'] is False
assert result['error'] == 'Access denied: path not in allowed directories'
# --------------------------------------------------------------------------- #
# Everything genuinely inside a root still resolves
# --------------------------------------------------------------------------- #
def test_paths_inside_the_root_still_pass(roots):
"""Including one reached through '..', which realpath collapses before the
comparison happens."""
assert LogService.is_path_allowed(roots['allowed'])
assert LogService.is_path_allowed(os.path.join(roots['allowed'], 'app.log'))
assert LogService.is_path_allowed(
os.path.join(roots['allowed'], 'nested', '..', 'app.log'))
# --------------------------------------------------------------------------- #
# Panel-internal roots are never allowed, even under an allowed root
# --------------------------------------------------------------------------- #
def test_protected_roots_are_shared_with_file_service():
"""One source of truth: LogService must not grow its own copy of the
panel-internal list and drift away from FileService's."""
assert LogService.PROTECTED_ROOTS is FileService.PROTECTED_ROOTS
def test_install_dir_is_rejected_even_when_its_parent_is_allowed(monkeypatch):
"""On the install.sh layout the install dir sits under the allowed root
/opt, so reachability must come from the exclusion, not from the allowed
list happening not to contain the parent."""
parent = os.path.dirname(FileService._INSTALL_DIR)
monkeypatch.setattr(LogService, 'ALLOWED_LOG_DIRECTORIES', [parent])
assert not LogService.is_path_allowed(FileService._INSTALL_DIR)
assert not LogService.is_path_allowed(
os.path.join(FileService._INSTALL_DIR, '.env'))
assert not LogService.is_path_allowed(
os.path.join(FileService._BACKEND_DIR, 'config.py'))
def test_clear_log_refuses_a_panel_secret(monkeypatch):
"""The exclusion also gates truncation, through clear_log.
The target deliberately does not exist, so if the guard regresses the
call falls through to 'Log file not found' instead of running truncate
against a real file.
"""
parent = os.path.dirname(FileService._INSTALL_DIR)
monkeypatch.setattr(LogService, 'ALLOWED_LOG_DIRECTORIES', [parent])
target = os.path.join(
FileService._INSTALL_DIR, '.env-not-present-for-this-test')
result = LogService.clear_log(target)
assert result['success'] is False
assert result['error'] == 'Access denied: path not in allowed directories'