fix(wiki): reassert cached read-path containment (#4375)

This commit is contained in:
Rod Boev
2026-06-18 22:34:51 -04:00
committed by nesquena-hermes
parent 3f6369ae5c
commit e44fd2a6ac
2 changed files with 51 additions and 1 deletions
+8 -1
View File
@@ -7365,6 +7365,10 @@ def handle_get(handler, parsed) -> bool:
full_path = Path(os.path.join(wiki_root, page_path))
if not _skill_path_within(Path(wiki_root), full_path):
return bad(handler, "Invalid path", status=400)
try:
wiki_real = Path(wiki_root).resolve()
except OSError:
return bad(handler, "Page not found", status=404)
# Only serve files the browse/list path would surface (same allowlist:
# *.md under the wiki page-dirs, no dotfiles, forbidden-roots guard).
# Without this the read endpoint could return ANY file inside the wiki
@@ -7380,9 +7384,12 @@ def handle_get(handler, parsed) -> bool:
for _p in _llm_wiki_page_files(Path(wiki_root)):
try:
rp = _p.resolve()
rel = rp.relative_to(wiki_real)
if any(part.startswith(".") for part in rel.parts):
continue
st0 = rp.stat()
allowed_identity[rp] = (st0.st_dev, st0.st_ino)
except OSError:
except (OSError, ValueError):
continue
except Exception:
allowed_identity = {}
+43
View File
@@ -233,6 +233,49 @@ def test_wiki_symlink_to_hidden_same_section_target_blocked(monkeypatch, tmp_pat
assert b"hidden_marker_q" not in h_page.body, "hidden secret leaked via symlink"
def test_wiki_page_cached_nested_entry_rechecks_resolved_containment(monkeypatch, tmp_path):
import os as _os
from api import routes
wiki_root = tmp_path / "wiki"
section = wiki_root / "concepts"
nested = section / "sub"
nested.mkdir(parents=True)
page = nested / "real.md"
page.write_text("# real\n", encoding="utf-8")
secret = wiki_root / ".env"
secret.write_text("DONOTLEAK=stale_cache_marker\n", encoding="utf-8")
try:
page.unlink()
page.symlink_to(_os.path.join("..", "..", ".env"))
except (OSError, NotImplementedError):
import pytest
pytest.skip("symlinks not supported on this platform")
routes._llm_wiki_clear_page_files_cache()
monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)
monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))
# Prime the cached name list while the nested page is still valid, then
# swap only the nested entry so the top-level section signature stays stale.
page.unlink()
page.write_text("# real\n", encoding="utf-8")
assert routes._llm_wiki_page_files(wiki_root) == [page]
sig_before = routes._llm_wiki_page_files_cache_signature(wiki_root.resolve())
page.unlink()
page.symlink_to(_os.path.join("..", "..", ".env"))
sig_after = routes._llm_wiki_page_files_cache_signature(wiki_root.resolve())
assert sig_after == sig_before
handler = _FakeHandler()
routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=concepts/sub/real.md"))
assert handler.status == 404, f"stale cached nested symlink swap must 404, got {handler.status}"
assert b"stale_cache_marker" not in handler.body, "stale cached nested symlink leaked secret content"
def test_wiki_symlinked_section_cannot_expose_outside_tree(monkeypatch, tmp_path):
"""A symlinked SECTION dir (concepts -> /tmp/outside) must not expose files
outside the real wiki root."""