From 0fe707b2bf3fa747c3f84b987ad32e07fae4f6d6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 19 Jun 2026 21:06:57 +0000 Subject: [PATCH] fix(wiki): reject hardlinked page files in allowlist + read revalidation (#4375 Codex gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate-found (Codex CORE, repro'd): a hardlink at a clean *.md page name can carry an arbitrary inode (incl. one outside the wiki) through the O_NOFOLLOW + inode-identity read check — O_NOFOLLOW does not block hardlinks. Reject any page file with st_nlink > 1 in BOTH the allowlist walk and the read-path revalidation. Pre-existing on master (the inode recapture predates this PR's cache); closing it here while on the read path. +1 toothed regression test (prime cache -> hardlink listed page to outside secret -> assert 404, no leak). Co-authored-by: rodboev --- api/routes.py | 16 +++++++++++ tests/test_issue2941_wiki_browse.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/api/routes.py b/api/routes.py index 5d9d6192..2cbdfd8d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5917,6 +5917,16 @@ def _llm_wiki_page_files_uncached(wiki_path: Path) -> list[Path]: rel = item.relative_to(section) if not item.is_file() or not _is_clean_relpath(rel): continue + # Reject multi-link (hardlinked) page files. A hardlink at a + # clean *.md name can point at an arbitrary inode (incl. one + # outside the wiki); O_NOFOLLOW + inode-identity at read time + # cannot tell such a hardlink apart from the real page, so + # exclude any file with st_nlink > 1 from the allowlist. (#4375) + try: + if item.lstat().st_nlink > 1: + continue + except OSError: + continue # Resolve the real target and require it to live under BOTH the # real wiki root and the real section, with no dot-prefixed # segment on the resolved-relative path. This closes symlink @@ -6005,6 +6015,12 @@ def _llm_wiki_allowlisted_entries(wiki_path: Path) -> dict[str, tuple[Path, tupl resolved_target.relative_to(section_real) if not resolved_target.is_file(): continue + # Reject hardlinked targets (st_nlink > 1): a multi-link file at + # a clean page name can carry an arbitrary inode through the + # O_NOFOLLOW + identity read check. Defense in depth alongside + # the same rejection in the allowlist walk. (#4375) + if resolved_target.stat().st_nlink > 1: + continue rel_resolved = resolved_target.relative_to(wiki_real) if not _wiki_read_relpath_is_clean(rel_resolved): continue diff --git a/tests/test_issue2941_wiki_browse.py b/tests/test_issue2941_wiki_browse.py index 6eb06be0..1ba47b19 100644 --- a/tests/test_issue2941_wiki_browse.py +++ b/tests/test_issue2941_wiki_browse.py @@ -262,6 +262,49 @@ def test_wiki_page_cached_nested_entry_rechecks_resolved_containment(monkeypatch assert b"stale_cache_marker" not in handler.body, "stale cached nested symlink leaked hidden content" +def test_wiki_page_cached_entry_rejects_hardlink_swap_to_outside(monkeypatch, tmp_path): + """A hardlink swap to an outside file within the TTL must not leak (#4375). + + O_NOFOLLOW + inode-identity cannot distinguish a hardlink at a clean page + name from the real page, so multi-link (st_nlink > 1) page files are + rejected by both the allowlist walk and the read-path revalidation. Prime + the cache with a real page, replace it with a hardlink to an outside secret + within the TTL, and assert the read 404s without leaking the secret. + """ + import os as _os + from api import routes + + wiki_root = tmp_path / "wiki" + section = wiki_root / "concepts" + section.mkdir(parents=True) + page = section / "page.md" + page.write_text("# real\n", encoding="utf-8") + outside = tmp_path / "outside_secret" + outside.write_text("DONOTLEAK=hardlink_cache_marker\n", encoding="utf-8") + + 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 page is a genuine single-link file. + assert routes._llm_wiki_page_files(wiki_root) == [page] + + # Swap the listed page name to a hardlink pointing at the outside secret, + # within the TTL window (cache still warm for the page NAME). + page.unlink() + try: + _os.link(outside, page) + except (OSError, NotImplementedError, AttributeError): + import pytest + pytest.skip("hardlinks not supported on this platform") + + handler = _FakeHandler() + routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=concepts/page.md")) + + assert handler.status == 404, f"cached page hardlinked to outside file must 404, got {handler.status}" + assert b"hardlink_cache_marker" not in handler.body, "hardlink swap leaked outside content" + + def test_wiki_browse_cached_entry_rechecks_resolved_containment(monkeypatch, tmp_path): import os as _os from api import routes