fix(logs): compare whole segments in path guard - #145
Merged
jhd3197 merged 1 commit intoSep 18, 2026
Merged
Conversation
LogService.is_path_allowed resolved the requested path with realpath and then asked whether the result merely started with one of ALLOWED_LOG_DIRECTORIES. That is a text comparison rather than a containment test, so a directory whose name only begins with an allowed root was accepted as if it sat inside it. With the roots shipped at backend/app/services/log_service.py:30-36, both /opt-private and /homestead pass on the strength of /opt and /home, which is not what the comment above the list at line 29 says the list does. FileService guards the same roots and gets this right at backend/app/services/file_service.py:96-100, comparing real_path == root or real_path.startswith(root + os.sep). The same form appears at backend/app/api/deploy.py:197, backend/app/services/upload_service.py:66 and backend/app/services/linked_panel_agent.py:251. LogService does not, and its guard also gates truncation through clear_log at backend/app/services/log_service.py:367. The loop now compares against the separator-terminated root, so a sibling sharing a name prefix is rejected while everything genuinely inside a root still resolves, including paths reached through .. because realpath has already collapsed them. A regression test covering the shipped /opt constant, a sandboxed root and the clear_log door ships with it.
jhd3197
added a commit
that referenced
this pull request
Sep 18, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The log viewer treats a directory that merely shares a name prefix with an allowed root as though it sat inside that root. With the roots shipped at
backend/app/services/log_service.py:30-36, both/opt-private/creds.txtand/homestead/.ssh/id_rsaresolve as allowed log paths, on the strength of/optand/home.The cause is
backend/app/services/log_service.py:47.is_path_allowedresolves the request withos.path.realpathand then testsreal_path.startswith(allowed_dir). That compares text, not path segments, so it cannot tell/opt/app.logfrom/opt-private/creds.txt. The comment that introduces the list atlog_service.py:29calls it "Allowed directories for log file access (path traversal protection)", which is not what the loop underneath it implements. Line numbers throughout are as ofdevat d866de5.FileService is the exact sibling here rather than a distant analogy. It guards nearly the same roots (
backend/app/services/file_service.py:45) and returns a denial string identical to LogService's, character for character (file_service.py:123againstlog_service.py:81). It also gets the comparison right, atfile_service.py:96-100:real_path == root or real_path.startswith(root + os.sep). The same segment-wise form appears atbackend/app/api/deploy.py:197,backend/app/services/upload_service.py:66,backend/app/services/application_lifecycle_service.py:49,backend/app/services/manifest_apply_service.py:941andbackend/app/services/linked_panel_agent.py:251. LogService does not.One qualification on reach, so this is not read as more than it is. Every route in
backend/app/api/logs.pyis@admin_required(lines 9, 17, 32, 47, 58, 71 and 85), so this is not a privilege escalation and I am not presenting it as one. The guard is still worth correcting on its own terms: it also gates truncation atlog_service.py:367, whereclear_logrunstruncate -s 0against whatever it admits, and streaming atlog_service.py:409. A path that lands in a prefix sibling is currently accepted by all four doors, including the destructive one.The change is one line and a comment naming the case. One consequence worth naming: a
SERVERKIT_DIRset with a trailing slash no longer matches its own root, sincerealpathnever returns one andallowed_dir + os.sepbecomes a doubled separator; that fails closed, and it is exactly howfile_service.py:99-100already treats the same value. LogService has noPROTECTED_ROOTSexclusion of the kind FileService grew for GHSA-rm3m-9mvw-68fh; adding one is a separate logical change and this patch does not attempt it.Related Issues
None.
Type of Change
How Has This Been Tested?
The fix ships with
backend/tests/test_log_path_containment.py, four tests in the shape oftest_files_readonly_roots.py: a monkeypatchedtmp_pathroot with a prefix sibling beside it, one assertion against the literal shipped constants, and a no-regression case. The shipped-constant case asserts/optrather than/var/log, becauserealpath('/var/log')is/private/var/logon a macOS dev box and a/var/*assertion would test the developer's filesystem instead of the guard.The test earns its place. On an unmodified
devcheckout at d866de5 it gives3 failed, 1 passed.test_shipped_roots_reject_a_prefix_siblingfails onassert not True, where True = is_path_allowed('/opt-private').test_clear_log_refuses_a_prefix_siblingfails onassert 'Log file not found' == 'Access denied: path not in allowed directories', which is the useful one: the broken guard admits the path and falls through to the existence check below it, so the guard itself is demonstrably what lets the path in. That target deliberately names a file that does not exist, so even the failing run stops at the existence check and never reachestruncate.With the fix applied,
./venv/bin/python -m pytest tests/test_log_path_containment.py -qgives4 passed in 0.34s.Every other test file that touches LogService or the sibling guard, run together as
./venv/bin/python -m pytest tests/test_log_path_containment.py tests/test_files_rbac.py tests/test_app_observability_runtime.py tests/test_connect_logs.py tests/test_files_readonly_roots.py -q, gives121 passed.The new file is collected by CI shard 3, so I ran that shard exactly as the workflow does:
python -m pytest tests --splits 4 --group 3 -qgives1 failed, 1311 passed, 35 skipped, 3948 deselected in 134.15s. The single failure istests/test_probe_honesty_metrics.py::test_one_failed_section_does_not_blank_the_others, and it is not mine: it reproduces identically on an unmodifieddevcheckout at d866de5 with a clean tree, failing onisinstance(metrics['cpu'], dict)because the CPU probe returnsNoneon this machine. The other three shards carry four more failures that also reproduce identically on unmodifieddev:tests/test_api_surface_inventory.py::test_committed_surface_matches_served_surfaceon shard 1, which reportsnew operations: ['GET /connect/managed-profile']because thedevhead added that route without regeneratingdocs/API_SURFACE.md, and will be red on CI for any branch off that head;tests/test_backup_protection.py::test_smart_backup_files_incremental_roundtripand::test_full_backup_resets_snaron shard 1, because bsdtar on macOS has no--listed-incremental; andtests/test_fleet_doctor.py::test_dns_fail_when_unresolvedon shard 2.The ratchet scripts in
backend/tests/all stay inside their ceilings:check_test_count.pyreportscollected=5295 baseline=5279, error shape 1147 of 1150, JWT-only 608 of 608, identity door 69 of 69, raw subprocess 24 of 24, stub adoption 26 of 26, status sniffing 0 of 0, unreported crash 0 of 0. No floor bump was needed.Because the change touches
backend/app, I ran the Bandit gate asdev/dev.shdoes,bandit -r backend/app --ini backend/.bandit --severity-level medium. It reports 2 low, 13 medium and 0 high, which is the same result the unmodifieddevcheckout produces, and none of the findings are inlog_service.py.All runs were on macOS (Darwin 25.2.0) with Python 3.11.15 against
backend/venv. No route was added or removed, sodocs/API_SURFACE.mdis unchanged.Checklist
UnreleasedinCHANGELOG.md, or explained why this change needs no release note