From 0e95a4995bd8c2c28a48493722abfd99fe2d22c3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 20:58:15 +0100 Subject: [PATCH 01/49] feat(proxy): reload the upstream allowlist from HEADROOM_ALLOWED_BASE_URLS_FILE The guard's allowlist came only from HEADROOM_ALLOWED_BASE_URLS, which is fixed when the process starts, so an allowed endpoint that moves (a supervisor's own loopback port) is refused until the proxy restarts. HEADROOM_ALLOWED_BASE_URLS_FILE names a file of entries in the same format, merged with the variable's and re-read when its inode, mtime or size changes; an unchanged file costs one stat per check. Naming a file always means allowlist mode: an empty, missing or unreadable file admits only the variable's entries, and a read failure is logged once per distinct failure. Both sources now accept newlines as well as commas between entries. HEADROOM_PROXIED_BASE_URLS keeps no file form. --- headroom/proxy/upstream_guard.py | 177 ++++++++++++- headroom/proxy/upstream_pinning.py | 5 +- tests/test_upstream_guard_allowlist_file.py | 274 ++++++++++++++++++++ 3 files changed, 440 insertions(+), 16 deletions(-) create mode 100644 tests/test_upstream_guard_allowlist_file.py diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 1872895e8d..d6721f1647 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -9,10 +9,39 @@ * Default: reject destinations that resolve to private, loopback, link-local, or otherwise non-public addresses. Public hosts (api.openai.com, api.x.ai, Azure, ...) are allowed so ordinary BYOK keeps working. - * When ``HEADROOM_ALLOWED_BASE_URLS`` is set (comma-separated hosts or URLs), - bare hosts permit every safe scheme/port for that host, while URLs permit - only their exact normalized origin. Because that is an explicit operator - choice, allowlisted destinations may point at internal/on-prem endpoints. + * When ``HEADROOM_ALLOWED_BASE_URLS`` is set (hosts or URLs separated by + commas and/or newlines), bare hosts permit every safe scheme/port for that + host, while URLs permit only their exact normalized origin. Because that is + an explicit operator choice, allowlisted destinations may point at + internal/on-prem endpoints. + * ``HEADROOM_ALLOWED_BASE_URLS_FILE`` names a file holding more entries in the + same format, merged with the variable's. The variable is fixed for the + life of the process; the file is re-read whenever it changes, so a + supervisor whose own listed endpoint moves (a loopback port chosen at + start-up, say) can rewrite the file instead of restarting a proxy that + sessions are still using. Replace it atomically (write a sibling, then + rename over it): a change is detected by the file's identity (inode, + modification time and size), and an unchanged file costs one ``stat`` per + check, never a read. + +Naming a file is itself the operator's choice of allowlist mode, so it never +falls back to the public-host default, whatever state the file is in: + + * readable, with entries: allowlist mode, admitting the file's entries plus + the variable's. + * readable but empty (or holding no valid entry): allowlist mode admitting + only the variable's entries, and nothing at all when the variable is unset. + This follows the existing meaning of the two shapes ``allowlisted_destinations`` + returns: ``None`` is "no allowlist, apply the default policy", while an + empty list is "allowlist mode, nothing listed", which a variable holding + only separators already produces. A supervisor that truncates the file + before it knows its endpoints therefore gets a proxy that refuses every + caller-supplied upstream, not one that briefly admits every public host. + * missing or unreadable (permissions, a directory, not UTF-8): the same as + empty, logged at warning level once per distinct failure rather than on + every request, and logged again if it recurs after the file was read. + * the file variable set to blank: as if unset, mirroring how a blank + ``HEADROOM_ALLOWED_BASE_URLS`` is treated. Answering "is this safe?" is only half the job: the answer has to survive to the socket. Every accepted destination therefore has the addresses it was judged on @@ -38,8 +67,11 @@ import asyncio import ipaddress +import logging import os +import re import socket +import threading import time from concurrent.futures import ThreadPoolExecutor from concurrent.futures import TimeoutError as _FutureTimeout @@ -47,7 +79,10 @@ from dataclasses import dataclass from urllib.parse import urlparse +logger = logging.getLogger(__name__) + ALLOWED_BASE_URLS_ENV = "HEADROOM_ALLOWED_BASE_URLS" +ALLOWED_BASE_URLS_FILE_ENV = "HEADROOM_ALLOWED_BASE_URLS_FILE" # `socket.getaddrinfo` has no timeout parameter and runs on whatever thread # calls it -- which, for the proxy, is the event loop. A caller-supplied host @@ -205,16 +240,109 @@ def clear_validated_addresses() -> None: _PIN_SCOPE.set(None) -def allowlisted_destinations( - env: str = ALLOWED_BASE_URLS_ENV, -) -> tuple[set[str], set[tuple[str, str, int]]] | None: - """Parse a comma-separated host/URL list from ``env``; ``None`` when unset.""" - raw = os.environ.get(env) - if not raw or not raw.strip(): - return None +Destinations = tuple[frozenset[str], frozenset[tuple[str, str, int]]] +"""Bare hosts (any safe scheme and port) and exact ``(scheme, host, port)`` origins.""" + +_NO_DESTINATIONS: Destinations = (frozenset(), frozenset()) + + +@dataclass(frozen=True) +class _FileIdentity: + """What distinguishes one version of a file from another without reading it. + + A rename-replaced file has a new inode; an in-place rewrite changes the modification time and usually the size. ``st_mtime_ns`` rather than ``st_mtime`` so two writes within one coarse timestamp tick still differ on filesystems that record nanoseconds. + """ + + inode: int + mtime_ns: int + size: int + + @classmethod + def of(cls, st: os.stat_result) -> _FileIdentity: + return cls(st.st_ino, st.st_mtime_ns, st.st_size) + + +class _AllowlistFiles: + """Parsed allowlist files, re-read only when a file's identity changes. + + Thread-safe: the event loop and the resolver pool both reach the guard. The lock covers only the bookkeeping, never the ``stat`` or the read, so a slow filesystem cannot serialise every check behind one reader. Two threads that both see a change may both read the file; each records what it read against the identity of the descriptor it read from, so whichever lands last is still a consistent pair. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._loaded: dict[str, tuple[_FileIdentity, Destinations]] = {} + self._reported: dict[str, tuple[str, int | None]] = {} + + def load(self, path: str, file_env: str, env: str) -> Destinations | None: + """Return ``path``'s entries, or ``None`` when it cannot be read. + + ``file_env`` names the variable that pointed here and ``env`` the list + its entries are merged with; both are for the log lines only. + """ + try: + identity = _FileIdentity.of(os.stat(path)) + except OSError as exc: + self._report(path, exc, file_env, env) + return None + with self._lock: + cached = self._loaded.get(path) + if cached is not None and cached[0] == identity: + return cached[1] + try: + # The identity recorded is the descriptor's, not the earlier `stat`'s: a rename between the two would otherwise file the new contents under the old identity and never reload them. + with open(path, "rb") as handle: + identity = _FileIdentity.of(os.fstat(handle.fileno())) + raw = handle.read().decode("utf-8") + except (OSError, UnicodeDecodeError) as exc: + self._report(path, exc, file_env, env) + return None + entries = _parse_destinations(raw) + with self._lock: + self._loaded[path] = (identity, entries) + recovered = self._reported.pop(path, None) is not None + logger.info( + "%s: %s %s, %d host(s) and %d origin(s)", + file_env, + "read again after failing:" if recovered else "loaded", + path, + len(entries[0]), + len(entries[1]), + ) + return entries + + def _report(self, path: str, exc: Exception, file_env: str, env: str) -> None: + """Log a failure once, until it changes or the file is read again.""" + failure = (type(exc).__name__, getattr(exc, "errno", None)) + with self._lock: + # A file that cannot be read must not keep admitting what it used to list, so its last good contents are forgotten, not served. + self._loaded.pop(path, None) + if self._reported.get(path) == failure: + return + self._reported[path] = failure + logger.warning( + "%s: cannot read %s (%s). Until it can be read, only the entries in " + "%s are allowlisted (none when it is unset) and every other " + "x-headroom-base-url is rejected with 400.", + file_env, + path, + exc, + env, + ) + + +_ALLOWLIST_FILES = _AllowlistFiles() + +_SEPARATORS = re.compile(r"[,\n]") + + +def _parse_destinations(raw: str) -> Destinations: + """Parse hosts and URLs separated by commas and/or newlines. + + Entries that are neither a host nor a URL with a safe scheme are skipped, so a list of nothing valid is an empty allowlist, not an absent one. + """ hosts: set[str] = set() origins: set[tuple[str, str, int]] = set() - for item in raw.split(","): + for item in _SEPARATORS.split(raw): item = item.strip() if not item: continue @@ -233,11 +361,32 @@ def allowlisted_destinations( if port is None: port = 443 if parsed.scheme.lower() in {"https", "wss"} else 80 origins.add((parsed.scheme.lower(), parsed.hostname.lower(), port)) - return hosts, origins + return frozenset(hosts), frozenset(origins) + + +def allowlisted_destinations( + env: str = ALLOWED_BASE_URLS_ENV, + file_env: str | None = ALLOWED_BASE_URLS_FILE_ENV, +) -> Destinations | None: + """Return the operator's allowlist from ``env`` and the file ``file_env`` names. + + ``None`` means no allowlist at all: neither variable is set (or each is blank), and the caller applies its default policy. Anything else is allowlist mode, possibly with nothing listed. Once ``file_env`` names a file the result is never ``None``, even when the file is empty, missing or unreadable; see the module docstring for why. Pass ``file_env=None`` for a list that has no file form. + """ + raw = os.environ.get(env) + listed = _parse_destinations(raw) if raw and raw.strip() else None + if file_env is None: + return listed + path = (os.environ.get(file_env) or "").strip() + if not path: + return listed + from_file = _ALLOWLIST_FILES.load(path, file_env, env) or _NO_DESTINATIONS + if listed is None: + return from_file + return listed[0] | from_file[0], listed[1] | from_file[1] def destination_listed( - allow: tuple[set[str], set[tuple[str, str, int]]], + allow: Destinations, scheme: str, host: str, port: int | None, diff --git a/headroom/proxy/upstream_pinning.py b/headroom/proxy/upstream_pinning.py index d3274e402d..e516044e68 100644 --- a/headroom/proxy/upstream_pinning.py +++ b/headroom/proxy/upstream_pinning.py @@ -94,9 +94,10 @@ def proxied_upstream_listed(url: httpx.URL) -> bool: """Whether the operator listed ``url``'s destination in HEADROOM_PROXIED_BASE_URLS. Read per request, like the guard's own allowlist, so a test or an operator - changing the environment does not need a new client. + changing the environment does not need a new client. This list has no file + form: ``HEADROOM_ALLOWED_BASE_URLS_FILE`` extends only the guard's allowlist. """ - allow = allowlisted_destinations(PROXIED_BASE_URLS_ENV) + allow = allowlisted_destinations(PROXIED_BASE_URLS_ENV, file_env=None) return allow is not None and destination_listed(allow, url.scheme, url.host, url.port) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py new file mode 100644 index 0000000000..20b8cbeaba --- /dev/null +++ b/tests/test_upstream_guard_allowlist_file.py @@ -0,0 +1,274 @@ +"""Tests for HEADROOM_ALLOWED_BASE_URLS_FILE, the allowlist the guard re-reads at runtime. + +A supervisor that lists its own loopback endpoint cannot restart a proxy that sessions are still using when that endpoint's port changes, so the guard has to pick up a rewritten file on the next request. Every case uses IP literals, so no external network is needed. +""" + +from __future__ import annotations + +import logging +import os +import socket +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from headroom.proxy import upstream_guard +from headroom.proxy.server import ProxyConfig, create_app +from headroom.proxy.upstream_guard import is_safe_upstream_url +from headroom.proxy.upstream_pinning import proxied_upstream_listed + +FILE_ENV = "HEADROOM_ALLOWED_BASE_URLS_FILE" +LIST_ENV = "HEADROOM_ALLOWED_BASE_URLS" + + +def _replace(path: Path, text: str) -> None: + """Rewrite ``path`` the way a supervisor should: a sibling, then a rename.""" + staging = path.with_name(path.name + ".tmp") + staging.write_text(text, encoding="utf-8", newline="\n") + os.replace(staging, path) + + +@pytest.fixture +def allow_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "allowed-base-urls" + monkeypatch.setenv(FILE_ENV, str(path)) + return path + + +def test_a_listed_origin_is_admitted_and_others_are_refused(allow_file: Path) -> None: + _replace(allow_file, "http://127.0.0.1:4100\n") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + # Exact origin only, and allowlist mode refuses public hosts too. + assert is_safe_upstream_url("http://127.0.0.1:4101/v1") is False + assert is_safe_upstream_url("https://8.8.8.8/v1") is False + + +def test_a_replaced_file_is_picked_up_without_a_restart(allow_file: Path) -> None: + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False + + _replace(allow_file, "http://127.0.0.1:4200") + + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + + +def test_an_unchanged_file_is_not_read_again( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _replace(allow_file, "http://127.0.0.1:4100") + reads: list[str] = [] + real_open = open + + def counting_open(file: object, *args: object, **kwargs: object): # noqa: ANN202 + if str(file) == str(allow_file): + reads.append(str(file)) + return real_open(file, *args, **kwargs) # type: ignore[call-overload] + + monkeypatch.setattr(upstream_guard, "open", counting_open, raising=False) + + for _ in range(5): + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert len(reads) == 1 + + _replace(allow_file, "http://127.0.0.1:4100,http://127.0.0.1:4300") + assert is_safe_upstream_url("http://127.0.0.1:4300/v1") is True + assert len(reads) == 2 + + +def test_commas_and_newlines_both_separate_entries(allow_file: Path) -> None: + _replace(allow_file, "gateway.internal, http://127.0.0.1:4100\r\nhttps://llm.corp:8443\n\n") + + assert is_safe_upstream_url("https://gateway.internal/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("https://llm.corp:8443/v1") is True + + +def test_file_entries_are_merged_with_the_variable( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(LIST_ENV, "gateway.internal") + _replace(allow_file, "http://127.0.0.1:4100") + + assert is_safe_upstream_url("https://gateway.internal/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("https://8.8.8.8/v1") is False + + +def test_an_empty_file_is_an_empty_allowlist_not_the_default_policy( + allow_file: Path, +) -> None: + """Truncating the file must refuse everything, never admit every public host.""" + _replace(allow_file, "") + + assert is_safe_upstream_url("https://8.8.8.8/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + + +def test_a_missing_file_fails_closed_and_is_logged_once( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + + for _ in range(3): + assert is_safe_upstream_url("https://8.8.8.8/v1") is False + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert FILE_ENV in warnings[0].getMessage() + assert str(allow_file) in warnings[0].getMessage() + + # Once it can be read again it is honoured, and a later failure is a new one worth reporting. + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + allow_file.unlink() + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 2 + + +def test_an_unreadable_file_keeps_only_the_variable( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(LIST_ENV, "gateway.internal") + allow_file.mkdir() # exists, but cannot be read as a file + + assert is_safe_upstream_url("https://gateway.internal/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("https://8.8.8.8/v1") is False + + +def test_a_file_that_is_not_utf8_fails_closed(allow_file: Path) -> None: + allow_file.write_bytes(b"http://127.0.0.1:4100\xff\n") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("https://8.8.8.8/v1") is False + + +def test_entries_from_an_earlier_version_are_dropped_when_the_file_breaks( + allow_file: Path, +) -> None: + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + allow_file.write_bytes(b"\xff") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + + +def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(FILE_ENV, " ") + + assert is_safe_upstream_url("https://8.8.8.8/v1") is True + + +def test_the_proxied_list_does_not_read_the_file( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import httpx + + _replace(allow_file, "http://127.0.0.1:4100") + + assert proxied_upstream_listed(httpx.URL("http://127.0.0.1:4100/v1")) is False + + +def test_concurrent_checks_during_replacement_see_a_consistent_list(allow_file: Path) -> None: + """Every answer is a whole version of the file, never a torn or empty one.""" + ports = (4100, 4101) + expected = {(frozenset(), frozenset({("http", "127.0.0.1", port)})) for port in ports} + _replace(allow_file, f"http://127.0.0.1:{ports[0]}") + seen: list[object] = [] + stop = threading.Event() + + def check() -> None: + while not stop.is_set(): + allow = upstream_guard.allowlisted_destinations() + if allow not in expected: + seen.append(allow) + + threads = [threading.Thread(target=check) for _ in range(4)] + for thread in threads: + thread.start() + for n in range(200): + _replace(allow_file, f"http://127.0.0.1:{ports[n % 2]}") + stop.set() + for thread in threads: + thread.join() + + assert seen == [] + + +class _Upstream: + """A loopback upstream that records which paths reached it.""" + + def __init__(self) -> None: + self.hits: list[str] = [] + hits = self.hits + + class _Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + hits.append(self.path) + body = b'{"id":"msg_test","type":"message","role":"assistant","content":[]}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args: object) -> None: + return + + self._server = HTTPServer(("127.0.0.1", 0), _Handler) + self.port = int(self._server.server_address[1]) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + def __enter__(self) -> _Upstream: + self._thread.start() + return self + + def __exit__(self, *exc: object) -> None: + self._server.shutdown() + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_a_running_proxy_admits_an_upstream_once_the_file_lists_it(allow_file: Path) -> None: + """The scenario the file exists for: one app instance, no restart between requests.""" + _replace(allow_file, "http://127.0.0.1:1") + app = create_app( + ProxyConfig( + host="127.0.0.1", + port=_free_port(), + optimize=False, + cache_enabled=False, + rate_limit_enabled=False, + ) + ) + request = { + "model": "claude-test", + "max_tokens": 1, + "messages": [{"role": "user", "content": "hi"}], + } + + with _Upstream() as upstream, TestClient(app) as client: + headers = {"x-headroom-base-url": upstream.url, "x-api-key": "test"} + refused = client.post("/v1/messages", headers=headers, json=request) + assert refused.status_code == 400 + assert upstream.hits == [] + + _replace(allow_file, upstream.url) + admitted = client.post("/v1/messages", headers=headers, json=request) + + assert admitted.status_code == 200, admitted.text + assert upstream.hits == ["/v1/messages"] From 78644a915a58092ffb24dc0fa9f1cad435a314d2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 20:58:20 +0100 Subject: [PATCH 02/49] docs(configuration): document HEADROOM_ALLOWED_BASE_URLS_FILE Describe the entry format, how the file is reloaded and how it must be replaced, and what an empty, missing or unreadable file admits. --- docs/content/docs/configuration.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 93e49cae1f..81c6faaa04 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -197,6 +197,8 @@ reachable via `x-headroom-base-url` through an outbound proxy, name it in `HEADROOM_ALLOWED_BASE_URLS` — an explicit operator decision to admit that destination by name, which also turns off the resolve-and-pin check for it. +Entries are hosts (any port) or URLs (that exact origin), separated by commas and/or newlines. The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and name it in `HEADROOM_ALLOWED_BASE_URLS_FILE` instead, or as well: its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart. Replace it atomically (write a sibling file, then rename it over the original). Naming a file always means allowlist mode: if it is empty, missing or unreadable, only the variable's entries are admitted (none when the variable is unset), every other `x-headroom-base-url` is rejected with 400, and the proxy logs a warning naming the file and the error once per distinct failure. + ## SmartCrusher Configuration Fine-tune JSON compression behavior: From c4604a59069b65ee2630b78061113a5ec271a9cf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:16:24 +0100 Subject: [PATCH 03/49] fix(proxy): key the allowlist file cache on device and status-change time A same-size rewrite whose mtime is restored (cp -p, touch -r, os.utime or a coarse timestamp) kept the old inode, size and mtime, so the cached entries were served for the new contents. Revoking read permission changed none of them either, so a cached file stayed in force after chmod 000. The identity now also carries st_dev and st_ctime_ns; ctime cannot be set by a writer and moves on every write, chmod and utime. --- headroom/proxy/upstream_guard.py | 14 ++++--- tests/test_upstream_guard_allowlist_file.py | 43 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index d6721f1647..af3d79fd5a 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -20,9 +20,9 @@ supervisor whose own listed endpoint moves (a loopback port chosen at start-up, say) can rewrite the file instead of restarting a proxy that sessions are still using. Replace it atomically (write a sibling, then - rename over it): a change is detected by the file's identity (inode, - modification time and size), and an unchanged file costs one ``stat`` per - check, never a read. + rename over it): a change is detected by the file's identity (device, + inode, size, and modification and status-change times), and an unchanged + file costs one ``stat`` per check, never a read. Naming a file is itself the operator's choice of allowlist mode, so it never falls back to the public-host default, whatever state the file is in: @@ -250,16 +250,18 @@ def clear_validated_addresses() -> None: class _FileIdentity: """What distinguishes one version of a file from another without reading it. - A rename-replaced file has a new inode; an in-place rewrite changes the modification time and usually the size. ``st_mtime_ns`` rather than ``st_mtime`` so two writes within one coarse timestamp tick still differ on filesystems that record nanoseconds. + A rename-replaced file has a new inode (and, across a mount, a new device). An in-place rewrite changes the modification time, but that time is settable (``cp -p``, ``touch -r``, ``os.utime``) and coarse on some filesystems, so a same-size rewrite can leave it as it was. The status-change time cannot be set by a writer and moves on every write, ``chmod`` and ``utime``, so it catches both that rewrite and a permission change that should stop the file being read. Nanosecond fields, so two changes within one coarse tick still differ where the filesystem records nanoseconds. """ + device: int inode: int - mtime_ns: int size: int + mtime_ns: int + ctime_ns: int @classmethod def of(cls, st: os.stat_result) -> _FileIdentity: - return cls(st.st_ino, st.st_mtime_ns, st.st_size) + return cls(st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns) class _AllowlistFiles: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 20b8cbeaba..023dc42798 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -159,6 +159,49 @@ def test_entries_from_an_earlier_version_are_dropped_when_the_file_breaks( assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False +def test_a_same_size_rewrite_with_its_mtime_restored_is_noticed(allow_file: Path) -> None: + """``cp -p``, ``touch -r`` and coarse timestamps all leave mtime and size unchanged.""" + allow_file.write_text("http://127.0.0.1:4100", encoding="utf-8") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + before = allow_file.stat() + + with allow_file.open("r+", encoding="utf-8") as handle: + handle.write("http://127.0.0.1:4200") + os.utime(allow_file, ns=(before.st_atime_ns, before.st_mtime_ns)) + after = allow_file.stat() + assert (after.st_ino, after.st_size, after.st_mtime_ns) == ( + before.st_ino, + before.st_size, + before.st_mtime_ns, + ) + + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + + +@pytest.mark.skipif( + not hasattr(os, "geteuid") or os.geteuid() == 0, + reason="needs POSIX permissions that bind the current user (root reads mode 000 files)", +) +def test_revoking_read_permission_on_a_cached_file_takes_effect( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + allow_file.chmod(0) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + allow_file.chmod(0o600) + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "Permission denied" in warnings[0].getMessage() + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From 55a28dfdb779d32b561e5e73fe382266ecc9cde0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:17:17 +0100 Subject: [PATCH 04/49] fix(proxy): skip an allowlist entry urlparse cannot parse urlparse raises ValueError for an unbalanced IPv6 bracket ("[oops", "http://[::1") and for a netloc that NFKC normalisation turns into URL syntax, and the parser let it escape, so one such entry in HEADROOM_ALLOWED_BASE_URLS or its file failed every check instead of being ignored. Each entry is now parsed on its own and skipped when invalid. The file's load line reports how many were skipped (at warning level when any were), and the variable logs one warning per distinct value that holds any. --- headroom/proxy/upstream_guard.py | 80 +++++++++++++++------ tests/test_upstream_guard_allowlist_file.py | 38 ++++++++++ 2 files changed, 96 insertions(+), 22 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index af3d79fd5a..d77410507b 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -298,17 +298,20 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: except (OSError, UnicodeDecodeError) as exc: self._report(path, exc, file_env, env) return None - entries = _parse_destinations(raw) + entries, skipped = _parse_destinations(raw) with self._lock: self._loaded[path] = (identity, entries) recovered = self._reported.pop(path, None) is not None - logger.info( - "%s: %s %s, %d host(s) and %d origin(s)", + logger.log( + logging.WARNING if skipped else logging.INFO, + "%s: %s %s, %d host(s) and %d origin(s), %d invalid entr%s skipped", file_env, "read again after failing:" if recovered else "loaded", path, len(entries[0]), len(entries[1]), + skipped, + "y" if skipped == 1 else "ies", ) return entries @@ -337,33 +340,66 @@ def _report(self, path: str, exc: Exception, file_env: str, env: str) -> None: _SEPARATORS = re.compile(r"[,\n]") -def _parse_destinations(raw: str) -> Destinations: +def _parse_entry(item: str) -> str | tuple[str, str, int] | None: + """Return a bare host, an exact ``(scheme, host, port)`` origin, or ``None`` for neither.""" + try: + if "://" not in item: + hostname = urlparse(f"//{item}").hostname + return hostname.lower() if hostname else None + parsed = urlparse(item) + scheme = parsed.scheme.lower() + if scheme not in _SAFE_SCHEMES or not parsed.hostname: + return None + port = parsed.port + except ValueError: + # `urlparse` raises for an unbalanced IPv6 bracket ("[oops", "http://[::1") and for a netloc that NFKC normalisation would turn into URL syntax; `.port` raises for a non-numeric or out-of-range port. Each is one bad entry, skipped like any other, rather than an exception that takes every other entry, and every request, down with it. + return None + if port is None: + port = 443 if scheme in {"https", "wss"} else 80 + return scheme, parsed.hostname.lower(), port + + +def _parse_destinations(raw: str) -> tuple[Destinations, int]: """Parse hosts and URLs separated by commas and/or newlines. - Entries that are neither a host nor a URL with a safe scheme are skipped, so a list of nothing valid is an empty allowlist, not an absent one. + Returns the destinations and how many non-blank entries were skipped as invalid: neither a host nor a URL with a safe scheme, or not parseable at all. A list of nothing valid is an empty allowlist, not an absent one. """ hosts: set[str] = set() origins: set[tuple[str, str, int]] = set() + skipped = 0 for item in _SEPARATORS.split(raw): item = item.strip() if not item: continue - if "://" not in item: - parsed = urlparse(f"//{item}") - if parsed.hostname: - hosts.add(parsed.hostname.lower()) - continue - parsed = urlparse(item) - if parsed.scheme.lower() not in _SAFE_SCHEMES or not parsed.hostname: - continue - try: - port = parsed.port - except ValueError: - continue - if port is None: - port = 443 if parsed.scheme.lower() in {"https", "wss"} else 80 - origins.add((parsed.scheme.lower(), parsed.hostname.lower(), port)) - return frozenset(hosts), frozenset(origins) + entry = _parse_entry(item) + if entry is None: + skipped += 1 + elif isinstance(entry, str): + hosts.add(entry) + else: + origins.add(entry) + return (frozenset(hosts), frozenset(origins)), skipped + + +_SKIPPED_IN_VARIABLE: dict[str, str] = {} +_SKIPPED_IN_VARIABLE_LOCK = threading.Lock() + + +def _parse_variable(env: str, raw: str) -> Destinations: + """Parse ``env``'s value, warning once per distinct value that holds invalid entries. + + The variable is parsed on every check, so the warning is keyed on the value rather than logged per request. + """ + destinations, skipped = _parse_destinations(raw) + if skipped: + with _SKIPPED_IN_VARIABLE_LOCK: + already = _SKIPPED_IN_VARIABLE.get(env) == raw + _SKIPPED_IN_VARIABLE[env] = raw + if not already: + logger.warning( + "%s: skipped %d invalid entr%s", env, skipped, "y" if skipped == 1 else "ies" + ) + return destinations def allowlisted_destinations( @@ -375,7 +411,7 @@ def allowlisted_destinations( ``None`` means no allowlist at all: neither variable is set (or each is blank), and the caller applies its default policy. Anything else is allowlist mode, possibly with nothing listed. Once ``file_env`` names a file the result is never ``None``, even when the file is empty, missing or unreadable; see the module docstring for why. Pass ``file_env=None`` for a list that has no file form. """ raw = os.environ.get(env) - listed = _parse_destinations(raw) if raw and raw.strip() else None + listed = _parse_variable(env, raw) if raw and raw.strip() else None if file_env is None: return listed path = (os.environ.get(file_env) or "").strip() diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 023dc42798..fc819ed4b8 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -202,6 +202,44 @@ def test_revoking_read_permission_on_a_cached_file_takes_effect( assert "Permission denied" in warnings[0].getMessage() +_MALFORMED = pytest.mark.parametrize( + "bad", + [ + "[oops", # unbalanced IPv6 bracket, bare + "http://[::1", # unbalanced IPv6 bracket in a URL + "http://a℀b.example", # NFKC turns U+2100 into "a/c", URL syntax in the netloc + "a℀b.example", # the same, bare + ], +) + + +@_MALFORMED +def test_a_malformed_file_entry_is_skipped_not_fatal( + allow_file: Path, bad: str, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + _replace(allow_file, f"{bad}\nhttp://127.0.0.1:4100\n") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("https://8.8.8.8/v1") is False + loads = [r.getMessage() for r in caplog.records if "loaded" in r.getMessage()] + assert len(loads) == 1 + assert "1 invalid entry skipped" in loads[0] + + +@_MALFORMED +def test_a_malformed_variable_entry_is_skipped_not_fatal( + monkeypatch: pytest.MonkeyPatch, bad: str, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + monkeypatch.setenv(LIST_ENV, f"{bad},gateway.internal") + + for _ in range(3): + assert is_safe_upstream_url("https://gateway.internal/v1") is True + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert warnings == [f"{LIST_ENV}: skipped 1 invalid entry"] + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From 33aff2c8cf2786766f0155a99b843275e61dc5a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:18:25 +0100 Subject: [PATCH 05/49] fix(proxy): read only a bounded regular file as the allowlist A FIFO at the allowlist path blocked the open until a writer appeared, stalling the check on the resolver pool or event loop, and a device or an oversized file was read without limit. The file is opened with O_NONBLOCK, must be a regular file by fstat, and is refused whole past _MAX_ALLOWLIST_FILE_BYTES (1 MiB, derived beside the constant) rather than truncated into a different list. Each is reported like any other unreadable file: the variable's entries only, one warning. --- headroom/proxy/upstream_guard.py | 40 +++++++++-- tests/test_upstream_guard_allowlist_file.py | 74 ++++++++++++++++++--- 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index d77410507b..446282f0c2 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -71,6 +71,7 @@ import os import re import socket +import stat import threading import time from concurrent.futures import ThreadPoolExecutor @@ -264,6 +265,34 @@ def of(cls, st: os.stat_result) -> _FileIdentity: return cls(st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns) +# The longest entry worth listing is a URL origin: "https://", a 253-character DNS name (or a bracketed IPv6 literal, shorter), ":65535" and a separator, about 270 bytes. One MiB holds several thousand of those, orders of magnitude beyond any operator's allowlist, while bounding the memory and parse work one reload can cost. A file past it is a wrong path (a log, a binary) rather than a list, and it is refused whole: reading only a prefix could turn it into a different, valid-looking list. +_MAX_ALLOWLIST_FILE_BYTES = 1 << 20 + +# `os.open` flag that keeps a FIFO at the path from blocking the open until a writer appears. Windows has neither FIFOs nor the flag. +_O_NONBLOCK = getattr(os, "O_NONBLOCK", 0) + + +class _UnusableAllowlistFile(Exception): + """The path opened, but what it names must not be read as an allowlist.""" + + +def _read_allowlist_file(path: str) -> tuple[_FileIdentity, bytes]: + """Return the identity of the file read and its bytes. + + Raises ``OSError`` when it cannot be opened or read, and :class:`_UnusableAllowlistFile` when it is not a regular file (a FIFO would block the read until a writer appeared, a device could stream without end) or is larger than ``_MAX_ALLOWLIST_FILE_BYTES``. + """ + fd = os.open(path, os.O_RDONLY | _O_NONBLOCK) + with os.fdopen(fd, "rb") as handle: + st = os.fstat(handle.fileno()) + if not stat.S_ISREG(st.st_mode): + raise _UnusableAllowlistFile("not a regular file") + # One byte past the cap, so a file that grew after the `fstat` is still caught. + data = handle.read(_MAX_ALLOWLIST_FILE_BYTES + 1) + if len(data) > _MAX_ALLOWLIST_FILE_BYTES: + raise _UnusableAllowlistFile(f"larger than {_MAX_ALLOWLIST_FILE_BYTES} bytes") + return _FileIdentity.of(st), data + + class _AllowlistFiles: """Parsed allowlist files, re-read only when a file's identity changes. @@ -273,7 +302,7 @@ class _AllowlistFiles: def __init__(self) -> None: self._lock = threading.Lock() self._loaded: dict[str, tuple[_FileIdentity, Destinations]] = {} - self._reported: dict[str, tuple[str, int | None]] = {} + self._reported: dict[str, tuple[str, str]] = {} def load(self, path: str, file_env: str, env: str) -> Destinations | None: """Return ``path``'s entries, or ``None`` when it cannot be read. @@ -292,10 +321,9 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: return cached[1] try: # The identity recorded is the descriptor's, not the earlier `stat`'s: a rename between the two would otherwise file the new contents under the old identity and never reload them. - with open(path, "rb") as handle: - identity = _FileIdentity.of(os.fstat(handle.fileno())) - raw = handle.read().decode("utf-8") - except (OSError, UnicodeDecodeError) as exc: + identity, data = _read_allowlist_file(path) + raw = data.decode("utf-8") + except (OSError, UnicodeDecodeError, _UnusableAllowlistFile) as exc: self._report(path, exc, file_env, env) return None entries, skipped = _parse_destinations(raw) @@ -317,7 +345,7 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: def _report(self, path: str, exc: Exception, file_env: str, env: str) -> None: """Log a failure once, until it changes or the file is read again.""" - failure = (type(exc).__name__, getattr(exc, "errno", None)) + failure = (type(exc).__name__, str(exc)) with self._lock: # A file that cannot be read must not keep admitting what it used to list, so its last good contents are forgotten, not served. self._loaded.pop(path, None) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index fc819ed4b8..542a718627 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -31,6 +31,19 @@ def _replace(path: Path, text: str) -> None: os.replace(staging, path) +def _count_reads(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Record every path the guard actually opens and reads from here on.""" + reads: list[str] = [] + real_read = upstream_guard._read_allowlist_file + + def counting_read(path: str): # noqa: ANN202 + reads.append(path) + return real_read(path) + + monkeypatch.setattr(upstream_guard, "_read_allowlist_file", counting_read) + return reads + + @pytest.fixture def allow_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: path = tmp_path / "allowed-base-urls" @@ -61,15 +74,7 @@ def test_an_unchanged_file_is_not_read_again( allow_file: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _replace(allow_file, "http://127.0.0.1:4100") - reads: list[str] = [] - real_open = open - - def counting_open(file: object, *args: object, **kwargs: object): # noqa: ANN202 - if str(file) == str(allow_file): - reads.append(str(file)) - return real_open(file, *args, **kwargs) # type: ignore[call-overload] - - monkeypatch.setattr(upstream_guard, "open", counting_open, raising=False) + reads = _count_reads(monkeypatch) for _ in range(5): assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True @@ -240,6 +245,57 @@ def test_a_malformed_variable_entry_is_skipped_not_fatal( assert warnings == [f"{LIST_ENV}: skipped 1 invalid entry"] +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="needs FIFOs") +def test_a_fifo_at_the_path_is_refused_without_blocking( + allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Opening a FIFO for reading blocks until a writer appears; the check must not.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + monkeypatch.setenv(LIST_ENV, "gateway.internal") + os.mkfifo(allow_file) + results: list[bool] = [] + + def check() -> None: + results.append(is_safe_upstream_url("http://127.0.0.1:4100/v1")) + results.append(is_safe_upstream_url("https://gateway.internal/v1")) + + worker = threading.Thread(target=check, daemon=True) + worker.start() + worker.join(timeout=5) + + if worker.is_alive(): + # Release the blocked open so the daemon thread does not outlive the test. + with open(allow_file, "w", encoding="utf-8"): + pass + pytest.fail("the check blocked opening a FIFO") + assert results == [False, True] + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "not a regular file" in warnings[0] + + +def test_an_oversized_file_is_refused_whole_not_truncated( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Its first bytes alone would be a valid list; the file as a whole is not one.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + head = "http://127.0.0.1:4100\n" + padding = "\n" * (upstream_guard._MAX_ALLOWLIST_FILE_BYTES + 1 - len(head)) + _replace(allow_file, head + padding) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert f"larger than {upstream_guard._MAX_ALLOWLIST_FILE_BYTES} bytes" in warnings[0] + + +def test_a_file_exactly_at_the_size_cap_is_read(allow_file: Path) -> None: + head = "http://127.0.0.1:4100\n" + _replace(allow_file, head + "\n" * (upstream_guard._MAX_ALLOWLIST_FILE_BYTES - len(head))) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From a2b21300af4eacec495417ffe8f2594423a6bc6a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:18:57 +0100 Subject: [PATCH 06/49] fix(proxy): skip a bare allowlist host that carries a port A scheme-less entry such as 127.0.0.1:4100 was read as a bare host, and a bare host admits every port, so an entry naming one origin admitted every port on that host. It is now skipped as invalid, with the skip counted in the log; an exact origin is written as a URL. This narrows HEADROOM_ALLOWED_BASE_URLS and HEADROOM_PROXIED_BASE_URLS as well as the file, since all three share the parser: a host:port entry there no longer admits anything. --- headroom/proxy/upstream_guard.py | 14 +++++++----- tests/test_upstream_guard_allowlist_file.py | 24 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 446282f0c2..aa77431a4e 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -11,9 +11,10 @@ Azure, ...) are allowed so ordinary BYOK keeps working. * When ``HEADROOM_ALLOWED_BASE_URLS`` is set (hosts or URLs separated by commas and/or newlines), bare hosts permit every safe scheme/port for that - host, while URLs permit only their exact normalized origin. Because that is - an explicit operator choice, allowlisted destinations may point at - internal/on-prem endpoints. + host, while URLs permit only their exact normalized origin. A bare host + with a port (``127.0.0.1:4100``) is neither and is skipped as invalid. + Because that is an explicit operator choice, allowlisted destinations may + point at internal/on-prem endpoints. * ``HEADROOM_ALLOWED_BASE_URLS_FILE`` names a file holding more entries in the same format, merged with the variable's. The variable is fixed for the life of the process; the file is re-read whenever it changes, so a @@ -372,8 +373,11 @@ def _parse_entry(item: str) -> str | tuple[str, str, int] | None: """Return a bare host, an exact ``(scheme, host, port)`` origin, or ``None`` for neither.""" try: if "://" not in item: - hostname = urlparse(f"//{item}").hostname - return hostname.lower() if hostname else None + bare = urlparse(f"//{item}") + if bare.port is not None: + # A bare host admits every port, so "127.0.0.1:4100" read that way would admit far more than it names, and read as an origin it would have to guess the scheme. It is neither: the entry is invalid, and an exact origin is written as a URL. + return None + return bare.hostname.lower() if bare.hostname else None parsed = urlparse(item) scheme = parsed.scheme.lower() if scheme not in _SAFE_SCHEMES or not parsed.hostname: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 542a718627..7932e7947b 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -296,6 +296,30 @@ def test_a_file_exactly_at_the_size_cap_is_read(allow_file: Path) -> None: assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True +@pytest.mark.parametrize("bare", ["127.0.0.1:4100", "[::1]:4100", "gateway.internal:8443"]) +def test_a_bare_host_with_a_port_is_invalid_in_the_file( + allow_file: Path, bare: str, caplog: pytest.LogCaptureFixture +) -> None: + """Read as a bare host it would admit every port on that host.""" + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + _replace(allow_file, bare) + host = bare.rsplit(":", 1)[0] + + assert is_safe_upstream_url(f"http://{host}:4100/v1") is False + assert is_safe_upstream_url(f"http://{host}:9999/v1") is False + assert any("1 invalid entry skipped" in r.getMessage() for r in caplog.records) + + +def test_a_bare_host_with_a_port_is_invalid_in_the_variable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(LIST_ENV, "127.0.0.1:4100,gateway.internal") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:9999/v1") is False + assert is_safe_upstream_url("https://gateway.internal:8443/v1") is True + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From 1c8bf3ca8c4924ff5f27f25fa1a3fdf8c90b9203 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:19:36 +0100 Subject: [PATCH 07/49] feat(proxy): accept a BOM, tabs and carriage returns, and comments in the allowlist file A byte-order mark joined the first entry and made it invalid, so the file is decoded as utf-8-sig. Carriage returns and tabs now separate entries in the file and in both variables, so a file saved with old Mac line endings or tab-separated lists parse as written. In the file only, a line whose first non-blank character is # is ignored whole; a # entry in a variable stays invalid. --- headroom/proxy/upstream_guard.py | 15 ++++++--- tests/test_upstream_guard_allowlist_file.py | 35 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index aa77431a4e..d8bf7310be 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -323,11 +323,12 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: try: # The identity recorded is the descriptor's, not the earlier `stat`'s: a rename between the two would otherwise file the new contents under the old identity and never reload them. identity, data = _read_allowlist_file(path) - raw = data.decode("utf-8") + # utf-8-sig drops the byte-order mark some editors write; without it, the mark would join the first entry and make it invalid. + raw = data.decode("utf-8-sig") except (OSError, UnicodeDecodeError, _UnusableAllowlistFile) as exc: self._report(path, exc, file_env, env) return None - entries, skipped = _parse_destinations(raw) + entries, skipped = _parse_destinations(raw, comments=True) with self._lock: self._loaded[path] = (identity, entries) recovered = self._reported.pop(path, None) is not None @@ -366,7 +367,7 @@ def _report(self, path: str, exc: Exception, file_env: str, env: str) -> None: _ALLOWLIST_FILES = _AllowlistFiles() -_SEPARATORS = re.compile(r"[,\n]") +_SEPARATORS = re.compile(r"[,\n\r\t]") def _parse_entry(item: str) -> str | tuple[str, str, int] | None: @@ -391,14 +392,18 @@ def _parse_entry(item: str) -> str | tuple[str, str, int] | None: return scheme, parsed.hostname.lower(), port -def _parse_destinations(raw: str) -> tuple[Destinations, int]: - """Parse hosts and URLs separated by commas and/or newlines. +def _parse_destinations(raw: str, *, comments: bool = False) -> tuple[Destinations, int]: + """Parse hosts and URLs separated by commas, newlines, carriage returns or tabs. + + With ``comments``, a line whose first non-blank character is ``#`` is ignored whole. Only the file takes comments: a variable has no lines to comment out, and there a ``#`` entry stays an invalid one. Returns the destinations and how many non-blank entries were skipped as invalid: neither a host nor a URL with a safe scheme, or not parseable at all. A list of nothing valid is an empty allowlist, not an absent one. """ hosts: set[str] = set() origins: set[tuple[str, str, int]] = set() skipped = 0 + if comments: + raw = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#")) for item in _SEPARATORS.split(raw): item = item.strip() if not item: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 7932e7947b..b322d1fe4e 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -320,6 +320,41 @@ def test_a_bare_host_with_a_port_is_invalid_in_the_variable( assert is_safe_upstream_url("https://gateway.internal:8443/v1") is True +def test_a_byte_order_mark_is_ignored(allow_file: Path) -> None: + allow_file.write_bytes("\ufeffhttp://127.0.0.1:4100\n".encode()) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +def test_carriage_returns_and_tabs_separate_entries(allow_file: Path) -> None: + allow_file.write_bytes(b"http://127.0.0.1:4100\rhttp://127.0.0.1:4200\thttp://127.0.0.1:4300") + + for port in (4100, 4200, 4300): + assert is_safe_upstream_url(f"http://127.0.0.1:{port}/v1") is True + + +def test_whole_line_comments_are_ignored_in_the_file( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + _replace( + allow_file, + "# written by the supervisor\n # gateway.internal, http://127.0.0.1:4200\nhttp://127.0.0.1:4100\n", + ) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("https://gateway.internal/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False + assert any("0 invalid entries skipped" in r.getMessage() for r in caplog.records) + + +def test_a_hash_entry_in_the_variable_is_not_a_comment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(LIST_ENV, "#gateway.internal\nhttp://127.0.0.1:4100") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("https://gateway.internal/v1") is False + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From a1087560690bedd981a4c0d399ffa248c39e3e99 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:20:16 +0100 Subject: [PATCH 08/49] test(proxy): pin the allowlist cache to the identity of the bytes it read Nothing failed if the cache recorded the identity from the stat before the open instead of the descriptor's. A test now renames a new file in between and asserts the next check is served from the cache without a second read. The comment there claimed a lost reload; the real cost of the mistake is one needless re-read, and it now says so. The pop of cached entries on a read failure is removed: load already returns None while the failure lasts, and an identity that includes ctime only matches the same version of the file. --- headroom/proxy/upstream_guard.py | 9 +++++---- tests/test_upstream_guard_allowlist_file.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index d8bf7310be..f492775369 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -321,7 +321,7 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: if cached is not None and cached[0] == identity: return cached[1] try: - # The identity recorded is the descriptor's, not the earlier `stat`'s: a rename between the two would otherwise file the new contents under the old identity and never reload them. + # The identity recorded is the descriptor's, not the earlier `stat`'s. A rename between the two means the bytes read are the newer file's; filed under the older identity, the next check would see a mismatch and read the file again for nothing. identity, data = _read_allowlist_file(path) # utf-8-sig drops the byte-order mark some editors write; without it, the mark would join the first entry and make it invalid. raw = data.decode("utf-8-sig") @@ -346,11 +346,12 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: return entries def _report(self, path: str, exc: Exception, file_env: str, env: str) -> None: - """Log a failure once, until it changes or the file is read again.""" + """Log a failure once, until it changes or the file is read again. + + The last good entries stay cached and are not served while the failure lasts, since :meth:`load` returns ``None``. They are served again only if a later ``stat`` matches their identity exactly, and an identity that includes the status-change time matches only the same version of the same file: one whose entries they still are. + """ failure = (type(exc).__name__, str(exc)) with self._lock: - # A file that cannot be read must not keep admitting what it used to list, so its last good contents are forgotten, not served. - self._loaded.pop(path, None) if self._reported.get(path) == failure: return self._reported[path] = failure diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index b322d1fe4e..edd51d034b 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -355,6 +355,25 @@ def test_a_hash_entry_in_the_variable_is_not_a_comment(monkeypatch: pytest.Monke assert is_safe_upstream_url("https://gateway.internal/v1") is False +def test_the_identity_cached_is_the_one_read_not_the_one_first_stat_ed( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A rename between the guard's ``stat`` and its ``open`` must not cost a second read.""" + _replace(allow_file, "http://127.0.0.1:4100") + real_read = upstream_guard._read_allowlist_file + + def replaced_after_stat(path: str): # noqa: ANN202 + _replace(allow_file, "http://127.0.0.1:4200") + return real_read(path) + + monkeypatch.setattr(upstream_guard, "_read_allowlist_file", replaced_after_stat) + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is True + + reads = _count_reads(monkeypatch) + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is True + assert reads == [] + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From 3c124d88391e2d4c33b689fe4c33c02809e155b5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:21:41 +0100 Subject: [PATCH 09/49] feat(cli): add headroom proxy --allowed-base-urls-file A supervisor passing the allowlist file as an environment variable gets no error from a headroom that predates it, which then ignores the file. As an option, an older headroom fails with "No such option" instead. The option (envvar HEADROOM_ALLOWED_BASE_URLS_FILE, refusing a directory) is exported to the environment as an absolute path before the server starts, since the guard and any spawned worker read the environment. --- headroom/cli/proxy.py | 21 ++++++++ tests/test_upstream_guard_allowlist_file.py | 60 +++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index eff91b5b87..e5a6511476 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -263,6 +263,20 @@ def dashboard(port: int, no_open: bool) -> None: "replaced, and the socket is removed on shutdown." ), ) +@click.option( + "--allowed-base-urls-file", + default=None, + type=click.Path(dir_okay=False, resolve_path=False), + envvar="HEADROOM_ALLOWED_BASE_URLS_FILE", + help=( + "File of upstream hosts and URLs that x-headroom-base-url may name, in " + "the HEADROOM_ALLOWED_BASE_URLS format (commas, newlines or tabs between " + "entries, # comment lines), merged with that variable. Re-read whenever " + "it changes, with no restart; replace it atomically. Naming it means " + "allowlist mode even while it is empty, missing or unreadable " + "(env: HEADROOM_ALLOWED_BASE_URLS_FILE)." + ), +) @click.option( "--workers", default=1, @@ -1056,6 +1070,7 @@ def proxy( host: str, port: int, uds: str | None, + allowed_base_urls_file: str | None, workers: int, limit_concurrency: int, max_connections: int, @@ -1356,6 +1371,12 @@ def proxy( except UnixSocketUnusableError as exc: raise click.ClickException(str(exc)) from None + if allowed_base_urls_file is not None: + from headroom.proxy.upstream_guard import ALLOWED_BASE_URLS_FILE_ENV + + # The guard reads the environment on every check, in this process and in any worker it spawns, so the option is handed over there. Absolute, so a later change of working directory cannot repoint it; not symlink-resolved, so a supervisor may swap a link's target. + os.environ[ALLOWED_BASE_URLS_FILE_ENV] = os.path.abspath(allowed_base_urls_file) + config = ProxyConfig( host=host, port=port, diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index edd51d034b..502ed8c8ab 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -11,10 +11,13 @@ import threading from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path +from unittest.mock import patch import pytest +from click.testing import CliRunner, Result from fastapi.testclient import TestClient +from headroom.cli.main import main from headroom.proxy import upstream_guard from headroom.proxy.server import ProxyConfig, create_app from headroom.proxy.upstream_guard import is_safe_upstream_url @@ -487,3 +490,60 @@ def test_a_running_proxy_admits_an_upstream_once_the_file_lists_it(allow_file: P assert admitted.status_code == 200, admitted.text assert upstream.hits == ["/v1/messages"] + + +class TestCliOption: + """``headroom proxy --allowed-base-urls-file`` hands the path to the guard.""" + + def _invoke(self, args: list[str], env: dict[str, str | None]) -> tuple[Result, str | None]: + seen: dict[str, str | None] = {} + + def fake_run_server(config: object, **kwargs: object) -> None: + seen["file"] = os.environ.get(FILE_ENV) + + with patch("headroom.proxy.server.run_server", fake_run_server): + result = CliRunner().invoke( + main, + ["proxy", "--port", str(_free_port()), *args], + env={"HEADROOM_HOST": None, "HEADROOM_PORT": None, **env}, + ) + return result, seen.get("file") + + def test_the_option_reaches_the_guard_before_the_server_starts(self, tmp_path: Path) -> None: + path = tmp_path / "allowed-base-urls" + + result, seen = self._invoke(["--allowed-base-urls-file", str(path)], {FILE_ENV: None}) + + assert result.exit_code == 0, result.output + assert seen == str(path) + + def test_a_relative_path_is_made_absolute( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + + result, seen = self._invoke(["--allowed-base-urls-file", "allowed"], {FILE_ENV: None}) + + assert result.exit_code == 0, result.output + assert seen == os.path.join(os.getcwd(), "allowed") + + def test_the_environment_variable_is_the_option_s_default(self, tmp_path: Path) -> None: + path = tmp_path / "from-env" + + result, seen = self._invoke([], {FILE_ENV: str(path)}) + + assert result.exit_code == 0, result.output + assert seen == str(path) + + def test_a_directory_is_refused(self, tmp_path: Path) -> None: + result, seen = self._invoke(["--allowed-base-urls-file", str(tmp_path)], {FILE_ENV: None}) + + assert result.exit_code == 2 + assert "is a directory" in result.output + assert seen is None + + def test_the_option_is_in_the_help(self) -> None: + result = CliRunner().invoke(main, ["proxy", "--help"]) + + assert "--allowed-base-urls-file" in result.output + assert "HEADROOM_ALLOWED_BASE_URLS_FILE" in result.output From 7c76b9d4cb9586edb507588adc8f05e05662c83d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 22:22:02 +0100 Subject: [PATCH 10/49] docs(configuration): describe allowlist entry parsing and the file option Cover the separators, the bare host:port entry now being invalid (and what that changes for variable users), skipped unparseable entries, the file's comments and byte-order mark, --allowed-base-urls-file, how a change is detected, and which file states are refused. --- docs/content/docs/configuration.mdx | 6 +++++- headroom/proxy/upstream_guard.py | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 81c6faaa04..03b5d38932 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -197,7 +197,11 @@ reachable via `x-headroom-base-url` through an outbound proxy, name it in `HEADROOM_ALLOWED_BASE_URLS` — an explicit operator decision to admit that destination by name, which also turns off the resolve-and-pin check for it. -Entries are hosts (any port) or URLs (that exact origin), separated by commas and/or newlines. The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and name it in `HEADROOM_ALLOWED_BASE_URLS_FILE` instead, or as well: its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart. Replace it atomically (write a sibling file, then rename it over the original). Naming a file always means allowlist mode: if it is empty, missing or unreadable, only the variable's entries are admitted (none when the variable is unset), every other `x-headroom-base-url` is rejected with 400, and the proxy logs a warning naming the file and the error once per distinct failure. +Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, newlines, carriage returns or tabs. A bare host with a port, such as `127.0.0.1:4100`, is neither, and is skipped as invalid: write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such an entry was read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on it now admits nothing for that entry. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. + +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH` or name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, instead of the variable or as well. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus whole-line comments starting with `#` and an optional UTF-8 byte-order mark. Each load logs how many hosts and origins it found and how many invalid entries it skipped. + +Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device) or larger than 1 MiB, only the variable's entries are admitted (none when the variable is unset), every other `x-headroom-base-url` is rejected with 400, and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index f492775369..5a608e152e 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -10,13 +10,14 @@ or otherwise non-public addresses. Public hosts (api.openai.com, api.x.ai, Azure, ...) are allowed so ordinary BYOK keeps working. * When ``HEADROOM_ALLOWED_BASE_URLS`` is set (hosts or URLs separated by - commas and/or newlines), bare hosts permit every safe scheme/port for that + commas, newlines, carriage returns or tabs), bare hosts permit every safe scheme/port for that host, while URLs permit only their exact normalized origin. A bare host with a port (``127.0.0.1:4100``) is neither and is skipped as invalid. Because that is an explicit operator choice, allowlisted destinations may point at internal/on-prem endpoints. - * ``HEADROOM_ALLOWED_BASE_URLS_FILE`` names a file holding more entries in the - same format, merged with the variable's. The variable is fixed for the + * ``HEADROOM_ALLOWED_BASE_URLS_FILE`` (or ``headroom proxy + --allowed-base-urls-file``) names a file holding more entries in the same + format, plus ``#`` comment lines and an optional byte-order mark, merged with the variable's. The variable is fixed for the life of the process; the file is re-read whenever it changes, so a supervisor whose own listed endpoint moves (a loopback port chosen at start-up, say) can rewrite the file instead of restarting a proxy that @@ -38,8 +39,8 @@ only separators already produces. A supervisor that truncates the file before it knows its endpoints therefore gets a proxy that refuses every caller-supplied upstream, not one that briefly admits every public host. - * missing or unreadable (permissions, a directory, not UTF-8): the same as - empty, logged at warning level once per distinct failure rather than on + * missing or unusable (permissions, not UTF-8, not a regular file, larger + than ``_MAX_ALLOWLIST_FILE_BYTES``): the same as empty, logged at warning level once per distinct failure rather than on every request, and logged again if it recurs after the file was read. * the file variable set to blank: as if unset, mirroring how a blank ``HEADROOM_ALLOWED_BASE_URLS`` is treated. From aade003182ca9f8dbeecc1957c6cb7591414b618 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:06:47 +0100 Subject: [PATCH 11/49] fix(settings): register HEADROOM_ALLOWED_BASE_URLS_FILE in the settings registry The registry drift test requires every HEADROOM_* envvar on a proxy option to have a SettingField, and --allowed-base-urls-file had none. It joins the Endpoints group beside the custom upstream URLs: a plain path (not secret), captured at startup like every registry knob, so changing it needs a restart while the file's contents still reload live. --- headroom/settings_store.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/headroom/settings_store.py b/headroom/settings_store.py index 4e36fc73bb..9540c449a4 100644 --- a/headroom/settings_store.py +++ b/headroom/settings_store.py @@ -725,6 +725,21 @@ class SettingField: help="JSON object of extra headers merged into (and overriding) forwarded OpenAI requests.", tier="advanced", ), + SettingField( + "HEADROOM_ALLOWED_BASE_URLS_FILE", + "allowed_base_urls_file", + "Upstream allowlist file", + "Endpoints", + "str", + default=None, + help=( + "File of hosts and URLs that x-headroom-base-url may name, merged with " + "HEADROOM_ALLOWED_BASE_URLS. Setting it means allowlist mode, even while the " + "file is empty or missing. The path is read at startup; the file's contents " + "are re-read whenever it changes, with no restart." + ), + tier="advanced", + ), ) _BY_KEY: dict[str, SettingField] = {f.key: f for f in SETTINGS} From ac1e3397cdfbd0d85ad147a7d521034fecd70dcb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:10:27 +0100 Subject: [PATCH 12/49] fix(proxy): cache an unusable allowlist file and read each change once A file that was oversized, not UTF-8, unreadable or not a regular file was opened (and often read) on every check, since only a good version was cached. The version that failed is now cached by its identity like a good one, so it costs one stat per check until it changes. The type is checked from the stat before anything is opened, so a device or terminal named by mistake is never opened, and again on the descriptor for one swapped in afterwards; the open adds O_NOCTTY. The size is checked on the descriptor before reading. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so concurrent requests read and log a change once. --- headroom/proxy/upstream_guard.py | 122 +++++++++------ tests/test_upstream_guard_allowlist_file.py | 161 +++++++++++++++++++- 2 files changed, 236 insertions(+), 47 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 5a608e152e..401e624076 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -270,69 +270,109 @@ def of(cls, st: os.stat_result) -> _FileIdentity: # The longest entry worth listing is a URL origin: "https://", a 253-character DNS name (or a bracketed IPv6 literal, shorter), ":65535" and a separator, about 270 bytes. One MiB holds several thousand of those, orders of magnitude beyond any operator's allowlist, while bounding the memory and parse work one reload can cost. A file past it is a wrong path (a log, a binary) rather than a list, and it is refused whole: reading only a prefix could turn it into a different, valid-looking list. _MAX_ALLOWLIST_FILE_BYTES = 1 << 20 -# `os.open` flag that keeps a FIFO at the path from blocking the open until a writer appears. Windows has neither FIFOs nor the flag. +# `os.open` flags: O_NONBLOCK keeps a FIFO that appears at the path between the `stat` and the open from blocking the open until a writer arrives, and O_NOCTTY keeps a terminal that appears there from becoming the process's controlling terminal. Windows has neither flag, nor FIFOs or controlling terminals. _O_NONBLOCK = getattr(os, "O_NONBLOCK", 0) +_O_NOCTTY = getattr(os, "O_NOCTTY", 0) class _UnusableAllowlistFile(Exception): - """The path opened, but what it names must not be read as an allowlist.""" + """The file at the path cannot be used as an allowlist, and which version of it that was.""" + def __init__(self, reason: str, identity: _FileIdentity) -> None: + super().__init__(reason) + self.identity = identity -def _read_allowlist_file(path: str) -> tuple[_FileIdentity, bytes]: - """Return the identity of the file read and its bytes. - Raises ``OSError`` when it cannot be opened or read, and :class:`_UnusableAllowlistFile` when it is not a regular file (a FIFO would block the read until a writer appeared, a device could stream without end) or is larger than ``_MAX_ALLOWLIST_FILE_BYTES``. +def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: + """Return the identity of the file read and its text; ``st`` is the caller's ``stat`` of it. + + Raises :class:`_UnusableAllowlistFile` when it is not a regular file, cannot be opened or read, is larger than ``_MAX_ALLOWLIST_FILE_BYTES`` or is not UTF-8. The type is checked on ``st`` before anything is opened, so a device or terminal named by mistake is never opened, and again on the descriptor, for one swapped in after the ``stat``. The size is checked on the descriptor before anything is read. """ - fd = os.open(path, os.O_RDONLY | _O_NONBLOCK) + identity = _FileIdentity.of(st) + if not stat.S_ISREG(st.st_mode): + raise _UnusableAllowlistFile("not a regular file", identity) + try: + fd = os.open(path, os.O_RDONLY | _O_NONBLOCK | _O_NOCTTY) + except OSError as exc: + raise _UnusableAllowlistFile(str(exc), identity) from exc with os.fdopen(fd, "rb") as handle: st = os.fstat(handle.fileno()) + # The identity recorded is the descriptor's, not the caller's `stat`'s. A rename between the two means the bytes read are the newer file's; filed under the older identity, the next check would see a mismatch and read the file again for nothing. + identity = _FileIdentity.of(st) if not stat.S_ISREG(st.st_mode): - raise _UnusableAllowlistFile("not a regular file") - # One byte past the cap, so a file that grew after the `fstat` is still caught. - data = handle.read(_MAX_ALLOWLIST_FILE_BYTES + 1) + raise _UnusableAllowlistFile("not a regular file", identity) + too_large = _UnusableAllowlistFile( + f"larger than {_MAX_ALLOWLIST_FILE_BYTES} bytes", identity + ) + if st.st_size > _MAX_ALLOWLIST_FILE_BYTES: + raise too_large + try: + # One byte past the cap, so a file that grew after the `fstat` is still caught. + data = handle.read(_MAX_ALLOWLIST_FILE_BYTES + 1) + except OSError as exc: + raise _UnusableAllowlistFile(str(exc), identity) from exc if len(data) > _MAX_ALLOWLIST_FILE_BYTES: - raise _UnusableAllowlistFile(f"larger than {_MAX_ALLOWLIST_FILE_BYTES} bytes") - return _FileIdentity.of(st), data + raise too_large + try: + # utf-8-sig drops the byte-order mark some editors write; without it, the mark would join the first entry and make it invalid. + return identity, data.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise _UnusableAllowlistFile(f"not UTF-8 ({exc})", identity) from exc class _AllowlistFiles: - """Parsed allowlist files, re-read only when a file's identity changes. + """What each allowlist file held, re-read only when the file's identity changes. + + A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs one ``stat`` per check like a good one, not an open and a read on every request. - Thread-safe: the event loop and the resolver pool both reach the guard. The lock covers only the bookkeeping, never the ``stat`` or the read, so a slow filesystem cannot serialise every check behind one reader. Two threads that both see a change may both read the file; each records what it read against the identity of the descriptor it read from, so whichever lands last is still a consistent pair. + Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together; the wait is bounded by one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``. """ def __init__(self) -> None: self._lock = threading.Lock() - self._loaded: dict[str, tuple[_FileIdentity, Destinations]] = {} - self._reported: dict[str, tuple[str, str]] = {} + self._reload_lock = threading.Lock() + self._loaded: dict[str, tuple[_FileIdentity, Destinations | None]] = {} + self._reported: dict[str, str] = {} + + def _cached(self, path: str, identity: _FileIdentity) -> tuple[bool, Destinations | None]: + """``(True, what that version held)`` if ``identity`` is the version cached, else ``(False, None)``.""" + with self._lock: + cached = self._loaded.get(path) + if cached is not None and cached[0] == identity: + return True, cached[1] + return False, None def load(self, path: str, file_env: str, env: str) -> Destinations | None: - """Return ``path``'s entries, or ``None`` when it cannot be read. + """Return ``path``'s entries, or ``None`` when it cannot be used. ``file_env`` names the variable that pointed here and ``env`` the list its entries are merged with; both are for the log lines only. """ try: - identity = _FileIdentity.of(os.stat(path)) + st = os.stat(path) except OSError as exc: - self._report(path, exc, file_env, env) - return None - with self._lock: - cached = self._loaded.get(path) - if cached is not None and cached[0] == identity: - return cached[1] - try: - # The identity recorded is the descriptor's, not the earlier `stat`'s. A rename between the two means the bytes read are the newer file's; filed under the older identity, the next check would see a mismatch and read the file again for nothing. - identity, data = _read_allowlist_file(path) - # utf-8-sig drops the byte-order mark some editors write; without it, the mark would join the first entry and make it invalid. - raw = data.decode("utf-8-sig") - except (OSError, UnicodeDecodeError, _UnusableAllowlistFile) as exc: - self._report(path, exc, file_env, env) + self._report(path, str(exc), file_env, env) return None - entries, skipped = _parse_destinations(raw, comments=True) - with self._lock: - self._loaded[path] = (identity, entries) - recovered = self._reported.pop(path, None) is not None + identity = _FileIdentity.of(st) + hit, entries = self._cached(path, identity) + if hit: + return entries + with self._reload_lock: + # Another check may have read this very version while this one queued. + hit, entries = self._cached(path, identity) + if hit: + return entries + try: + identity, raw = _read_allowlist_file(path, st) + except _UnusableAllowlistFile as exc: + with self._lock: + self._loaded[path] = (exc.identity, None) + self._report(path, str(exc), file_env, env) + return None + entries, skipped = _parse_destinations(raw, comments=True) + with self._lock: + self._loaded[path] = (identity, entries) + recovered = self._reported.pop(path, None) is not None logger.log( logging.WARNING if skipped else logging.INFO, "%s: %s %s, %d host(s) and %d origin(s), %d invalid entr%s skipped", @@ -346,23 +386,19 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: ) return entries - def _report(self, path: str, exc: Exception, file_env: str, env: str) -> None: - """Log a failure once, until it changes or the file is read again. - - The last good entries stay cached and are not served while the failure lasts, since :meth:`load` returns ``None``. They are served again only if a later ``stat`` matches their identity exactly, and an identity that includes the status-change time matches only the same version of the same file: one whose entries they still are. - """ - failure = (type(exc).__name__, str(exc)) + def _report(self, path: str, reason: str, file_env: str, env: str) -> None: + """Log a failure once, until it changes or the file is read again.""" with self._lock: - if self._reported.get(path) == failure: + if self._reported.get(path) == reason: return - self._reported[path] = failure + self._reported[path] = reason logger.warning( "%s: cannot read %s (%s). Until it can be read, only the entries in " "%s are allowlisted (none when it is unset) and every other " "x-headroom-base-url is rejected with 400.", file_env, path, - exc, + reason, env, ) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 502ed8c8ab..6829fd3d7e 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -39,9 +39,9 @@ def _count_reads(monkeypatch: pytest.MonkeyPatch) -> list[str]: reads: list[str] = [] real_read = upstream_guard._read_allowlist_file - def counting_read(path: str): # noqa: ANN202 + def counting_read(path: str, st: os.stat_result): # noqa: ANN202 reads.append(path) - return real_read(path) + return real_read(path, st) monkeypatch.setattr(upstream_guard, "_read_allowlist_file", counting_read) return reads @@ -365,9 +365,9 @@ def test_the_identity_cached_is_the_one_read_not_the_one_first_stat_ed( _replace(allow_file, "http://127.0.0.1:4100") real_read = upstream_guard._read_allowlist_file - def replaced_after_stat(path: str): # noqa: ANN202 + def replaced_after_stat(path: str, st: os.stat_result): # noqa: ANN202 _replace(allow_file, "http://127.0.0.1:4200") - return real_read(path) + return real_read(path, st) monkeypatch.setattr(upstream_guard, "_read_allowlist_file", replaced_after_stat) assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is True @@ -377,6 +377,159 @@ def replaced_after_stat(path: str): # noqa: ANN202 assert reads == [] +def _break_oversized(path: Path) -> None: + path.write_text("\n" * (upstream_guard._MAX_ALLOWLIST_FILE_BYTES + 1), encoding="utf-8") + + +def _break_not_utf8(path: Path) -> None: + path.write_bytes(b"http://127.0.0.1:4100\xff") + + +def _break_unreadable(path: Path) -> None: + _replace(path, "http://127.0.0.1:4100") + path.chmod(0) + + +def _break_fifo(path: Path) -> None: + os.mkfifo(path) + + +def _break_directory(path: Path) -> None: + path.mkdir() + + +def _remove(path: Path) -> None: + if path.is_dir(): + path.rmdir() + else: + path.chmod(0o600) + path.unlink() + + +@pytest.mark.parametrize( + ("break_file", "opens_expected"), + [ + pytest.param(_break_oversized, 1, id="oversized"), + pytest.param(_break_not_utf8, 1, id="not-utf8"), + pytest.param( + _break_unreadable, + 1, + id="unreadable", + marks=pytest.mark.skipif( + not hasattr(os, "geteuid") or os.geteuid() == 0, + reason="needs POSIX permissions that bind the current user", + ), + ), + pytest.param( + _break_fifo, + 0, + id="fifo", + marks=pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="needs FIFOs"), + ), + pytest.param(_break_directory, 0, id="directory"), + ], +) +def test_an_unusable_file_is_tried_once_per_version_and_a_repair_is_picked_up( + allow_file: Path, + monkeypatch: pytest.MonkeyPatch, + break_file: object, + opens_expected: int, +) -> None: + """A broken file costs a ``stat`` per check, not an open and a read on every request.""" + assert callable(break_file) + break_file(allow_file) + reads = _count_reads(monkeypatch) + opens: list[str] = [] + real_open = os.open + + def counting_open(path: str, *args: object, **kwargs: object) -> int: + if os.fspath(path) == str(allow_file): + opens.append(os.fspath(path)) + return real_open(path, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(os, "open", counting_open) + + for _ in range(5): + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert len(reads) == 1 + assert len(opens) == opens_expected + + _remove(allow_file) + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert len(reads) == 2 + + +def test_an_oversized_file_is_refused_from_its_size_before_any_read( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _break_oversized(allow_file) + read_sizes: list[int] = [] + real_fdopen = os.fdopen + + class _RecordingHandle: + def __init__(self, handle: object) -> None: + self._handle = handle + + def __enter__(self) -> _RecordingHandle: + return self + + def __exit__(self, *exc: object) -> None: + self._handle.close() # type: ignore[attr-defined] + + def fileno(self) -> int: + return int(self._handle.fileno()) # type: ignore[attr-defined] + + def read(self, size: int = -1) -> bytes: + read_sizes.append(size) + return bytes(self._handle.read(size)) # type: ignore[attr-defined] + + monkeypatch.setattr( + os, "fdopen", lambda fd, *a, **k: _RecordingHandle(real_fdopen(fd, *a, **k)) + ) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert read_sizes == [] + + +def test_concurrent_checks_after_a_change_read_and_log_it_once( + allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + _replace(allow_file, "http://127.0.0.1:4200") + caplog.clear() + + threads_count = 8 + arrived = threading.Barrier(threads_count) + reads: list[str] = [] + real_read = upstream_guard._read_allowlist_file + + def slow_read(path: str, st: os.stat_result): # noqa: ANN202 + reads.append(path) + # Hold the reload long enough for every other check to stat the changed file and queue. + threading.Event().wait(0.2) + return real_read(path, st) + + monkeypatch.setattr(upstream_guard, "_read_allowlist_file", slow_read) + results: list[bool] = [] + + def check() -> None: + arrived.wait(timeout=5) + results.append(is_safe_upstream_url("http://127.0.0.1:4200/v1")) + + workers = [threading.Thread(target=check) for _ in range(threads_count)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=10) + + assert results == [True] * threads_count + assert len(reads) == 1 + assert len([r for r in caplog.records if "loaded" in r.getMessage()]) == 1 + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From 1eed2277d57821496e3b580fc388bf13101931ad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:11:53 +0100 Subject: [PATCH 13/49] fix(proxy): reject allowlist entries with whitespace and allow inline comments An entry containing whitespace (gw.example # prod, a.example b.example) was counted as valid but could never match. Any whitespace inside an entry now makes it invalid, so it is counted and reported. In the file, a # at the start or after a space, tab, newline or carriage return begins a comment running to the end of its line, so "http://gw.example:8080 # prod" and "http://gw.example:8080/ # prod" both list that origin. Lines end only at a newline or carriage return, the separators the variable uses; str.splitlines also split at NEL and the Unicode line and paragraph separators. --- headroom/proxy/upstream_guard.py | 10 ++- tests/test_upstream_guard_allowlist_file.py | 70 +++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 401e624076..583705fa0f 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -407,9 +407,15 @@ def _report(self, path: str, reason: str, file_env: str, env: str) -> None: _SEPARATORS = re.compile(r"[,\n\r\t]") +# A comment in the file: a "#" at the very start, or after a space, tab, newline or carriage return, through to the end of its line (the next newline or carriage return, the line-ending members of `_SEPARATORS`). A "#" anywhere else is part of an entry, which then fails to parse as one. +_COMMENT = re.compile(r"(?:^|(?<=[ \t\r\n]))#[^\r\n]*") + def _parse_entry(item: str) -> str | tuple[str, str, int] | None: """Return a bare host, an exact ``(scheme, host, port)`` origin, or ``None`` for neither.""" + if any(character.isspace() for character in item): + # A host or URL never contains whitespace, so "a.example b.example" or "gw.example # prod" in a variable is one entry that could never match anything. Counted as invalid, it is at least reported. + return None try: if "://" not in item: bare = urlparse(f"//{item}") @@ -433,7 +439,7 @@ def _parse_entry(item: str) -> str | tuple[str, str, int] | None: def _parse_destinations(raw: str, *, comments: bool = False) -> tuple[Destinations, int]: """Parse hosts and URLs separated by commas, newlines, carriage returns or tabs. - With ``comments``, a line whose first non-blank character is ``#`` is ignored whole. Only the file takes comments: a variable has no lines to comment out, and there a ``#`` entry stays an invalid one. + With ``comments``, a ``#`` at the start of the text or after a space, tab, newline or carriage return begins a comment that runs to the end of its line. Only the file takes comments: a variable has no lines to comment out, and there a ``#`` stays part of an entry, which is then invalid. Returns the destinations and how many non-blank entries were skipped as invalid: neither a host nor a URL with a safe scheme, or not parseable at all. A list of nothing valid is an empty allowlist, not an absent one. """ @@ -441,7 +447,7 @@ def _parse_destinations(raw: str, *, comments: bool = False) -> tuple[Destinatio origins: set[tuple[str, str, int]] = set() skipped = 0 if comments: - raw = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#")) + raw = _COMMENT.sub("", raw) for item in _SEPARATORS.split(raw): item = item.strip() if not item: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 6829fd3d7e..f32dac883e 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -530,6 +530,76 @@ def check() -> None: assert len([r for r in caplog.records if "loaded" in r.getMessage()]) == 1 +@pytest.mark.parametrize( + "entry", + [ + "http://gw.example:8080 # prod", + "http://gw.example:8080/ # prod", + "http://gw.example:8080\t#prod", + ], +) +def test_an_inline_comment_in_the_file_ends_the_entry(allow_file: Path, entry: str) -> None: + _replace(allow_file, entry + "\n") + + assert is_safe_upstream_url("http://gw.example:8080/v1") is True + + +def test_a_comment_runs_to_the_end_of_its_line_only(allow_file: Path) -> None: + allow_file.write_bytes(b"# first, http://127.0.0.1:4200\rhttp://127.0.0.1:4100 # a, b\n") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False + + +def test_a_hash_after_a_comma_is_an_entry_not_a_comment( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100,#http://127.0.0.1:4200\n") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False + assert any("1 invalid entry skipped" in r.getMessage() for r in caplog.records) + + +@pytest.mark.parametrize( + "entry", ["gw.example # prod", "a.example b.example", "http://gw.example:8080 # prod"] +) +def test_whitespace_inside_a_variable_entry_makes_it_invalid( + monkeypatch: pytest.MonkeyPatch, entry: str, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + monkeypatch.setenv(LIST_ENV, f"{entry},gateway.internal") + + assert is_safe_upstream_url("https://gateway.internal/v1") is True + assert is_safe_upstream_url("https://gw.example/v1") is False + assert is_safe_upstream_url("http://gw.example:8080/v1") is False + assert [r.getMessage() for r in caplog.records] == [f"{LIST_ENV}: skipped 1 invalid entry"] + + +def test_whitespace_inside_a_file_entry_makes_it_invalid( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "a.example b.example\nhttp://127.0.0.1:4100\n") + + assert is_safe_upstream_url("https://a.example/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert any("1 invalid entry skipped" in r.getMessage() for r in caplog.records) + + +def test_only_newline_and_carriage_return_end_a_comment(allow_file: Path) -> None: + """``str.splitlines`` would also end a comment at U+2028, U+2029 or NEL.""" + _replace( + allow_file, + "# note\u2028http://127.0.0.1:4200\x85http://127.0.0.1:4300\nhttp://127.0.0.1:4100\n", + ) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4300/v1") is False + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From 08758794bbada8c562f237dfc7b9f3ca3b6f6580 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:12:57 +0100 Subject: [PATCH 14/49] fix(proxy): accept a scheme-less allowlist entry only as a bare hostname 127.0.0.1: (an empty port), gw.internal/v1 and user@gw.internal still parsed to a bare host and admitted every port on it. A scheme-less entry with an empty or real port, a path, userinfo, a query or a fragment is now invalid and counted as skipped, in the file and in both variables. --- headroom/proxy/upstream_guard.py | 20 +++++++++--- tests/test_upstream_guard_allowlist_file.py | 35 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 583705fa0f..c8b2c1bb57 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -411,6 +411,20 @@ def _report(self, path: str, reason: str, file_env: str, env: str) -> None: _COMMENT = re.compile(r"(?:^|(?<=[ \t\r\n]))#[^\r\n]*") +def _parse_bare_host(item: str) -> str | None: + """Return ``item`` as a lowercased host if it is a hostname and nothing else. + + A bare host admits every port on that host, so anything more than the name is refused rather than read as one: a port, even an empty one (``127.0.0.1:4100``, ``127.0.0.1:``), would admit far more than it names; a path (``gw.internal/v1``), userinfo (``user@gw.internal``), query or fragment means the entry was meant as something it is not. An exact origin is written as a URL. Raises ``ValueError`` where ``urlparse`` does. + """ + parsed = urlparse(f"//{item}") + if parsed.netloc != item or "@" in item or not parsed.hostname: + return None + # Outside the brackets of an IPv6 literal, a colon can only introduce a port. + if ":" in (item[item.index("]") + 1 :] if item.startswith("[") else item): + return None + return parsed.hostname.lower() + + def _parse_entry(item: str) -> str | tuple[str, str, int] | None: """Return a bare host, an exact ``(scheme, host, port)`` origin, or ``None`` for neither.""" if any(character.isspace() for character in item): @@ -418,11 +432,7 @@ def _parse_entry(item: str) -> str | tuple[str, str, int] | None: return None try: if "://" not in item: - bare = urlparse(f"//{item}") - if bare.port is not None: - # A bare host admits every port, so "127.0.0.1:4100" read that way would admit far more than it names, and read as an origin it would have to guess the scheme. It is neither: the entry is invalid, and an exact origin is written as a URL. - return None - return bare.hostname.lower() if bare.hostname else None + return _parse_bare_host(item) parsed = urlparse(item) scheme = parsed.scheme.lower() if scheme not in _SAFE_SCHEMES or not parsed.hostname: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index f32dac883e..cc1ec3e0bc 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -600,6 +600,41 @@ def test_only_newline_and_carriage_return_end_a_comment(allow_file: Path) -> Non assert is_safe_upstream_url("http://127.0.0.1:4300/v1") is False +@pytest.mark.parametrize( + "bare", + [ + "127.0.0.1:", + "[::1]:", + "gw.internal/v1", + "gw.internal/", + "user@gw.internal", + "user:pw@gw.internal", + "gw.internal?x=1", + "gw.internal#frag", + ], +) +def test_a_bare_entry_with_anything_but_a_hostname_is_invalid( + allow_file: Path, monkeypatch: pytest.MonkeyPatch, bare: str, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + _replace(allow_file, bare + "\n") + monkeypatch.setenv(LIST_ENV, bare) + + for host in ("127.0.0.1", "[::1]", "gw.internal"): + assert is_safe_upstream_url(f"http://{host}:4100/v1") is False + assert any("1 invalid entry skipped" in r.getMessage() for r in caplog.records) + assert any(r.getMessage() == f"{LIST_ENV}: skipped 1 invalid entry" for r in caplog.records) + + +@pytest.mark.parametrize("bare", ["gw.internal", "GW.Internal", "127.0.0.1", "[::1]"]) +def test_a_bare_hostname_still_admits_every_port(allow_file: Path, bare: str) -> None: + _replace(allow_file, bare + "\n") + host = bare.lower() + + assert is_safe_upstream_url(f"http://{host}:4100/v1") is True + assert is_safe_upstream_url(f"https://{host}/v1") is True + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") From 17f8ce63f5d1d0c420f3953ec7adcf674236fc12 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:14:23 +0100 Subject: [PATCH 15/49] fix(cli): treat a blank --allowed-base-urls-file as unset and expand ~ A blank option or environment value was made absolute into the working directory, which the guard then used as the allowlist file. The value is now stripped, and a blank one removes the variable, so an explicit --allowed-base-urls-file "" also overrides an inherited path. A ~ is expanded before the path is made absolute. --- headroom/cli/proxy.py | 11 ++++++++-- tests/test_upstream_guard_allowlist_file.py | 24 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index e5a6511476..110bcf44c3 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -1374,8 +1374,15 @@ def proxy( if allowed_base_urls_file is not None: from headroom.proxy.upstream_guard import ALLOWED_BASE_URLS_FILE_ENV - # The guard reads the environment on every check, in this process and in any worker it spawns, so the option is handed over there. Absolute, so a later change of working directory cannot repoint it; not symlink-resolved, so a supervisor may swap a link's target. - os.environ[ALLOWED_BASE_URLS_FILE_ENV] = os.path.abspath(allowed_base_urls_file) + allowed_base_urls_file = allowed_base_urls_file.strip() + if allowed_base_urls_file: + # The guard reads the environment on every check, in this process and in any worker it spawns, so the option is handed over there. `~` expanded and made absolute, so a later change of working directory cannot repoint it; not symlink-resolved, so a supervisor may swap a link's target. + os.environ[ALLOWED_BASE_URLS_FILE_ENV] = os.path.abspath( + os.path.expanduser(allowed_base_urls_file) + ) + else: + # Blank means no file, as a blank HEADROOM_ALLOWED_BASE_URLS means no list; an explicit `--allowed-base-urls-file ""` also overrides a path inherited from the environment or stored settings. + os.environ.pop(ALLOWED_BASE_URLS_FILE_ENV, None) config = ProxyConfig( host=host, diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index cc1ec3e0bc..a60a39b3cf 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -800,6 +800,30 @@ def test_a_directory_is_refused(self, tmp_path: Path) -> None: assert "is a directory" in result.output assert seen is None + @pytest.mark.parametrize( + ("args", "env"), + [ + (["--allowed-base-urls-file", ""], {FILE_ENV: None}), + (["--allowed-base-urls-file", " "], {FILE_ENV: None}), + ([], {FILE_ENV: " "}), + (["--allowed-base-urls-file", ""], {FILE_ENV: "/somewhere/else"}), + ], + ids=["blank-option", "spaces-option", "spaces-env", "blank-option-overrides-env"], + ) + def test_a_blank_value_means_no_file(self, args: list[str], env: dict[str, str | None]) -> None: + result, seen = self._invoke(args, env) + + assert result.exit_code == 0, result.output + assert seen is None + + def test_a_home_relative_path_is_expanded(self, tmp_path: Path) -> None: + result, seen = self._invoke( + ["--allowed-base-urls-file", "~/allowed"], {FILE_ENV: None, "HOME": str(tmp_path)} + ) + + assert result.exit_code == 0, result.output + assert seen == str(tmp_path / "allowed") + def test_the_option_is_in_the_help(self) -> None: result = CliRunner().invoke(main, ["proxy", "--help"]) From ee26d666c545867a573e2be7fe22dd3449fa675d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:14:54 +0100 Subject: [PATCH 16/49] docs(configuration): describe the stricter allowlist entries and comments Cover bare entries being hostnames only, whitespace making an entry invalid, comments starting after whitespace and running to the end of the line, the dashboard setting and its restart, the blank and ~ handling of the option, and that an unusable file is not retried until it changes. --- docs/content/docs/configuration.mdx | 6 +++--- headroom/cli/proxy.py | 4 ++-- headroom/proxy/upstream_guard.py | 14 ++++++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 03b5d38932..ea6e02fb1b 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -197,11 +197,11 @@ reachable via `x-headroom-base-url` through an outbound proxy, name it in `HEADROOM_ALLOWED_BASE_URLS` — an explicit operator decision to admit that destination by name, which also turns off the resolve-and-pin check for it. -Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, newlines, carriage returns or tabs. A bare host with a port, such as `127.0.0.1:4100`, is neither, and is skipped as invalid: write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such an entry was read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on it now admits nothing for that entry. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. +Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, newlines, carriage returns or tabs. A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. -The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH` or name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, instead of the variable or as well. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus whole-line comments starting with `#` and an optional UTF-8 byte-order mark. Each load logs how many hosts and origins it found and how many invalid entries it skipped. +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup (a leading `~` is expanded, and a blank value means no file); changing it needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of a line or after a space or tab and runs to the end of the line, so `http://127.0.0.1:4100 # front door` lists that origin; a `#` anywhere else is part of an entry, which is then invalid. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device) or larger than 1 MiB, only the variable's entries are admitted (none when the variable is unset), every other `x-headroom-base-url` is rejected with 400, and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part. +Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device) or larger than 1 MiB, only the variable's entries are admitted (none when the variable is unset), every other `x-headroom-base-url` is rejected with 400, and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 110bcf44c3..435669722d 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -271,10 +271,10 @@ def dashboard(port: int, no_open: bool) -> None: help=( "File of upstream hosts and URLs that x-headroom-base-url may name, in " "the HEADROOM_ALLOWED_BASE_URLS format (commas, newlines or tabs between " - "entries, # comment lines), merged with that variable. Re-read whenever " + "entries, # comments), merged with that variable. Re-read whenever " "it changes, with no restart; replace it atomically. Naming it means " "allowlist mode even while it is empty, missing or unreadable " - "(env: HEADROOM_ALLOWED_BASE_URLS_FILE)." + "A blank value means no file (env: HEADROOM_ALLOWED_BASE_URLS_FILE)." ), ) @click.option( diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index c8b2c1bb57..b931a51d89 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -11,20 +11,26 @@ Azure, ...) are allowed so ordinary BYOK keeps working. * When ``HEADROOM_ALLOWED_BASE_URLS`` is set (hosts or URLs separated by commas, newlines, carriage returns or tabs), bare hosts permit every safe scheme/port for that - host, while URLs permit only their exact normalized origin. A bare host - with a port (``127.0.0.1:4100``) is neither and is skipped as invalid. + host, while URLs permit only their exact normalized origin. A scheme-less + entry must be a hostname and nothing else: one with a port (even an empty + one), a path, userinfo, a query or a fragment is skipped as invalid, as is + any entry containing whitespace. Because that is an explicit operator choice, allowlisted destinations may point at internal/on-prem endpoints. * ``HEADROOM_ALLOWED_BASE_URLS_FILE`` (or ``headroom proxy --allowed-base-urls-file``) names a file holding more entries in the same - format, plus ``#`` comment lines and an optional byte-order mark, merged with the variable's. The variable is fixed for the + format, plus ``#`` comments (from a ``#`` at the start of a line or after + whitespace, to the end of the line) and an optional byte-order mark, + merged with the variable's. The variable is fixed for the life of the process; the file is re-read whenever it changes, so a supervisor whose own listed endpoint moves (a loopback port chosen at start-up, say) can rewrite the file instead of restarting a proxy that sessions are still using. Replace it atomically (write a sibling, then rename over it): a change is detected by the file's identity (device, inode, size, and modification and status-change times), and an unchanged - file costs one ``stat`` per check, never a read. + file costs one ``stat`` per check, never a read. A version that cannot be + used is remembered the same way, so a broken file is not re-opened on + every request. Naming a file is itself the operator's choice of allowlist mode, so it never falls back to the public-host default, whatever state the file is in: From 67f8709003ae672027d11ce26f6cedb6341c7cac Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:45:36 +0100 Subject: [PATCH 17/49] fix(proxy): retry an allowlist read that failed for a transient reason Every OSError from opening or reading the file was cached against its unchanged identity, so one EMFILE, ENFILE, ENOMEM or EIO left every caller-supplied upstream refused until the file was touched. Only errors that belong to the file or its path (EACCES, EPERM, ELOOP, ENOENT, ENOTDIR, EISDIR, ENAMETOOLONG) are cached now; any other fails that check closed, warns once per distinct failure, and is retried next time. --- headroom/proxy/upstream_guard.py | 38 +++++++++++++++++---- tests/test_upstream_guard_allowlist_file.py | 26 ++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index b931a51d89..4af6d412ea 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -74,6 +74,7 @@ from __future__ import annotations import asyncio +import errno import ipaddress import logging import os @@ -281,12 +282,34 @@ def of(cls, st: os.stat_result) -> _FileIdentity: _O_NOCTTY = getattr(os, "O_NOCTTY", 0) +# Errors that describe the file at the path, or the path itself, rather than the state of this process or the machine at the moment of the read. Only these are remembered against a file version: retrying the same version would meet them again. Everything else (EMFILE, ENFILE, ENOMEM, EIO, EINTR, ...) can clear without the file changing, so it is retried on the next check. +_FILE_ERRNOS = frozenset( + { + errno.EACCES, + errno.EPERM, + errno.ELOOP, + errno.ENOENT, + errno.ENOTDIR, + errno.EISDIR, + errno.ENAMETOOLONG, + } +) + + class _UnusableAllowlistFile(Exception): - """The file at the path cannot be used as an allowlist, and which version of it that was.""" + """The file at the path cannot be used as an allowlist, and which version of it that was. - def __init__(self, reason: str, identity: _FileIdentity) -> None: + ``lasting`` is false for a failure that may clear while the file stays as it is, which must not be cached against its identity. + """ + + def __init__(self, reason: str, identity: _FileIdentity, *, lasting: bool = True) -> None: super().__init__(reason) self.identity = identity + self.lasting = lasting + + @classmethod + def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowlistFile: + return cls(str(exc), identity, lasting=exc.errno in _FILE_ERRNOS) def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: @@ -300,7 +323,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, try: fd = os.open(path, os.O_RDONLY | _O_NONBLOCK | _O_NOCTTY) except OSError as exc: - raise _UnusableAllowlistFile(str(exc), identity) from exc + raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc with os.fdopen(fd, "rb") as handle: st = os.fstat(handle.fileno()) # The identity recorded is the descriptor's, not the caller's `stat`'s. A rename between the two means the bytes read are the newer file's; filed under the older identity, the next check would see a mismatch and read the file again for nothing. @@ -316,7 +339,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, # One byte past the cap, so a file that grew after the `fstat` is still caught. data = handle.read(_MAX_ALLOWLIST_FILE_BYTES + 1) except OSError as exc: - raise _UnusableAllowlistFile(str(exc), identity) from exc + raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc if len(data) > _MAX_ALLOWLIST_FILE_BYTES: raise too_large try: @@ -329,7 +352,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, class _AllowlistFiles: """What each allowlist file held, re-read only when the file's identity changes. - A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs one ``stat`` per check like a good one, not an open and a read on every request. + A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs one ``stat`` per check like a good one, not an open and a read on every request. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together; the wait is bounded by one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``. """ @@ -371,8 +394,9 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: try: identity, raw = _read_allowlist_file(path, st) except _UnusableAllowlistFile as exc: - with self._lock: - self._loaded[path] = (exc.identity, None) + if exc.lasting: + with self._lock: + self._loaded[path] = (exc.identity, None) self._report(path, str(exc), file_env, env) return None entries, skipped = _parse_destinations(raw, comments=True) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index a60a39b3cf..497e09b1c4 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -5,6 +5,7 @@ from __future__ import annotations +import errno import logging import os import socket @@ -492,6 +493,31 @@ def read(self, size: int = -1) -> bytes: assert read_sizes == [] +@pytest.mark.parametrize("error", [errno.EMFILE, errno.ENFILE, errno.EIO, errno.ENOMEM]) +def test_a_transient_open_failure_is_retried_on_the_next_check( + allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, error: int +) -> None: + """Running out of descriptors says nothing about the file, so it must not stick to it.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100") + real_open = os.open + failures = [error, error] + + def failing_open(path: str, *args: object, **kwargs: object) -> int: + if os.fspath(path) == str(allow_file) and failures: + raise OSError(failures.pop(), os.strerror(error), os.fspath(path)) + return real_open(path, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(os, "open", failing_open) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert os.strerror(error) in warnings[0] + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From f866c764f3578e33ce79bda3aceb97549a485f5c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:46:27 +0100 Subject: [PATCH 18/49] fix(proxy): use Unicode whitespace and line breaks for allowlist comments A comment started only after an ASCII space, tab, CR or LF, while any Unicode whitespace made an entry invalid. With a no-break space before the #, "http://a.example # was, http://c.example" dropped a.example and admitted c.example from text meant as a comment. A comment now starts at a # after any whitespace (\s, which is str.isspace), and it and the entries end at every line break str.splitlines recognises; entries are separated by commas, tabs and those line breaks. --- headroom/proxy/upstream_guard.py | 12 +++--- tests/test_upstream_guard_allowlist_file.py | 45 +++++++++++++++++---- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 4af6d412ea..056e0daf81 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -435,10 +435,12 @@ def _report(self, path: str, reason: str, file_env: str, env: str) -> None: _ALLOWLIST_FILES = _AllowlistFiles() -_SEPARATORS = re.compile(r"[,\n\r\t]") +# The line boundaries `str.splitlines` recognises: newline, carriage return, vertical tab, form feed, the file, group and record separators, next line (NEL), and the Unicode line and paragraph separators. Each ends a line, and so a comment, and separates entries. +_LINE_BREAKS = "\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029" +_SEPARATORS = re.compile(f"[,\t{_LINE_BREAKS}]") -# A comment in the file: a "#" at the very start, or after a space, tab, newline or carriage return, through to the end of its line (the next newline or carriage return, the line-ending members of `_SEPARATORS`). A "#" anywhere else is part of an entry, which then fails to parse as one. -_COMMENT = re.compile(r"(?:^|(?<=[ \t\r\n]))#[^\r\n]*") +# A comment in the file: a "#" at the very start, or after any whitespace character (`\s` in a str pattern is `str.isspace`, the same test that makes an entry containing whitespace invalid), through to the next line break. So text an operator spaced off with any kind of blank before a "#" is always a comment, never part of an entry that would fail or, after a comma, a hidden one that would count. A "#" anywhere else is part of an entry: after an origin in a URL it begins the fragment, which an origin ignores; anywhere else it makes the entry invalid. +_COMMENT = re.compile(f"(?:^|(?<=\\s))#[^{_LINE_BREAKS}]*") def _parse_bare_host(item: str) -> str | None: @@ -477,9 +479,9 @@ def _parse_entry(item: str) -> str | tuple[str, str, int] | None: def _parse_destinations(raw: str, *, comments: bool = False) -> tuple[Destinations, int]: - """Parse hosts and URLs separated by commas, newlines, carriage returns or tabs. + """Parse hosts and URLs separated by commas, tabs or line breaks (``_LINE_BREAKS``). - With ``comments``, a ``#`` at the start of the text or after a space, tab, newline or carriage return begins a comment that runs to the end of its line. Only the file takes comments: a variable has no lines to comment out, and there a ``#`` stays part of an entry, which is then invalid. + With ``comments``, a ``#`` at the start of the text or after any whitespace character begins a comment that runs to the next line break. Only the file takes comments: a variable has no lines to comment out, and there a ``#`` stays part of an entry, which is then invalid. Returns the destinations and how many non-blank entries were skipped as invalid: neither a host nor a URL with a safe scheme, or not parseable at all. A list of nothing valid is an empty allowlist, not an absent one. """ diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 497e09b1c4..1b590e5505 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -614,16 +614,45 @@ def test_whitespace_inside_a_file_entry_makes_it_invalid( assert any("1 invalid entry skipped" in r.getMessage() for r in caplog.records) -def test_only_newline_and_carriage_return_end_a_comment(allow_file: Path) -> None: - """``str.splitlines`` would also end a comment at U+2028, U+2029 or NEL.""" - _replace( - allow_file, - "# note\u2028http://127.0.0.1:4200\x85http://127.0.0.1:4300\nhttp://127.0.0.1:4100\n", - ) +@pytest.mark.parametrize( + "blank", + ["\u00a0", "\x0c", "\u2028", "\u2029", "\x85", "\u3000"], + ids=["nbsp", "form-feed", "line-separator", "paragraph-separator", "nel", "ideographic-space"], +) +def test_any_whitespace_before_a_hash_starts_a_comment(allow_file: Path, blank: str) -> None: + """Text spaced off before a ``#`` is a comment, never a hidden entry after its comma.""" + _replace(allow_file, f"http://a.example{blank}# was, http://c.example\n") + + assert is_safe_upstream_url("http://a.example/v1") is True + assert is_safe_upstream_url("http://c.example/v1") is False + + +@pytest.mark.parametrize( + "brk", ["\x0b", "\x0c", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"] +) +def test_every_line_break_ends_a_comment_and_separates_entries(allow_file: Path, brk: str) -> None: + _replace(allow_file, f"# note{brk}http://127.0.0.1:4100{brk}http://127.0.0.1:4200\n") assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True - assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False - assert is_safe_upstream_url("http://127.0.0.1:4300/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is True + + +def test_the_line_breaks_are_exactly_those_splitlines_recognises() -> None: + boundaries = {chr(code) for code in range(0x110000) if len(f"a{chr(code)}b".splitlines()) == 2} + assert set(upstream_guard._LINE_BREAKS) == boundaries + + +def test_the_comment_look_behind_uses_the_same_whitespace_as_entries() -> None: + """``\\s`` must be ``str.isspace``, or the comment and the invalid-entry rules disagree.""" + import re + + pattern = re.compile(r"\s") + disagree = [ + hex(code) + for code in range(0x110000) + if bool(pattern.match(chr(code))) != chr(code).isspace() + ] + assert disagree == [] @pytest.mark.parametrize( From 63a675a65cbf735dd4f24f3c48b202414025842e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:47:06 +0100 Subject: [PATCH 19/49] fix(proxy): say what each route does with a refused upstream override The read-failure warning and the configuration docs said every refused x-headroom-base-url was rejected with 400. /v1/messages, /v1/alpha/search and the catch-all passthrough do answer 400, but the OpenAI chat-completions and responses handlers log the override and send the request to the configured upstream instead (_resolve_openai_upstream_base). Both now say so. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 6 ++++-- tests/test_upstream_guard_allowlist_file.py | 23 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index ea6e02fb1b..418884fe0c 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup (a leading `~` is expanded, and a blank value means no file); changing it needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of a line or after a space or tab and runs to the end of the line, so `http://127.0.0.1:4100 # front door` lists that origin; a `#` anywhere else is part of an entry, which is then invalid. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device) or larger than 1 MiB, only the variable's entries are admitted (none when the variable is unset), every other `x-headroom-base-url` is rejected with 400, and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device) or larger than 1 MiB, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 056e0daf81..62f59b9dc7 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -424,8 +424,10 @@ def _report(self, path: str, reason: str, file_env: str, env: str) -> None: self._reported[path] = reason logger.warning( "%s: cannot read %s (%s). Until it can be read, only the entries in " - "%s are allowlisted (none when it is unset) and every other " - "x-headroom-base-url is rejected with 400.", + "%s are allowlisted (none when it is unset). Any other " + "x-headroom-base-url is not used: /v1/messages, /v1/alpha/search and " + "the catch-all passthrough answer 400, and the OpenAI chat-completions " + "and responses handlers send the request to the configured upstream.", file_env, path, reason, diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 1b590e5505..3b40a4d409 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -518,6 +518,29 @@ def failing_open(path: str, *args: object, **kwargs: object) -> int: assert os.strerror(error) in warnings[0] +def test_the_failure_warning_says_what_each_route_does( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Not every route answers 400: the OpenAI handlers fall back to the configured upstream.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + + (warning,) = [r.getMessage() for r in caplog.records] + assert "rejected with 400" not in warning + assert "/v1/messages, /v1/alpha/search and the catch-all passthrough answer 400" in warning + assert "send the request to the configured upstream" in warning + + +def test_the_openai_chat_handler_ignores_a_refused_override(allow_file: Path) -> None: + """The behaviour the warning describes, read from the handler rather than assumed.""" + from headroom.proxy.handlers.openai import _resolve_openai_upstream_base + + _replace(allow_file, "") + + assert _resolve_openai_upstream_base({"x-headroom-base-url": "http://127.0.0.1:4100"}) is None + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From e54e38c0e88755c211fbfb8ab12efd37df969b3b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:47:51 +0100 Subject: [PATCH 20/49] fix(proxy): resolve the allowlist file path in the guard ~ was expanded only by the CLI, so HEADROOM_ALLOWED_BASE_URLS_FILE=~/allow set in the environment or stored settings named a literal ~ directory, and a relative path followed every later change of working directory. resolve_allowlist_file_path expands ~ and makes the path absolute against the working directory at its first use, and the CLI exports the path the same function resolves instead of doing its own expansion. --- docs/content/docs/configuration.mdx | 2 +- headroom/cli/proxy.py | 11 ++++--- headroom/proxy/upstream_guard.py | 15 ++++++++-- tests/test_upstream_guard_allowlist_file.py | 32 +++++++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 418884fe0c..e590a6a2f0 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -199,7 +199,7 @@ destination by name, which also turns off the resolve-and-pin check for it. Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, newlines, carriage returns or tabs. A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. -The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup (a leading `~` is expanded, and a blank value means no file); changing it needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of a line or after a space or tab and runs to the end of the line, so `http://127.0.0.1:4100 # front door` lists that origin; a `#` anywhere else is part of an entry, which is then invalid. Each load logs how many hosts and origins it found and how many invalid entries it skipped. +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of a line or after a space or tab and runs to the end of the line, so `http://127.0.0.1:4100 # front door` lists that origin; a `#` anywhere else is part of an entry, which is then invalid. Each load logs how many hosts and origins it found and how many invalid entries it skipped. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device) or larger than 1 MiB, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 435669722d..403fc843ac 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -1372,13 +1372,16 @@ def proxy( raise click.ClickException(str(exc)) from None if allowed_base_urls_file is not None: - from headroom.proxy.upstream_guard import ALLOWED_BASE_URLS_FILE_ENV + from headroom.proxy.upstream_guard import ( + ALLOWED_BASE_URLS_FILE_ENV, + resolve_allowlist_file_path, + ) allowed_base_urls_file = allowed_base_urls_file.strip() if allowed_base_urls_file: - # The guard reads the environment on every check, in this process and in any worker it spawns, so the option is handed over there. `~` expanded and made absolute, so a later change of working directory cannot repoint it; not symlink-resolved, so a supervisor may swap a link's target. - os.environ[ALLOWED_BASE_URLS_FILE_ENV] = os.path.abspath( - os.path.expanduser(allowed_base_urls_file) + # The guard reads the environment on every check, in this process and in any worker it spawns, so the option is handed over there, resolved the way the guard resolves it so a worker started in another directory still names the same file. + os.environ[ALLOWED_BASE_URLS_FILE_ENV] = resolve_allowlist_file_path( + allowed_base_urls_file ) else: # Blank means no file, as a blank HEADROOM_ALLOWED_BASE_URLS means no list; an explicit `--allowed-base-urls-file ""` also overrides a path inherited from the environment or stored settings. diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 62f59b9dc7..3e7b383287 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -75,6 +75,7 @@ import asyncio import errno +import functools import ipaddress import logging import os @@ -527,6 +528,15 @@ def _parse_variable(env: str, raw: str) -> Destinations: return destinations +@functools.lru_cache(maxsize=16) +def resolve_allowlist_file_path(raw: str) -> str: + """Return the file ``raw`` names, with ``~`` expanded and a relative path made absolute. + + A relative path resolves against the working directory at its first use in this process and keeps that meaning afterwards, so a later change of directory cannot repoint the allowlist. Symlinks are not resolved, so a supervisor may swap a link's target. ``raw`` must already be stripped and not blank. + """ + return os.path.abspath(os.path.expanduser(raw)) + + def allowlisted_destinations( env: str = ALLOWED_BASE_URLS_ENV, file_env: str | None = ALLOWED_BASE_URLS_FILE_ENV, @@ -539,9 +549,10 @@ def allowlisted_destinations( listed = _parse_variable(env, raw) if raw and raw.strip() else None if file_env is None: return listed - path = (os.environ.get(file_env) or "").strip() - if not path: + raw_path = (os.environ.get(file_env) or "").strip() + if not raw_path: return listed + path = resolve_allowlist_file_path(raw_path) from_file = _ALLOWLIST_FILES.load(path, file_env, env) or _NO_DESTINATIONS if listed is None: return from_file diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 3b40a4d409..5a578e25b1 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -541,6 +541,36 @@ def test_the_openai_chat_handler_ignores_a_refused_override(allow_file: Path) -> assert _resolve_openai_upstream_base({"x-headroom-base-url": "http://127.0.0.1:4100"}) is None +def test_the_guard_expands_a_home_relative_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + upstream_guard.resolve_allowlist_file_path.cache_clear() + _replace(tmp_path / "allowed", "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, "~/allowed") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +def test_a_relative_path_keeps_the_directory_of_its_first_use( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + upstream_guard.resolve_allowlist_file_path.cache_clear() + first, elsewhere = tmp_path / "first", tmp_path / "elsewhere" + first.mkdir() + elsewhere.mkdir() + _replace(first / "allowed", "http://127.0.0.1:4100") + _replace(elsewhere / "allowed", "http://127.0.0.1:4200") + monkeypatch.setenv(FILE_ENV, "allowed") + monkeypatch.chdir(first) + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + monkeypatch.chdir(elsewhere) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: @@ -857,6 +887,7 @@ def test_a_relative_path_is_made_absolute( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) + upstream_guard.resolve_allowlist_file_path.cache_clear() result, seen = self._invoke(["--allowed-base-urls-file", "allowed"], {FILE_ENV: None}) @@ -895,6 +926,7 @@ def test_a_blank_value_means_no_file(self, args: list[str], env: dict[str, str | assert seen is None def test_a_home_relative_path_is_expanded(self, tmp_path: Path) -> None: + upstream_guard.resolve_allowlist_file_path.cache_clear() result, seen = self._invoke( ["--allowed-base-urls-file", "~/allowed"], {FILE_ENV: None, "HOME": str(tmp_path)} ) From f3107c43e9d078873a4894c33e1dba652977ae20 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:48:43 +0100 Subject: [PATCH 21/49] refactor(proxy): share the check that another user could change a path The --uds parent-directory check decided inline whether another owner or group or other write permission could let someone replace the socket. That decision moves to headroom.proxy.private_paths.foreign_write_access, a stdlib-only module, so the upstream allowlist file can apply the same rule; the socket's error messages are unchanged. --- headroom/proxy/private_paths.py | 39 +++++++++++++++++++++++++++++++++ headroom/proxy/unix_socket.py | 18 ++++++++------- 2 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 headroom/proxy/private_paths.py diff --git a/headroom/proxy/private_paths.py b/headroom/proxy/private_paths.py new file mode 100644 index 0000000000..a29fe0fd74 --- /dev/null +++ b/headroom/proxy/private_paths.py @@ -0,0 +1,39 @@ +"""Whether another user could change a file or directory the proxy trusts. + +Shared by the ``--uds`` socket's parent-directory check and the upstream allowlist file, which both trust a path only if nobody but the user running the proxy can replace what it names. Standard library only, so the SSRF guard can import it without widening its own dependencies. POSIX only: ownership and mode bits are what is judged, so callers on a platform without ``os.geteuid`` do not call it. +""" + +from __future__ import annotations + +import os +import stat +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ForeignWriteAccess: + """Why someone other than the current effective user could change what a ``stat`` describes. + + ``description`` completes " is ...", for example ``owned by uid 0, not by the user running the proxy (uid 501)``. ``other_owner`` tells the two causes apart for callers that advise differently on each. + """ + + description: str + other_owner: bool + + +def foreign_write_access(info: os.stat_result) -> ForeignWriteAccess | None: + """Return why another user could change the file or directory ``info`` describes, else ``None``. + + Its owner can always change it, so one that is not the current effective user is refused first; otherwise group or other write permission is. For a directory, write permission is what allows unlinking or renaming its entries, whoever owns them, so either lets someone else replace what the directory holds. + """ + if info.st_uid != os.geteuid(): + return ForeignWriteAccess( + f"owned by uid {info.st_uid}, not by the user running the proxy (uid {os.geteuid()})", + other_owner=True, + ) + if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + return ForeignWriteAccess( + f"writable by group or others (mode {stat.S_IMODE(info.st_mode):04o})", + other_owner=False, + ) + return None diff --git a/headroom/proxy/unix_socket.py b/headroom/proxy/unix_socket.py index 60e6722242..787677c2d9 100644 --- a/headroom/proxy/unix_socket.py +++ b/headroom/proxy/unix_socket.py @@ -31,6 +31,8 @@ from dataclasses import dataclass from types import FrameType +from headroom.proxy.private_paths import foreign_write_access + #: Owner read and write. Connecting to a unix socket needs write permission on it, so this admits only the proxy's own user. SOCKET_MODE = stat.S_IRUSR | stat.S_IWUSR @@ -126,16 +128,16 @@ def _require_safe_parent(parent: str, socket_path: str) -> None: raise UnixSocketUnusableError( f"{parent} holds the socket {socket_path} but is not a directory" ) - if info.st_uid != os.geteuid(): - raise UnixSocketUnusableError( - f"directory {parent} is owned by uid {info.st_uid}, not by the user running the proxy " - f"(uid {os.geteuid()}); another owner could replace the socket" - ) - if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH): - raise UnixSocketUnusableError( - f"directory {parent} is writable by group or others (mode {stat.S_IMODE(info.st_mode):04o}); " + problem = foreign_write_access(info) + if problem is None: + return + if problem.other_owner: + consequence = "another owner could replace the socket" + else: + consequence = ( "they could replace the socket. Run chmod go-w on it or use a private directory" ) + raise UnixSocketUnusableError(f"directory {parent} is {problem.description}; {consequence}") def checked_unix_socket_path(path: str) -> str: From bee468a8005f3fe0b6ea1220b94e8eaf40fea738 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:49:27 +0100 Subject: [PATCH 22/49] fix(proxy): refuse an allowlist file another user could change Whoever can write the allowlist file, or the directory that holds it, can list any upstream they like, so the file now has to pass the same check as the --uds socket's directory: owned by the user running the proxy and not writable by group or others, for the file and for its directory. Either failure is an unusable file: the guard fails closed and warns once, naming which. The file's own failure is remembered against its identity (a chmod moves its ctime); the directory's is retried on each check, since fixing it does not change the file. Skipped on Windows, which has no os.geteuid. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 33 ++++++++++- tests/test_upstream_guard_allowlist_file.py | 62 +++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index e590a6a2f0..ee1ca1e7af 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of a line or after a space or tab and runs to the end of the line, so `http://127.0.0.1:4100 # front door` lists that origin; a `#` anywhere else is part of an entry, which is then invalid. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device) or larger than 1 MiB, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +The file, and the directory that holds it, must be owned by the user running the proxy and not writable by group or others, the same rule as the `--uds` socket's directory, since whoever can change either could list any upstream they like (Windows, which has no POSIX ownership or modes, skips this check). Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 3e7b383287..777bf36d5d 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -67,7 +67,8 @@ a guarded destination with no usable pin is a denial. It also stops one caller's pin from being visible to another caller's connection. -This module intentionally depends only on the standard library so it is safe to +This module intentionally depends only on the standard library (and +``headroom.proxy.private_paths``, itself standard-library only) so it is safe to import from any handler without risking an import cycle. """ @@ -90,6 +91,8 @@ from dataclasses import dataclass from urllib.parse import urlparse +from headroom.proxy.private_paths import foreign_write_access + logger = logging.getLogger(__name__) ALLOWED_BASE_URLS_ENV = "HEADROOM_ALLOWED_BASE_URLS" @@ -313,6 +316,32 @@ def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowl return cls(str(exc), identity, lasting=exc.errno in _FILE_ERRNOS) +def _require_private( + path: str, st: os.stat_result, identity: _FileIdentity, *, directory: bool = True +) -> None: + """Raise :class:`_UnusableAllowlistFile` if a user other than this one could change the file. + + The same rule as the ``--uds`` socket's directory (:func:`headroom.proxy.private_paths.foreign_write_access`): another owner, or group or other write permission, on the file itself, or with ``directory``, on the directory that holds the path, whose write permission lets anyone with it replace the file. A permission change on the file moves its status-change time, so that failure is remembered against the file's identity; one on the directory does not, so that failure is retried on every check until it clears. A cached good version is checked again whenever the file changes, which is the only way the directory's write access could reach it. Skipped where there is no ``os.geteuid`` (Windows), which has no POSIX ownership and modes to judge. + """ + if not hasattr(os, "geteuid"): + return + problem = foreign_write_access(st) + if problem is not None: + raise _UnusableAllowlistFile(f"the file is {problem.description}", identity) + if not directory: + return + parent = os.path.dirname(path) + try: + parent_st = os.stat(parent) + except OSError as exc: + raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc + problem = foreign_write_access(parent_st) + if problem is not None: + raise _UnusableAllowlistFile( + f"its directory is {problem.description}", identity, lasting=False + ) + + def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: """Return the identity of the file read and its text; ``st`` is the caller's ``stat`` of it. @@ -321,6 +350,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, identity = _FileIdentity.of(st) if not stat.S_ISREG(st.st_mode): raise _UnusableAllowlistFile("not a regular file", identity) + _require_private(path, st, identity) try: fd = os.open(path, os.O_RDONLY | _O_NONBLOCK | _O_NOCTTY) except OSError as exc: @@ -331,6 +361,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, identity = _FileIdentity.of(st) if not stat.S_ISREG(st.st_mode): raise _UnusableAllowlistFile("not a regular file", identity) + _require_private(path, st, identity, directory=False) too_large = _UnusableAllowlistFile( f"larger than {_MAX_ALLOWLIST_FILE_BYTES} bytes", identity ) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 5a578e25b1..e6ae32fe8a 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -571,6 +571,68 @@ def test_a_relative_path_keeps_the_directory_of_its_first_use( assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False +_POSIX_USER = pytest.mark.skipif( + not hasattr(os, "geteuid") or os.geteuid() == 0, + reason="needs POSIX ownership and modes that bind the current user", +) + + +@_POSIX_USER +def test_a_group_writable_file_is_refused( + allow_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100") + allow_file.chmod(0o620) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert "the file is writable by group or others (mode 0620)" in warning + + allow_file.chmod(0o600) + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +@_POSIX_USER +def test_a_file_in_a_group_writable_directory_is_refused_until_it_is_fixed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + shared = tmp_path / "shared" + shared.mkdir() + allow_file = shared / "allowed" + _replace(allow_file, "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(allow_file)) + shared.chmod(0o770) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + # Fixing the directory changes nothing about the file, so this proves the failure was not cached against it. + shared.chmod(0o700) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "its directory is writable by group or others (mode 0770)" in warnings[0] + + +@_POSIX_USER +def test_a_file_owned_by_another_user_is_refused( + allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Ownership cannot be given away without root, so the running user is changed instead.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100") + real_uid = os.geteuid() + monkeypatch.setattr(os, "geteuid", lambda: real_uid + 1) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert f"the file is owned by uid {real_uid}" in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From e2f25445c5b8aadf671b9ccd3e22765651e2421d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:50:39 +0100 Subject: [PATCH 23/49] fix(proxy): log only the end of the allowlist path The path comes from the environment or stored settings and is unbounded, and an OSError's text repeated it, so one mistaken value could fill the warning twice over. The load and failure lines now show the path quoted and cut to its last 255 characters (NAME_MAX, so the file's own name stays whole), and an OS error is described by its message and errno name alone. --- headroom/proxy/upstream_guard.py | 26 +++++++++++++++++---- tests/test_upstream_guard_allowlist_file.py | 18 +++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 777bf36d5d..519b4d8ce6 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -300,6 +300,24 @@ def of(cls, st: os.stat_result) -> _FileIdentity: ) +def _os_error_reason(exc: OSError) -> str: + """Describe ``exc`` without the path it carries, which the log line already shows (truncated).""" + if exc.errno is None: + return str(exc) + return f"{os.strerror(exc.errno)} ({errno.errorcode.get(exc.errno, exc.errno)})" + + +# The path is the operator's, but it comes from an environment variable or stored setting and is otherwise unbounded (PATH_MAX is 1024 bytes on macOS and 4096 on Linux), so a mistaken value could fill a log line. 255 is NAME_MAX, the longest single file name on common filesystems, so the file's own name always survives whole, with as much of its directory as fits. +_LOGGED_PATH_CHARS = 255 + + +def _logged_path(path: str) -> str: + """``path`` as a log line shows it: quoted, escaped, and cut to its last ``_LOGGED_PATH_CHARS`` characters.""" + if len(path) <= _LOGGED_PATH_CHARS: + return repr(path) + return repr("..." + path[-_LOGGED_PATH_CHARS:]) + + class _UnusableAllowlistFile(Exception): """The file at the path cannot be used as an allowlist, and which version of it that was. @@ -313,7 +331,7 @@ def __init__(self, reason: str, identity: _FileIdentity, *, lasting: bool = True @classmethod def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowlistFile: - return cls(str(exc), identity, lasting=exc.errno in _FILE_ERRNOS) + return cls(_os_error_reason(exc), identity, lasting=exc.errno in _FILE_ERRNOS) def _require_private( @@ -412,7 +430,7 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: try: st = os.stat(path) except OSError as exc: - self._report(path, str(exc), file_env, env) + self._report(path, _os_error_reason(exc), file_env, env) return None identity = _FileIdentity.of(st) hit, entries = self._cached(path, identity) @@ -440,7 +458,7 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: "%s: %s %s, %d host(s) and %d origin(s), %d invalid entr%s skipped", file_env, "read again after failing:" if recovered else "loaded", - path, + _logged_path(path), len(entries[0]), len(entries[1]), skipped, @@ -461,7 +479,7 @@ def _report(self, path: str, reason: str, file_env: str, env: str) -> None: "the catch-all passthrough answer 400, and the OpenAI chat-completions " "and responses handlers send the request to the configured upstream.", file_env, - path, + _logged_path(path), reason, env, ) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index e6ae32fe8a..4b23ceb8ad 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -128,7 +128,7 @@ def test_a_missing_file_fails_closed_and_is_logged_once( warnings = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warnings) == 1 assert FILE_ENV in warnings[0].getMessage() - assert str(allow_file) in warnings[0].getMessage() + assert upstream_guard._logged_path(str(allow_file)) in warnings[0].getMessage() # Once it can be read again it is honoured, and a later failure is a new one worth reporting. _replace(allow_file, "http://127.0.0.1:4100") @@ -633,6 +633,22 @@ def test_a_file_owned_by_another_user_is_refused( assert f"the file is owned by uid {real_uid}" in warning +def test_the_failure_warning_shows_only_the_end_of_a_long_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + long_path = str(tmp_path / ("d" * 250) / ("e" * 250) / "allowed") + monkeypatch.setenv(FILE_ENV, long_path) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + + (warning,) = [r.getMessage() for r in caplog.records] + assert long_path not in warning + assert repr("..." + long_path[-upstream_guard._LOGGED_PATH_CHARS :]) in warning + assert "No such file or directory (ENOENT)" in warning + assert "d" * 10 not in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From b0a8a61846af196a99282c33c3c98dd412d2c5a6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:51:21 +0100 Subject: [PATCH 24/49] fix(settings): refuse a NUL in a stored string setting apply_to_environ sets os.environ key by key, and a NUL in a stored str or csv-list value raised mid-loop, silently dropping every stored setting after it. _coerce now rejects a NUL, so validate reports the key and load skips just that one. --- headroom/settings_store.py | 14 +++++++++++- tests/test_proxy/test_settings_store.py | 30 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/headroom/settings_store.py b/headroom/settings_store.py index 9540c449a4..16d9e54c6c 100644 --- a/headroom/settings_store.py +++ b/headroom/settings_store.py @@ -828,7 +828,9 @@ def _coerce(field: SettingField, value: Any) -> Any: tokens = value if isinstance(value, list | tuple) else str(value).split(",") tokens = [str(token).strip() for token in tokens] tokens = [token for token in tokens if token] - return ",".join(tokens) if tokens else None + joined = ",".join(tokens) + _refuse_nul(joined) + return joined if tokens else None if field.type == "header-map": if isinstance(value, dict): parsed = value @@ -844,9 +846,19 @@ def _coerce(field: SettingField, value: Any) -> Any: return json.dumps(parsed, sort_keys=True) if parsed else None # str token = str(value) + _refuse_nul(token) return token if token != "" else None +def _refuse_nul(token: str) -> None: + """Raise ``ValueError`` for a NUL, which no environment variable can hold. + + ``apply_to_environ`` sets ``os.environ`` key by key, and a NUL there raises mid-loop, dropping every stored setting after it; refusing it here makes ``load`` skip just that key and ``validate`` report it. + """ + if "\x00" in token: + raise ValueError("must not contain a NUL character") + + def _serialize(field: SettingField, value: Any) -> str: """Serialize a coerced value to the exact string its env var expects.""" if field.type in ("bool", "optional-bool"): diff --git a/tests/test_proxy/test_settings_store.py b/tests/test_proxy/test_settings_store.py index 58f6bce660..0669f22c37 100644 --- a/tests/test_proxy/test_settings_store.py +++ b/tests/test_proxy/test_settings_store.py @@ -163,6 +163,36 @@ def test_bool_false_serializes_to_zero(self, workspace, monkeypatch): assert os.environ["HEADROOM_CODE_AWARE_ENABLED"] == "0" +class TestNulCharacters: + """An environment value cannot hold NUL, so a stored one must never reach ``os.environ``.""" + + @pytest.mark.parametrize( + ("key", "value"), + [ + ("allowed_base_urls_file", "/run/allow\x00ed"), + ("openai_base_url", "http://a\x00b"), + ("proxy_extensions", ["a\x00b"]), + ], + ) + def test_validate_rejects_a_nul_in_a_string(self, workspace, key, value): + with pytest.raises(settings_store.SettingsValidationError) as excinfo: + settings_store.validate({key: value}) + assert key in excinfo.value.field_errors + + def test_a_stored_nul_is_dropped_and_later_settings_still_apply(self, workspace, monkeypatch): + _clear_env(monkeypatch) + paths.settings_path().write_text( + json.dumps({"allowed_base_urls_file": "/run/allow\x00ed", "port": 9898}), + encoding="utf-8", + newline="\n", + ) + + settings_store.apply_to_environ(settings_store.load()) + + assert "HEADROOM_ALLOWED_BASE_URLS_FILE" not in os.environ + assert os.environ["HEADROOM_PORT"] == "9898" + + class TestEffectiveValues: def test_default_then_file_then_env(self, workspace, monkeypatch): _clear_env(monkeypatch) From cba06f285397678a9dce6c2972e3a236afd961e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 23:52:15 +0100 Subject: [PATCH 25/49] docs(proxy): correct the allowlist help, # notes and reload-wait comment The --allowed-base-urls-file help lost a full stop between two sentences and still listed only commas, newlines and tabs. The docs now say a # after an origin in a URL is its fragment and accepted, and that in the variable a # has no comment meaning and hides nothing after a comma. The reload lock's wait is bounded in bytes, not time: a stalled mount holds it, and a synchronous caller on the event loop blocks the loop meanwhile. --- docs/content/docs/configuration.mdx | 4 ++-- headroom/cli/proxy.py | 6 +++--- headroom/proxy/upstream_guard.py | 9 +++++---- tests/test_upstream_guard_allowlist_file.py | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index ee1ca1e7af..8f3a1c25f4 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -197,9 +197,9 @@ reachable via `x-headroom-base-url` through an outbound proxy, name it in `HEADROOM_ALLOWED_BASE_URLS` — an explicit operator decision to admit that destination by name, which also turns off the resolve-and-pin check for it. -Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, newlines, carriage returns or tabs. A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. +Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, tabs or line breaks (newline, carriage return, vertical tab, form feed, the file, group and record separators, NEL, and the Unicode line and paragraph separators). A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. -The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of a line or after a space or tab and runs to the end of the line, so `http://127.0.0.1:4100 # front door` lists that origin; a `#` anywhere else is part of an entry, which is then invalid. Each load logs how many hosts and origins it found and how many invalid entries it skipped. +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. The file, and the directory that holds it, must be owned by the user running the proxy and not writable by group or others, the same rule as the `--uds` socket's directory, since whoever can change either could list any upstream they like (Windows, which has no POSIX ownership or modes, skips this check). Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 403fc843ac..2b3e7cb551 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -270,10 +270,10 @@ def dashboard(port: int, no_open: bool) -> None: envvar="HEADROOM_ALLOWED_BASE_URLS_FILE", help=( "File of upstream hosts and URLs that x-headroom-base-url may name, in " - "the HEADROOM_ALLOWED_BASE_URLS format (commas, newlines or tabs between " - "entries, # comments), merged with that variable. Re-read whenever " + "the HEADROOM_ALLOWED_BASE_URLS format (commas, tabs or line breaks between " + "entries) plus # comments, merged with that variable. Re-read whenever " "it changes, with no restart; replace it atomically. Naming it means " - "allowlist mode even while it is empty, missing or unreadable " + "allowlist mode even while it is empty, missing or unreadable. " "A blank value means no file (env: HEADROOM_ALLOWED_BASE_URLS_FILE)." ), ) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 519b4d8ce6..9fc674937c 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -10,7 +10,7 @@ or otherwise non-public addresses. Public hosts (api.openai.com, api.x.ai, Azure, ...) are allowed so ordinary BYOK keeps working. * When ``HEADROOM_ALLOWED_BASE_URLS`` is set (hosts or URLs separated by - commas, newlines, carriage returns or tabs), bare hosts permit every safe scheme/port for that + commas, tabs or line breaks), bare hosts permit every safe scheme/port for that host, while URLs permit only their exact normalized origin. A scheme-less entry must be a hostname and nothing else: one with a port (even an empty one), a path, userinfo, a query or a fragment is skipped as invalid, as is @@ -19,8 +19,9 @@ point at internal/on-prem endpoints. * ``HEADROOM_ALLOWED_BASE_URLS_FILE`` (or ``headroom proxy --allowed-base-urls-file``) names a file holding more entries in the same - format, plus ``#`` comments (from a ``#`` at the start of a line or after - whitespace, to the end of the line) and an optional byte-order mark, + format, plus ``#`` comments (from a ``#`` at the start of the file or + after any whitespace, to the next line break; see ``_COMMENT``) and an + optional byte-order mark, merged with the variable's. The variable is fixed for the life of the process; the file is re-read whenever it changes, so a supervisor whose own listed endpoint moves (a loopback port chosen at @@ -404,7 +405,7 @@ class _AllowlistFiles: A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs one ``stat`` per check like a good one, not an open and a read on every request. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. - Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together; the wait is bounded by one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``. + Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together. The wait is bounded in bytes (one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``), not in time: a file on a stalled mount holds the reload, and every check that saw the change, for as long as the filesystem does, and a synchronous caller of :func:`is_safe_upstream_url` on the event loop blocks the loop meanwhile, as it already would on the ``stat``. Event-loop callers should use :func:`is_safe_upstream_url_async`, which runs the check on a worker thread. """ def __init__(self) -> None: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 4b23ceb8ad..bd4a658066 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -821,6 +821,22 @@ def test_a_bare_hostname_still_admits_every_port(allow_file: Path, bare: str) -> assert is_safe_upstream_url(f"https://{host}/v1") is True +def test_a_hash_after_an_origin_in_a_url_is_its_fragment(allow_file: Path) -> None: + """What the docs promise: the fragment is ignored and the origin is listed.""" + _replace(allow_file, "http://127.0.0.1:4100#x\n") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +def test_a_hash_in_the_variable_hides_nothing_after_a_comma( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(LIST_ENV, "gw.example #note, http://127.0.0.1:4100") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("https://gw.example/v1") is False + + def test_a_blank_file_variable_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(FILE_ENV, " ") @@ -1017,3 +1033,6 @@ def test_the_option_is_in_the_help(self) -> None: assert "--allowed-base-urls-file" in result.output assert "HEADROOM_ALLOWED_BASE_URLS_FILE" in result.output + assert "missing or unreadable. A blank value means no file" in " ".join( + result.output.split() + ) From a9980c06cd8f37cca65efb6aa83a90be4993d43a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 00:50:14 +0100 Subject: [PATCH 26/49] fix(cli): keep a blank --allowed-base-urls-file over a stored setting The CLI removed the variable for a blank value, and create_app (and each worker's create_app_from_env) re-applies stored settings with setdefault, which put a stored allowlist file path straight back. The blank value is now exported as an empty variable, which setdefault leaves alone and the guard treats as unset. --- headroom/cli/proxy.py | 4 +-- tests/test_upstream_guard_allowlist_file.py | 32 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 2b3e7cb551..eeebd366aa 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -1384,8 +1384,8 @@ def proxy( allowed_base_urls_file ) else: - # Blank means no file, as a blank HEADROOM_ALLOWED_BASE_URLS means no list; an explicit `--allowed-base-urls-file ""` also overrides a path inherited from the environment or stored settings. - os.environ.pop(ALLOWED_BASE_URLS_FILE_ENV, None) + # Blank means no file, as a blank HEADROOM_ALLOWED_BASE_URLS means no list. Set to "" rather than removed: create_app re-applies stored settings with setdefault (in this process and in every worker), which would put a stored path back into a missing variable but leaves a present one alone, so an explicit `--allowed-base-urls-file ""` overrides a stored or inherited path. + os.environ[ALLOWED_BASE_URLS_FILE_ENV] = "" config = ProxyConfig( host=host, diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index bd4a658066..1ee2d084aa 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -1017,7 +1017,37 @@ def test_a_blank_value_means_no_file(self, args: list[str], env: dict[str, str | result, seen = self._invoke(args, env) assert result.exit_code == 0, result.output - assert seen is None + assert seen == "" + + @pytest.mark.parametrize("factory", ["create_app", "create_app_from_env"]) + def test_a_blank_option_overrides_a_stored_setting_through_the_app_factory( + self, tmp_path: Path, factory: str + ) -> None: + """create_app re-applies stored settings with setdefault; the blank must survive that.""" + from headroom import settings_store + from headroom.proxy import server + + settings_store.save({"allowed_base_urls_file": str(tmp_path / "stored")}) + seen: dict[str, str | None] = {} + + def run_server_through_the_factory(config: ProxyConfig, **kwargs: object) -> None: + if factory == "create_app": + server.create_app(config) + else: + server.create_app_from_env() + seen["file"] = os.environ.get(FILE_ENV) + seen["allows_public"] = str(is_safe_upstream_url("https://8.8.8.8/v1")) + + with patch("headroom.proxy.server.run_server", run_server_through_the_factory): + result = CliRunner().invoke( + main, + ["proxy", "--port", str(_free_port()), "--allowed-base-urls-file", ""], + env={"HEADROOM_HOST": None, "HEADROOM_PORT": None, FILE_ENV: None}, + ) + + assert result.exit_code == 0, result.output + assert seen["file"] == "" + assert seen["allows_public"] == "True" def test_a_home_relative_path_is_expanded(self, tmp_path: Path) -> None: upstream_guard.resolve_allowlist_file_path.cache_clear() From 2a65e532a4fbf55d2c232415c77d954d59bac8fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 00:51:32 +0100 Subject: [PATCH 27/49] fix(proxy): accept root-owned and sticky locations for the allowlist file The --uds rule refused a root-owned file or directory and a sticky shared directory, which rules out the usual container setups: a non-root proxy reading a root-owned bind mount or ConfigMap volume, or a file in /tmp. foreign_write_access gains root_may_own and sticky_may_share, both off by default so the --uds check and its messages are unchanged. The allowlist file must be owned by this user or root and not writable by group or others; its directory must be owned by this user or root and either not writable by group or others or sticky. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/private_paths.py | 21 +++-- headroom/proxy/upstream_guard.py | 9 +- tests/test_private_paths.py | 97 +++++++++++++++++++++ tests/test_upstream_guard_allowlist_file.py | 64 ++++++++++++++ 5 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 tests/test_private_paths.py diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 8f3a1c25f4..6c4673e9eb 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -The file, and the directory that holds it, must be owned by the user running the proxy and not writable by group or others, the same rule as the `--uds` socket's directory, since whoever can change either could list any upstream they like (Windows, which has no POSIX ownership or modes, skips this check). Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. Its directory must be owned by the user running the proxy or by root, and must either not be writable by group or others or have the sticky bit set (a `/tmp`-style directory, where only an entry's owner can remove or rename it). Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user. This is looser than the rule for the `--uds` socket's directory, which must belong to the user running the proxy and never be writable by others, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/private_paths.py b/headroom/proxy/private_paths.py index a29fe0fd74..4dbbf8da06 100644 --- a/headroom/proxy/private_paths.py +++ b/headroom/proxy/private_paths.py @@ -21,17 +21,22 @@ class ForeignWriteAccess: other_owner: bool -def foreign_write_access(info: os.stat_result) -> ForeignWriteAccess | None: +def foreign_write_access( + info: os.stat_result, *, root_may_own: bool = False, sticky_may_share: bool = False +) -> ForeignWriteAccess | None: """Return why another user could change the file or directory ``info`` describes, else ``None``. - Its owner can always change it, so one that is not the current effective user is refused first; otherwise group or other write permission is. For a directory, write permission is what allows unlinking or renaming its entries, whoever owns them, so either lets someone else replace what the directory holds. + Its owner can always change it, so an owner other than the current effective user is refused first; otherwise group or other write permission is. For a directory, write permission is what allows unlinking or renaming its entries, whoever owns them, so either lets someone else replace what the directory holds. + + The two relaxations are for paths the proxy only reads, never binds, and are off by default, which is the ``--uds`` rule. ``root_may_own`` also accepts uid 0, which can change anything anyway, so trusting it adds no one (bind mounts and Kubernetes ConfigMap volumes are root-owned while the proxy runs as another user). ``sticky_may_share`` accepts a group- or other-writable directory with the sticky bit set, where only an entry's owner (or the directory's, or root) can unlink or rename it; that is safe only together with a check that the entry itself is owned by the user or root and not writable by others, which the caller must make. """ - if info.st_uid != os.geteuid(): - return ForeignWriteAccess( - f"owned by uid {info.st_uid}, not by the user running the proxy (uid {os.geteuid()})", - other_owner=True, - ) - if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + euid = os.geteuid() + if info.st_uid != euid and not (root_may_own and info.st_uid == 0): + owners = f"the user running the proxy (uid {euid})" + (" or root" if root_may_own else "") + return ForeignWriteAccess(f"owned by uid {info.st_uid}, not by {owners}", other_owner=True) + if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH) and not ( + sticky_may_share and info.st_mode & stat.S_ISVTX + ): return ForeignWriteAccess( f"writable by group or others (mode {stat.S_IMODE(info.st_mode):04o})", other_owner=False, diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 9fc674937c..6c3924de30 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -338,13 +338,13 @@ def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowl def _require_private( path: str, st: os.stat_result, identity: _FileIdentity, *, directory: bool = True ) -> None: - """Raise :class:`_UnusableAllowlistFile` if a user other than this one could change the file. + """Raise :class:`_UnusableAllowlistFile` if a user other than this one or root could change the file. - The same rule as the ``--uds`` socket's directory (:func:`headroom.proxy.private_paths.foreign_write_access`): another owner, or group or other write permission, on the file itself, or with ``directory``, on the directory that holds the path, whose write permission lets anyone with it replace the file. A permission change on the file moves its status-change time, so that failure is remembered against the file's identity; one on the directory does not, so that failure is retried on every check until it clears. A cached good version is checked again whenever the file changes, which is the only way the directory's write access could reach it. Skipped where there is no ``os.geteuid`` (Windows), which has no POSIX ownership and modes to judge. + The ``--uds`` directory rule (:func:`headroom.proxy.private_paths.foreign_write_access`), relaxed for a file that is only read: the file must be owned by this user or root and not writable by group or others; with ``directory``, the directory that holds the path must be owned by this user or root and either not writable by group or others, or sticky, where only an entry's owner can unlink or rename it. Root is trusted because it can change anything anyway, which is what lets root-owned bind mounts and ConfigMap volumes work for a proxy running as another user. A permission change on the file moves its status-change time, so that failure is remembered against the file's identity; one on the directory does not, so that failure is retried on every check until it clears. A cached good version is checked again whenever the file changes, which is the only way the directory's write access could reach it. Skipped where there is no ``os.geteuid`` (Windows), which has no POSIX ownership and modes to judge. """ if not hasattr(os, "geteuid"): return - problem = foreign_write_access(st) + problem = foreign_write_access(st, root_may_own=True) if problem is not None: raise _UnusableAllowlistFile(f"the file is {problem.description}", identity) if not directory: @@ -354,7 +354,8 @@ def _require_private( parent_st = os.stat(parent) except OSError as exc: raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc - problem = foreign_write_access(parent_st) + # A sticky shared directory (/tmp style) is acceptable because the file was just checked: owned by this user or root and not writable by others, so nobody else may unlink or rename it there. + problem = foreign_write_access(parent_st, root_may_own=True, sticky_may_share=True) if problem is not None: raise _UnusableAllowlistFile( f"its directory is {problem.description}", identity, lasting=False diff --git a/tests/test_private_paths.py b/tests/test_private_paths.py new file mode 100644 index 0000000000..7466a14729 --- /dev/null +++ b/tests/test_private_paths.py @@ -0,0 +1,97 @@ +"""The rule deciding whether another user could change a path the proxy trusts.""" + +from __future__ import annotations + +import os +import stat + +import pytest + +from headroom.proxy.private_paths import foreign_write_access + +pytestmark = pytest.mark.skipif(not hasattr(os, "geteuid"), reason="POSIX ownership only") + +ME = 1000 +ROOT = 0 +OTHER = 2000 + + +def _stat(uid: int, mode: int) -> os.stat_result: + return os.stat_result((mode, 0, 0, 1, uid, 0, 0, 0, 0, 0)) + + +@pytest.fixture(autouse=True) +def _running_as_me(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "geteuid", lambda: ME) + + +FILE = stat.S_IFREG +DIR = stat.S_IFDIR + + +@pytest.mark.parametrize( + ("info", "kwargs"), + [ + (_stat(ME, FILE | 0o600), {}), + (_stat(ME, DIR | 0o700), {}), + (_stat(ROOT, FILE | 0o644), {"root_may_own": True}), + (_stat(ROOT, DIR | 0o755), {"root_may_own": True}), + (_stat(ROOT, DIR | 0o1777), {"root_may_own": True, "sticky_may_share": True}), + (_stat(ME, DIR | 0o1777), {"root_may_own": True, "sticky_may_share": True}), + ], + ids=[ + "own-file", + "own-dir", + "root-file", + "root-dir-0755", + "root-sticky-1777", + "own-sticky-1777", + ], +) +def test_accepted(info: os.stat_result, kwargs: dict[str, bool]) -> None: + assert foreign_write_access(info, **kwargs) is None + + +@pytest.mark.parametrize( + ("info", "kwargs", "description"), + [ + ( + _stat(ROOT, DIR | 0o700), + {}, + "owned by uid 0, not by the user running the proxy (uid 1000)", + ), + ( + _stat(OTHER, FILE | 0o600), + {"root_may_own": True}, + "owned by uid 2000, not by the user running the proxy (uid 1000) or root", + ), + ( + _stat(ME, FILE | 0o620), + {"root_may_own": True}, + "writable by group or others (mode 0620)", + ), + ( + _stat(ME, FILE | 0o1666), + {"root_may_own": True, "sticky_may_share": False}, + "writable by group or others (mode 1666)", + ), + (_stat(ME, DIR | 0o1777), {}, "writable by group or others (mode 1777)"), + ( + _stat(ROOT, DIR | 0o777), + {"root_may_own": True, "sticky_may_share": True}, + "writable by group or others (mode 0777)", + ), + ], + ids=[ + "uds-root-dir", + "other-owner-file", + "group-writable-file", + "sticky-file-not-shared", + "uds-sticky-dir", + "shared-dir-without-sticky", + ], +) +def test_refused(info: os.stat_result, kwargs: dict[str, bool], description: str) -> None: + problem = foreign_write_access(info, **kwargs) + assert problem is not None + assert problem.description == description diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 1ee2d084aa..aa5c650fdd 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -649,6 +649,70 @@ def test_the_failure_warning_shows_only_the_end_of_a_long_path( assert "d" * 10 not in warning +@_POSIX_USER +def test_an_own_file_in_a_sticky_shared_directory_is_accepted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + shared = tmp_path / "shared" + shared.mkdir() + allow_file = shared / "allowed" + _replace(allow_file, "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(allow_file)) + shared.chmod(0o1777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + finally: + shared.chmod(0o700) + + +@_POSIX_USER +def test_a_group_writable_file_in_a_sticky_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + shared = tmp_path / "shared" + shared.mkdir() + allow_file = shared / "allowed" + _replace(allow_file, "http://127.0.0.1:4100") + allow_file.chmod(0o664) + monkeypatch.setenv(FILE_ENV, str(allow_file)) + shared.chmod(0o1777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + shared.chmod(0o700) + (warning,) = [r.getMessage() for r in caplog.records] + assert "the file is writable by group or others (mode 0664)" in warning + + +@_POSIX_USER +def test_a_root_owned_file_in_a_root_owned_directory_is_accepted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A ConfigMap or bind mount read by a non-root proxy. Ownership cannot be given to root without being root, so the check sees root as the owner of everything here and the proxy as another user.""" + from headroom.proxy import private_paths + + mount = tmp_path / "mount" + mount.mkdir() + allow_file = mount / "allowed" + _replace(allow_file, "http://127.0.0.1:4100") + mount.chmod(0o755) + monkeypatch.setenv(FILE_ENV, str(allow_file)) + real_check = private_paths.foreign_write_access + + def as_root_owned(info: os.stat_result, **kwargs: bool): # noqa: ANN202 + owned_by_root = os.stat_result( + (info.st_mode, info.st_ino, info.st_dev, info.st_nlink, 0, 0, info.st_size, 0, 0, 0) + ) + return real_check(owned_by_root, **kwargs) + + real_uid = os.geteuid() + monkeypatch.setattr(upstream_guard, "foreign_write_access", as_root_owned) + monkeypatch.setattr(os, "geteuid", lambda: real_uid + 1) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 9487f40a159920dee87cefb1bc34d353f29b5eed Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 00:52:40 +0100 Subject: [PATCH 28/49] fix(proxy): check the directory of an allowlist symlink's target too Only the directory holding the path as given was checked, so a symlink in a private directory pointing at a file in a world-writable one passed, although anyone could replace the file it resolves to. The directory of the resolved file is now checked as well when it differs, and named as such in the warning. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 35 +++++++++++----- tests/test_upstream_guard_allowlist_file.py | 46 +++++++++++++++++++++ 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 6c4673e9eb..780e205bde 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. Its directory must be owned by the user running the proxy or by root, and must either not be writable by group or others or have the sticky bit set (a `/tmp`-style directory, where only an entry's owner can remove or rename it). Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user. This is looser than the rule for the `--uds` socket's directory, which must belong to the user running the proxy and never be writable by others, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directory checked is the one that holds the path as given and, when the path is a symlink to a file elsewhere, also the one that holds the file it resolves to. Each must be owned by the user running the proxy or by root, and must either not be writable by group or others or have the sticky bit set (a `/tmp`-style directory, where only an entry's owner can remove or rename it). Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user. This is looser than the rule for the `--uds` socket's directory, which must belong to the user running the proxy and never be writable by others, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 6c3924de30..da276b37b2 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -349,17 +349,30 @@ def _require_private( raise _UnusableAllowlistFile(f"the file is {problem.description}", identity) if not directory: return - parent = os.path.dirname(path) - try: - parent_st = os.stat(parent) - except OSError as exc: - raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc - # A sticky shared directory (/tmp style) is acceptable because the file was just checked: owned by this user or root and not writable by others, so nobody else may unlink or rename it there. - problem = foreign_write_access(parent_st, root_may_own=True, sticky_may_share=True) - if problem is not None: - raise _UnusableAllowlistFile( - f"its directory is {problem.description}", identity, lasting=False - ) + for parent, which in _allowlist_directories(path): + try: + parent_st = os.stat(parent) + except OSError as exc: + raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc + # A sticky shared directory (/tmp style) is acceptable because the file was just checked: owned by this user or root and not writable by others, so nobody else may unlink or rename it there. + problem = foreign_write_access(parent_st, root_may_own=True, sticky_may_share=True) + if problem is not None: + raise _UnusableAllowlistFile( + f"{which} is {problem.description}", identity, lasting=False + ) + + +def _allowlist_directories(path: str) -> list[tuple[str, str]]: + """The directories whose write access could replace the file ``path`` names, each with how to describe it. + + The directory holding ``path`` itself, where whoever can write could replace the entry there, and, when that entry is a symlink (or a chain of them) to a file elsewhere, the directory holding the file it resolves to, where whoever can write could replace the file. Symlinks among the directories' own ancestors (``/tmp`` to ``/private/tmp`` on macOS) do not count as a second directory: both resolve to the same place, which is checked once. + """ + link_directory = os.path.dirname(path) + target_directory = os.path.dirname(os.path.realpath(path)) + directories = [(link_directory, "its directory")] + if target_directory != os.path.realpath(link_directory): + directories.append((target_directory, "the directory of the file its symlink resolves to")) + return directories def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index aa5c650fdd..7e74236155 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -713,6 +713,52 @@ def as_root_owned(info: os.stat_result, **kwargs: bool): # noqa: ANN202 assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True +@_POSIX_USER +def test_a_symlink_into_a_shared_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The link sits in a private directory, but whoever can write the target's directory can replace the file.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + private, shared = tmp_path / "private", tmp_path / "shared" + private.mkdir(mode=0o700) + shared.mkdir() + _replace(shared / "allowed", "http://127.0.0.1:4100") + (private / "allowed").symlink_to(shared / "allowed") + monkeypatch.setenv(FILE_ENV, str(private / "allowed")) + shared.chmod(0o777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + shared.chmod(0o700) + (warning,) = [r.getMessage() for r in caplog.records] + assert ( + "the directory of the file its symlink resolves to is writable by group or others (mode 0777)" + in warning + ) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +@_POSIX_USER +def test_a_symlink_in_a_shared_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + private, shared = tmp_path / "private", tmp_path / "shared" + private.mkdir(mode=0o700) + shared.mkdir() + _replace(private / "allowed", "http://127.0.0.1:4100") + (shared / "allowed").symlink_to(private / "allowed") + monkeypatch.setenv(FILE_ENV, str(shared / "allowed")) + shared.chmod(0o777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + shared.chmod(0o700) + (warning,) = [r.getMessage() for r in caplog.records] + assert "its directory is writable by group or others (mode 0777)" in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 89b41b27ef355b61b69922dcba3b94fb9dc39283 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 00:54:37 +0100 Subject: [PATCH 29/49] fix(proxy): re-check the allowlist file's directories on every check A version read while its directory was private kept being served after the directory became writable by others, until the file itself changed. The directories that could replace the file are now stat-checked on every check: a cached version is withheld while they fail and served again, without a re-read, once they pass. The file's own checks still run only when it changes. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 99 ++++++++++++++------- tests/test_upstream_guard_allowlist_file.py | 31 ++++++- 3 files changed, 98 insertions(+), 34 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 780e205bde..e1cc634d73 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -199,7 +199,7 @@ destination by name, which also turns off the resolve-and-pin check for it. Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, tabs or line breaks (newline, carriage return, vertical tab, form feed, the file, group and record separators, NEL, and the Unicode line and paragraph separators). A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. -The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file costs one `stat` per request. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directory checked is the one that holds the path as given and, when the path is a symlink to a file elsewhere, also the one that holds the file it resolves to. Each must be owned by the user running the proxy or by root, and must either not be writable by group or others or have the sticky bit set (a `/tmp`-style directory, where only an entry's owner can remove or rename it). Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user. This is looser than the rule for the `--uds` socket's directory, which must belong to the user running the proxy and never be writable by others, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index da276b37b2..a11e6dd10c 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -29,7 +29,8 @@ sessions are still using. Replace it atomically (write a sibling, then rename over it): a change is detected by the file's identity (device, inode, size, and modification and status-change times), and an unchanged - file costs one ``stat`` per check, never a read. A version that cannot be + file is never read again; each check costs a ``stat`` of the file and of + the directories that could replace it, which are re-checked every time. A version that cannot be used is remembered the same way, so a broken file is not re-opened on every request. @@ -335,31 +336,34 @@ def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowl return cls(_os_error_reason(exc), identity, lasting=exc.errno in _FILE_ERRNOS) -def _require_private( - path: str, st: os.stat_result, identity: _FileIdentity, *, directory: bool = True -) -> None: - """Raise :class:`_UnusableAllowlistFile` if a user other than this one or root could change the file. +def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: + """Raise :class:`_UnusableAllowlistFile` if a user other than this one or root could change the file itself. - The ``--uds`` directory rule (:func:`headroom.proxy.private_paths.foreign_write_access`), relaxed for a file that is only read: the file must be owned by this user or root and not writable by group or others; with ``directory``, the directory that holds the path must be owned by this user or root and either not writable by group or others, or sticky, where only an entry's owner can unlink or rename it. Root is trusted because it can change anything anyway, which is what lets root-owned bind mounts and ConfigMap volumes work for a proxy running as another user. A permission change on the file moves its status-change time, so that failure is remembered against the file's identity; one on the directory does not, so that failure is retried on every check until it clears. A cached good version is checked again whenever the file changes, which is the only way the directory's write access could reach it. Skipped where there is no ``os.geteuid`` (Windows), which has no POSIX ownership and modes to judge. + The ``--uds`` directory rule (:func:`headroom.proxy.private_paths.foreign_write_access`), relaxed for a file that is only read: owned by this user or root and not writable by group or others. Root is trusted because it can change anything anyway, which is what lets root-owned bind mounts and ConfigMap volumes work for a proxy running as another user. A permission or owner change moves the file's status-change time, so the failure is remembered against its identity. Skipped where there is no ``os.geteuid`` (Windows), which has no POSIX ownership and modes to judge. """ if not hasattr(os, "geteuid"): return problem = foreign_write_access(st, root_may_own=True) if problem is not None: raise _UnusableAllowlistFile(f"the file is {problem.description}", identity) - if not directory: - return - for parent, which in _allowlist_directories(path): + + +def _directory_problem(directories: list[tuple[str, str]]) -> str | None: + """Why someone other than this user or root could replace the file through one of ``directories``, else ``None``. + + Each must be owned by this user or root and either not writable by group or others or sticky (``/tmp`` style), where only an entry's owner can unlink or rename it; that is safe because the file's own check requires it to be owned by this user or root. A directory's mode can change without the file changing, so this runs on every check, cached version or not, and its failure is never cached. Skipped where there is no ``os.geteuid`` (Windows). + """ + if not hasattr(os, "geteuid"): + return None + for directory, which in directories: try: - parent_st = os.stat(parent) + info = os.stat(directory) except OSError as exc: - raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc - # A sticky shared directory (/tmp style) is acceptable because the file was just checked: owned by this user or root and not writable by others, so nobody else may unlink or rename it there. - problem = foreign_write_access(parent_st, root_may_own=True, sticky_may_share=True) + return f"{which}: {_os_error_reason(exc)}" + problem = foreign_write_access(info, root_may_own=True, sticky_may_share=True) if problem is not None: - raise _UnusableAllowlistFile( - f"{which} is {problem.description}", identity, lasting=False - ) + return f"{which} is {problem.description}" + return None def _allowlist_directories(path: str) -> list[tuple[str, str]]: @@ -383,7 +387,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, identity = _FileIdentity.of(st) if not stat.S_ISREG(st.st_mode): raise _UnusableAllowlistFile("not a regular file", identity) - _require_private(path, st, identity) + _require_private_file(st, identity) try: fd = os.open(path, os.O_RDONLY | _O_NONBLOCK | _O_NOCTTY) except OSError as exc: @@ -394,7 +398,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, identity = _FileIdentity.of(st) if not stat.S_ISREG(st.st_mode): raise _UnusableAllowlistFile("not a regular file", identity) - _require_private(path, st, identity, directory=False) + _require_private_file(st, identity) too_large = _UnusableAllowlistFile( f"larger than {_MAX_ALLOWLIST_FILE_BYTES} bytes", identity ) @@ -417,7 +421,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, class _AllowlistFiles: """What each allowlist file held, re-read only when the file's identity changes. - A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs one ``stat`` per check like a good one, not an open and a read on every request. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. + A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs the same ``stat`` calls per check as a good one, not an open and a read on every request. A good version is served only while its directories still pass :func:`_directory_problem`, checked on every request; failing that it is withheld, not dropped, and served again once they pass. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together. The wait is bounded in bytes (one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``), not in time: a file on a stalled mount holds the reload, and every check that saw the change, for as long as the filesystem does, and a synchronous caller of :func:`is_safe_upstream_url` on the event loop blocks the loop meanwhile, as it already would on the ``stat``. Event-loop callers should use :func:`is_safe_upstream_url_async`, which runs the check on a worker thread. """ @@ -425,16 +429,42 @@ class _AllowlistFiles: def __init__(self) -> None: self._lock = threading.Lock() self._reload_lock = threading.Lock() - self._loaded: dict[str, tuple[_FileIdentity, Destinations | None]] = {} + # path -> (identity of the version read, its entries or None when unusable, the directories to re-check) + self._loaded: dict[ + str, tuple[_FileIdentity, Destinations | None, list[tuple[str, str]]] + ] = {} self._reported: dict[str, str] = {} - def _cached(self, path: str, identity: _FileIdentity) -> tuple[bool, Destinations | None]: - """``(True, what that version held)`` if ``identity`` is the version cached, else ``(False, None)``.""" + def _cached( + self, path: str, identity: _FileIdentity + ) -> tuple[Destinations | None, list[tuple[str, str]]] | None: + """What the cached version held and its directories, if ``identity`` is that version, else ``None``.""" with self._lock: cached = self._loaded.get(path) if cached is not None and cached[0] == identity: - return True, cached[1] - return False, None + return cached[1], cached[2] + return None + + def _serve( + self, + path: str, + cached: tuple[Destinations | None, list[tuple[str, str]]], + file_env: str, + env: str, + ) -> Destinations | None: + """Return a cached version's entries if its directories are still safe.""" + entries, directories = cached + if entries is None: + return None + problem = _directory_problem(directories) + if problem is not None: + self._report(path, problem, file_env, env) + return None + with self._lock: + recovered = self._reported.pop(path, None) is not None + if recovered: + logger.info("%s: %s is usable again", file_env, _logged_path(path)) + return entries def load(self, path: str, file_env: str, env: str) -> Destinations | None: """Return ``path``'s entries, or ``None`` when it cannot be used. @@ -448,25 +478,30 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: self._report(path, _os_error_reason(exc), file_env, env) return None identity = _FileIdentity.of(st) - hit, entries = self._cached(path, identity) - if hit: - return entries + cached = self._cached(path, identity) + if cached is not None: + return self._serve(path, cached, file_env, env) with self._reload_lock: # Another check may have read this very version while this one queued. - hit, entries = self._cached(path, identity) - if hit: - return entries + cached = self._cached(path, identity) + if cached is not None: + return self._serve(path, cached, file_env, env) + directories = _allowlist_directories(path) + problem = _directory_problem(directories) + if problem is not None: + self._report(path, problem, file_env, env) + return None try: identity, raw = _read_allowlist_file(path, st) except _UnusableAllowlistFile as exc: if exc.lasting: with self._lock: - self._loaded[path] = (exc.identity, None) + self._loaded[path] = (exc.identity, None, directories) self._report(path, str(exc), file_env, env) return None entries, skipped = _parse_destinations(raw, comments=True) with self._lock: - self._loaded[path] = (identity, entries) + self._loaded[path] = (identity, entries, directories) recovered = self._reported.pop(path, None) is not None logger.log( logging.WARNING if skipped else logging.INFO, diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 7e74236155..640b265e7a 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -630,7 +630,8 @@ def test_a_file_owned_by_another_user_is_refused( assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False (warning,) = [r.getMessage() for r in caplog.records] - assert f"the file is owned by uid {real_uid}" in warning + # The directory is checked first and is owned by the same "other" user; the file's own owner check is covered case by case in tests/test_private_paths.py. + assert f"is owned by uid {real_uid}, not by the user running the proxy" in warning def test_the_failure_warning_shows_only_the_end_of_a_long_path( @@ -759,6 +760,34 @@ def test_a_symlink_in_a_shared_directory_is_refused( assert "its directory is writable by group or others (mode 0777)" in warning +@_POSIX_USER +def test_a_cached_version_is_withheld_while_its_directory_is_unsafe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A directory made shared after the file was read is caught on the next check, not the next change.""" + caplog.set_level(logging.INFO, logger="headroom.proxy.upstream_guard") + shared = tmp_path / "shared" + shared.mkdir(mode=0o700) + allow_file = shared / "allowed" + _replace(allow_file, "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(allow_file)) + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + reads = _count_reads(monkeypatch) + + shared.chmod(0o770) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + shared.chmod(0o700) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert reads == [] + messages = [r.getMessage() for r in caplog.records] + assert len([m for m in messages if "its directory is writable by group or others" in m]) == 1 + assert any(m.endswith("is usable again") for m in messages) + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From b75d12347eb0208881b95b1da00b78e0782dc859 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 00:55:02 +0100 Subject: [PATCH 30/49] fix(proxy): open the allowlist file in binary mode on Windows os.open gives a text-mode descriptor on Windows, where a 0x1A byte ends the read early and could cut the list short. The open adds O_BINARY where the platform defines it. A test also covers the ownership checks being skipped when os.geteuid is absent, as on Windows. --- headroom/proxy/upstream_guard.py | 4 +- tests/test_upstream_guard_allowlist_file.py | 41 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index a11e6dd10c..0a3ad61698 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -286,6 +286,8 @@ def of(cls, st: os.stat_result) -> _FileIdentity: # `os.open` flags: O_NONBLOCK keeps a FIFO that appears at the path between the `stat` and the open from blocking the open until a writer arrives, and O_NOCTTY keeps a terminal that appears there from becoming the process's controlling terminal. Windows has neither flag, nor FIFOs or controlling terminals. _O_NONBLOCK = getattr(os, "O_NONBLOCK", 0) _O_NOCTTY = getattr(os, "O_NOCTTY", 0) +# Windows only: without O_BINARY a descriptor opened by `os.open` is in text mode, where a 0x1A byte (Ctrl-Z) ends the read early. POSIX has no text mode and no flag. +_O_BINARY = getattr(os, "O_BINARY", 0) # Errors that describe the file at the path, or the path itself, rather than the state of this process or the machine at the moment of the read. Only these are remembered against a file version: retrying the same version would meet them again. Everything else (EMFILE, ENFILE, ENOMEM, EIO, EINTR, ...) can clear without the file changing, so it is retried on the next check. @@ -389,7 +391,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, raise _UnusableAllowlistFile("not a regular file", identity) _require_private_file(st, identity) try: - fd = os.open(path, os.O_RDONLY | _O_NONBLOCK | _O_NOCTTY) + fd = os.open(path, os.O_RDONLY | _O_NONBLOCK | _O_NOCTTY | _O_BINARY) except OSError as exc: raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc with os.fdopen(fd, "rb") as handle: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 640b265e7a..8cd51384e0 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -788,6 +788,47 @@ def test_a_cached_version_is_withheld_while_its_directory_is_unsafe( assert any(m.endswith("is usable again") for m in messages) +def test_the_file_is_opened_in_binary_mode_where_the_platform_has_one( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """On Windows a text-mode descriptor stops at a 0x1A byte; the flag is what prevents it.""" + fake_o_binary = 1 << 30 + monkeypatch.setattr(upstream_guard, "_O_BINARY", fake_o_binary) + _replace(allow_file, "http://127.0.0.1:4100") + real_open = os.open + flags_seen: list[int] = [] + + def recording_open(path: str, flags: int, *args: object, **kwargs: object) -> int: + if os.fspath(path) == str(allow_file): + flags_seen.append(flags) + flags &= ~fake_o_binary + return real_open(path, flags, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(os, "open", recording_open) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert len(flags_seen) == 1 + assert flags_seen[0] & fake_o_binary + + +def test_without_geteuid_ownership_is_not_judged( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Windows has no os.geteuid and no POSIX modes; the file is still read and used.""" + shared = tmp_path / "shared" + shared.mkdir() + allow_file = shared / "allowed" + _replace(allow_file, "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(allow_file)) + monkeypatch.delattr(os, "geteuid", raising=False) + allow_file.chmod(0o666) + shared.chmod(0o777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + finally: + shared.chmod(0o700) + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 55a7a89882c210d52f72fb07262457b978047f33 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 00:55:47 +0100 Subject: [PATCH 31/49] fix(proxy): keep every resolved allowlist path for the life of the process resolve_allowlist_file_path used lru_cache(maxsize=16), so after sixteen other values a relative path was evicted and resolved again against the working directory of the moment, breaking the promise that it keeps the meaning of its first use. The cache is now an unbounded dict: its keys are the few values the file variable takes. A ~name for an unknown user is documented as left as written. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 16 ++++++--- tests/test_upstream_guard_allowlist_file.py | 38 ++++++++++++++++++--- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index e1cc634d73..a0b16a59d7 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -199,7 +199,7 @@ destination by name, which also turns off the resolve-and-pin check for it. Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, tabs or line breaks (newline, carriage return, vertical tab, form feed, the file, group and record separators, NEL, and the Unicode line and paragraph separators). A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. -The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded, and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directory checked is the one that holds the path as given and, when the path is a symlink to a file elsewhere, also the one that holds the file it resolves to. Each must be owned by the user running the proxy or by root, and must either not be writable by group or others or have the sticky bit set (a `/tmp`-style directory, where only an entry's owner can remove or rename it). Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user. This is looser than the rule for the `--uds` socket's directory, which must belong to the user running the proxy and never be writable by others, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 0a3ad61698..1f0df08672 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -78,7 +78,6 @@ import asyncio import errno -import functools import ipaddress import logging import os @@ -629,13 +628,22 @@ def _parse_variable(env: str, raw: str) -> Destinations: return destinations -@functools.lru_cache(maxsize=16) +# Unbounded on purpose: a bounded cache would evict a relative path and resolve it again against whatever the working directory is by then. The keys are the values the file variable has held in this process (the CLI option, the environment, stored settings), a handful at most. +_RESOLVED_ALLOWLIST_PATHS: dict[str, str] = {} +_RESOLVED_ALLOWLIST_PATHS_LOCK = threading.Lock() + + def resolve_allowlist_file_path(raw: str) -> str: """Return the file ``raw`` names, with ``~`` expanded and a relative path made absolute. - A relative path resolves against the working directory at its first use in this process and keeps that meaning afterwards, so a later change of directory cannot repoint the allowlist. Symlinks are not resolved, so a supervisor may swap a link's target. ``raw`` must already be stripped and not blank. + A relative path resolves against the working directory at its first use in this process and keeps that meaning afterwards, so a later change of directory cannot repoint the allowlist. ``~user`` for a user that does not exist is left as written, as ``os.path.expanduser`` leaves it, and so is a relative path like any other. Symlinks are not resolved, so a supervisor may swap a link's target. ``raw`` must already be stripped and not blank. """ - return os.path.abspath(os.path.expanduser(raw)) + with _RESOLVED_ALLOWLIST_PATHS_LOCK: + resolved = _RESOLVED_ALLOWLIST_PATHS.get(raw) + if resolved is None: + resolved = os.path.abspath(os.path.expanduser(raw)) + _RESOLVED_ALLOWLIST_PATHS[raw] = resolved + return resolved def allowlisted_destinations( diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 8cd51384e0..ada1e0829e 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -545,7 +545,7 @@ def test_the_guard_expands_a_home_relative_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HOME", str(tmp_path)) - upstream_guard.resolve_allowlist_file_path.cache_clear() + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() _replace(tmp_path / "allowed", "http://127.0.0.1:4100") monkeypatch.setenv(FILE_ENV, "~/allowed") @@ -555,7 +555,7 @@ def test_the_guard_expands_a_home_relative_path( def test_a_relative_path_keeps_the_directory_of_its_first_use( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - upstream_guard.resolve_allowlist_file_path.cache_clear() + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() first, elsewhere = tmp_path / "first", tmp_path / "elsewhere" first.mkdir() elsewhere.mkdir() @@ -829,6 +829,36 @@ def test_without_geteuid_ownership_is_not_judged( shared.chmod(0o700) +def test_a_relative_path_keeps_its_meaning_however_many_others_are_resolved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + first, elsewhere = tmp_path / "first", tmp_path / "elsewhere" + first.mkdir() + elsewhere.mkdir() + monkeypatch.chdir(first) + resolved = upstream_guard.resolve_allowlist_file_path("allowed") + + for n in range(64): + upstream_guard.resolve_allowlist_file_path(f"other-{n}") + monkeypatch.chdir(elsewhere) + + assert ( + upstream_guard.resolve_allowlist_file_path("allowed") == resolved == str(first / "allowed") + ) + + +def test_an_unknown_user_s_home_is_left_as_written( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + monkeypatch.chdir(tmp_path) + + resolved = upstream_guard.resolve_allowlist_file_path("~no-such-user-headroom/allowed") + + assert resolved == str(tmp_path / "~no-such-user-headroom" / "allowed") + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: @@ -1161,7 +1191,7 @@ def test_a_relative_path_is_made_absolute( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) - upstream_guard.resolve_allowlist_file_path.cache_clear() + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() result, seen = self._invoke(["--allowed-base-urls-file", "allowed"], {FILE_ENV: None}) @@ -1230,7 +1260,7 @@ def run_server_through_the_factory(config: ProxyConfig, **kwargs: object) -> Non assert seen["allows_public"] == "True" def test_a_home_relative_path_is_expanded(self, tmp_path: Path) -> None: - upstream_guard.resolve_allowlist_file_path.cache_clear() + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() result, seen = self._invoke( ["--allowed-base-urls-file", "~/allowed"], {FILE_ENV: None, "HOME": str(tmp_path)} ) From 778a3c943aa9b072a84a7433994274850cd7061f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 00:56:07 +0100 Subject: [PATCH 32/49] docs(proxy): use British spelling in the allowlist policy docstring --- headroom/proxy/upstream_guard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 1f0df08672..9a52327a1d 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -11,7 +11,7 @@ Azure, ...) are allowed so ordinary BYOK keeps working. * When ``HEADROOM_ALLOWED_BASE_URLS`` is set (hosts or URLs separated by commas, tabs or line breaks), bare hosts permit every safe scheme/port for that - host, while URLs permit only their exact normalized origin. A scheme-less + host, while URLs permit only their exact normalised origin. A scheme-less entry must be a hostname and nothing else: one with a port (even an empty one), a path, userinfo, a query or a fragment is skipped as invalid, as is any entry containing whitespace. From e2c7c73b6d4cae3a8d480e8c075bfc39318aef9c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 01:24:39 +0100 Subject: [PATCH 33/49] fix(proxy): refuse an allowlist file in a sticky shared directory The sticky-directory allowance checked the file a path leads to, never who created the entry at the path. In /tmp another user could plant a symlink to /etc/hosts, whose tab-separated loopback entry then admitted every port on 127.0.0.1, or on macOS hard-link a root-owned file there. foreign_write_access loses sticky_may_share, and the allowlist file's directories must not be writable by group or others, sticky or not; only the root-owner relaxation remains. The docs say a path directly in a shared directory is refused and a private subdirectory is the supported form. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/private_paths.py | 10 ++-- headroom/proxy/upstream_guard.py | 4 +- tests/test_private_paths.py | 20 ++++--- tests/test_upstream_guard_allowlist_file.py | 65 +++++++++++++++++---- 5 files changed, 72 insertions(+), 29 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index a0b16a59d7..bc2231cd81 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directory checked is the one that holds the path as given and, when the path is a symlink to a file elsewhere, also the one that holds the file it resolves to. Each must be owned by the user running the proxy or by root, and must either not be writable by group or others or have the sticky bit set (a `/tmp`-style directory, where only an entry's owner can remove or rename it). Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user. This is looser than the rule for the `--uds` socket's directory, which must belong to the user running the proxy and never be writable by others, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directory checked is the one that holds the path as given and, when the path is a symlink to a file elsewhere, also the one that holds the file it resolves to. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. A path directly in a shared directory such as `/tmp` is therefore refused: anyone can create an entry there first, such as a symlink to a file of their choosing or (on systems that allow it) a hard link to a root-owned one, and the file it leads to cannot show who created it. Use a private subdirectory instead, for example one made with `mkdir -m 700`. Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/private_paths.py b/headroom/proxy/private_paths.py index 4dbbf8da06..188cf87408 100644 --- a/headroom/proxy/private_paths.py +++ b/headroom/proxy/private_paths.py @@ -22,21 +22,21 @@ class ForeignWriteAccess: def foreign_write_access( - info: os.stat_result, *, root_may_own: bool = False, sticky_may_share: bool = False + info: os.stat_result, *, root_may_own: bool = False ) -> ForeignWriteAccess | None: """Return why another user could change the file or directory ``info`` describes, else ``None``. Its owner can always change it, so an owner other than the current effective user is refused first; otherwise group or other write permission is. For a directory, write permission is what allows unlinking or renaming its entries, whoever owns them, so either lets someone else replace what the directory holds. - The two relaxations are for paths the proxy only reads, never binds, and are off by default, which is the ``--uds`` rule. ``root_may_own`` also accepts uid 0, which can change anything anyway, so trusting it adds no one (bind mounts and Kubernetes ConfigMap volumes are root-owned while the proxy runs as another user). ``sticky_may_share`` accepts a group- or other-writable directory with the sticky bit set, where only an entry's owner (or the directory's, or root) can unlink or rename it; that is safe only together with a check that the entry itself is owned by the user or root and not writable by others, which the caller must make. + The sticky bit is never a substitute for write permission: in a sticky shared directory anyone can still create a new entry, such as a symlink to a file of their choosing or (where hard links to others' files are allowed) a hard link to one, at a path nobody has claimed yet, and checking the file that entry leads to cannot tell who made it. + + ``root_may_own`` relaxes the owner rule for paths the proxy only reads, never binds; it is off by default, which is the ``--uds`` rule. It also accepts uid 0, which can change anything anyway, so trusting it adds no one (bind mounts and Kubernetes ConfigMap volumes are root-owned while the proxy runs as another user). """ euid = os.geteuid() if info.st_uid != euid and not (root_may_own and info.st_uid == 0): owners = f"the user running the proxy (uid {euid})" + (" or root" if root_may_own else "") return ForeignWriteAccess(f"owned by uid {info.st_uid}, not by {owners}", other_owner=True) - if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH) and not ( - sticky_may_share and info.st_mode & stat.S_ISVTX - ): + if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH): return ForeignWriteAccess( f"writable by group or others (mode {stat.S_IMODE(info.st_mode):04o})", other_owner=False, diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 9a52327a1d..7b9a562b30 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -352,7 +352,7 @@ def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: def _directory_problem(directories: list[tuple[str, str]]) -> str | None: """Why someone other than this user or root could replace the file through one of ``directories``, else ``None``. - Each must be owned by this user or root and either not writable by group or others or sticky (``/tmp`` style), where only an entry's owner can unlink or rename it; that is safe because the file's own check requires it to be owned by this user or root. A directory's mode can change without the file changing, so this runs on every check, cached version or not, and its failure is never cached. Skipped where there is no ``os.geteuid`` (Windows). + Each must be owned by this user or root and not writable by group or others, sticky or not: in a shared directory such as ``/tmp`` anyone can plant the entry first (a symlink to a file of their choosing, or a hard link to a root-owned one), and the file it leads to cannot show who planted it. A directory's mode can change without the file changing, so this runs on every check, cached version or not, and its failure is never cached. Skipped where there is no ``os.geteuid`` (Windows). """ if not hasattr(os, "geteuid"): return None @@ -361,7 +361,7 @@ def _directory_problem(directories: list[tuple[str, str]]) -> str | None: info = os.stat(directory) except OSError as exc: return f"{which}: {_os_error_reason(exc)}" - problem = foreign_write_access(info, root_may_own=True, sticky_may_share=True) + problem = foreign_write_access(info, root_may_own=True) if problem is not None: return f"{which} is {problem.description}" return None diff --git a/tests/test_private_paths.py b/tests/test_private_paths.py index 7466a14729..1183acde7b 100644 --- a/tests/test_private_paths.py +++ b/tests/test_private_paths.py @@ -36,16 +36,12 @@ def _running_as_me(monkeypatch: pytest.MonkeyPatch) -> None: (_stat(ME, DIR | 0o700), {}), (_stat(ROOT, FILE | 0o644), {"root_may_own": True}), (_stat(ROOT, DIR | 0o755), {"root_may_own": True}), - (_stat(ROOT, DIR | 0o1777), {"root_may_own": True, "sticky_may_share": True}), - (_stat(ME, DIR | 0o1777), {"root_may_own": True, "sticky_may_share": True}), ], ids=[ "own-file", "own-dir", "root-file", "root-dir-0755", - "root-sticky-1777", - "own-sticky-1777", ], ) def test_accepted(info: os.stat_result, kwargs: dict[str, bool]) -> None: @@ -72,14 +68,19 @@ def test_accepted(info: os.stat_result, kwargs: dict[str, bool]) -> None: ), ( _stat(ME, FILE | 0o1666), - {"root_may_own": True, "sticky_may_share": False}, + {"root_may_own": True}, "writable by group or others (mode 1666)", ), (_stat(ME, DIR | 0o1777), {}, "writable by group or others (mode 1777)"), ( - _stat(ROOT, DIR | 0o777), - {"root_may_own": True, "sticky_may_share": True}, - "writable by group or others (mode 0777)", + _stat(ROOT, DIR | 0o1777), + {"root_may_own": True}, + "writable by group or others (mode 1777)", + ), + ( + _stat(ME, DIR | 0o1777), + {"root_may_own": True}, + "writable by group or others (mode 1777)", ), ], ids=[ @@ -88,7 +89,8 @@ def test_accepted(info: os.stat_result, kwargs: dict[str, bool]) -> None: "group-writable-file", "sticky-file-not-shared", "uds-sticky-dir", - "shared-dir-without-sticky", + "root-sticky-dir", + "own-sticky-dir", ], ) def test_refused(info: os.stat_result, kwargs: dict[str, bool], description: str) -> None: diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index ada1e0829e..b644ba0d54 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -9,6 +9,7 @@ import logging import os import socket +import stat import threading from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -651,39 +652,79 @@ def test_the_failure_warning_shows_only_the_end_of_a_long_path( @_POSIX_USER -def test_an_own_file_in_a_sticky_shared_directory_is_accepted( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_a_symlink_planted_in_a_sticky_shared_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: - shared = tmp_path / "shared" + """The /tmp attack: another user plants a link at the path to a file whose text admits loopback. + + The link's owner cannot be made another user without root, and it does not need to be: any group- or other-writable directory is refused, sticky or not, so who planted the entry is never relied on. + """ + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + shared, theirs = tmp_path / "shared", tmp_path / "theirs" shared.mkdir() - allow_file = shared / "allowed" - _replace(allow_file, "http://127.0.0.1:4100") - monkeypatch.setenv(FILE_ENV, str(allow_file)) + theirs.mkdir(mode=0o700) + hosts_like = theirs / "hosts" + hosts_like.write_text("127.0.0.1\tlocalhost\n", encoding="utf-8", newline="\n") + (shared / "headroom-allow").symlink_to(hosts_like) + monkeypatch.setenv(FILE_ENV, str(shared / "headroom-allow")) shared.chmod(0o1777) try: - assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:22/") is False finally: shared.chmod(0o700) + (warning,) = [r.getMessage() for r in caplog.records] + assert "its directory is writable by group or others (mode 1777)" in warning @_POSIX_USER -def test_a_group_writable_file_in_a_sticky_directory_is_refused( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +def test_an_own_file_in_a_sticky_shared_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") shared = tmp_path / "shared" shared.mkdir() allow_file = shared / "allowed" _replace(allow_file, "http://127.0.0.1:4100") - allow_file.chmod(0o664) monkeypatch.setenv(FILE_ENV, str(allow_file)) shared.chmod(0o1777) try: assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False finally: shared.chmod(0o700) + + +@_POSIX_USER +def test_a_hard_link_to_a_root_owned_file_in_another_user_s_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """On macOS anyone may hard-link a root-owned file into their own directory; the directory rule refuses it.""" + from headroom.proxy import private_paths + + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + original, theirs = tmp_path / "original", tmp_path / "theirs" + original.mkdir(mode=0o700) + theirs.mkdir(mode=0o755) + _replace(original / "allowed", "http://127.0.0.1:4100") + os.link(original / "allowed", theirs / "allowed") + assert (theirs / "allowed").stat().st_nlink == 2 + monkeypatch.setenv(FILE_ENV, str(theirs / "allowed")) + real_check = private_paths.foreign_write_access + + def file_owned_by_root(info: os.stat_result, **kwargs: bool): # noqa: ANN202 + if stat.S_ISREG(info.st_mode): + info = os.stat_result( + (info.st_mode, info.st_ino, info.st_dev, info.st_nlink, 0, 0, info.st_size, 0, 0, 0) + ) + return real_check(info, **kwargs) + + real_uid = os.geteuid() + monkeypatch.setattr(upstream_guard, "foreign_write_access", file_owned_by_root) + # The directory keeps its real owner, which is now "another user". + monkeypatch.setattr(os, "geteuid", lambda: real_uid + 1) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False (warning,) = [r.getMessage() for r in caplog.records] - assert "the file is writable by group or others (mode 0664)" in warning + assert f"its directory is owned by uid {real_uid}, not by the user running the proxy" in warning @_POSIX_USER From 1eb6cf35e516c5842adf6c42a5ee98ab6eb50629 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 01:25:38 +0100 Subject: [PATCH 34/49] fix(proxy): check the directory of every hop in an allowlist symlink chain Only the directory of the path and of the final file were checked, so in priv/allow -> shared/mid -> safe/real anyone who could write shared/ could repoint mid. The chain is now walked link by link with readlink, bounded by _MAX_SYMLINK_HOPS (40, Linux's MAXSYMLINKS), and every hop's directory is checked. Walking rather than refusing chains keeps ordinary two-link setups working at the cost of a few lines. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 45 +++++++++++++--- tests/test_upstream_guard_allowlist_file.py | 57 +++++++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index bc2231cd81..6c45dab072 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directory checked is the one that holds the path as given and, when the path is a symlink to a file elsewhere, also the one that holds the file it resolves to. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. A path directly in a shared directory such as `/tmp` is therefore refused: anyone can create an entry there first, such as a symlink to a file of their choosing or (on systems that allow it) a hard link to a root-owned one, and the file it leads to cannot show who created it. Use a private subdirectory instead, for example one made with `mkdir -m 700`. Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directories checked are the one that holds the path as given and, when the path is a symlink, the one that holds every symlink it passes through and the one that holds the file it finally resolves to, since whoever can write any of them could repoint the chain. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. A path directly in a shared directory such as `/tmp` is therefore refused: anyone can create an entry there first, such as a symlink to a file of their choosing or (on systems that allow it) a hard link to a root-owned one, and the file it leads to cannot show who created it. Use a private subdirectory instead, for example one made with `mkdir -m 700`. Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 7b9a562b30..a992226278 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -367,17 +367,39 @@ def _directory_problem(directories: list[tuple[str, str]]) -> str | None: return None +# The most symlinks walked while finding the directories that hold a path's chain: Linux's MAXSYMLINKS (40), the larger of the common kernels' limits (macOS's is 32). The kernel refuses a longer chain before this walk would see it, since the file is stat-ed first, so the limit only bounds the walk. +_MAX_SYMLINK_HOPS = 40 + + +class _UnwalkableAllowlistPath(Exception): + """The path's chain of symlinks could not be walked to the file.""" + + def _allowlist_directories(path: str) -> list[tuple[str, str]]: """The directories whose write access could replace the file ``path`` names, each with how to describe it. - The directory holding ``path`` itself, where whoever can write could replace the entry there, and, when that entry is a symlink (or a chain of them) to a file elsewhere, the directory holding the file it resolves to, where whoever can write could replace the file. Symlinks among the directories' own ancestors (``/tmp`` to ``/private/tmp`` on macOS) do not count as a second directory: both resolve to the same place, which is checked once. + Every hop counts: the directory holding ``path``, the directory holding each symlink the path passes through on the way to the file, and the directory holding the file. Whoever can write any of them could swap the entry there and so choose the file. Each directory is resolved (ancestor symlinks such as ``/tmp`` to ``/private/tmp`` followed) and checked once. Raises ``OSError`` when a link cannot be read and :class:`_UnwalkableAllowlistPath` past ``_MAX_SYMLINK_HOPS``. """ - link_directory = os.path.dirname(path) - target_directory = os.path.dirname(os.path.realpath(path)) - directories = [(link_directory, "its directory")] - if target_directory != os.path.realpath(link_directory): - directories.append((target_directory, "the directory of the file its symlink resolves to")) - return directories + directories: list[tuple[str, str]] = [] + seen: set[str] = set() + entry = path + for hop in range(_MAX_SYMLINK_HOPS + 1): + is_link = os.path.islink(entry) + if hop == 0: + which = "its directory" + elif is_link: + which = "the directory of a symlink it passes through" + else: + which = "the directory of the file its symlink resolves to" + directory = os.path.realpath(os.path.dirname(entry)) + if directory not in seen: + seen.add(directory) + directories.append((directory, which)) + if not is_link: + return directories + # An absolute target replaces the base; a relative one is relative to the link's own directory. + entry = os.path.join(os.path.dirname(entry), os.readlink(entry)) + raise _UnwalkableAllowlistPath(f"its symlinks go more than {_MAX_SYMLINK_HOPS} deep") def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: @@ -487,7 +509,14 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: cached = self._cached(path, identity) if cached is not None: return self._serve(path, cached, file_env, env) - directories = _allowlist_directories(path) + try: + directories = _allowlist_directories(path) + except OSError as exc: + self._report(path, _os_error_reason(exc), file_env, env) + return None + except _UnwalkableAllowlistPath as exc: + self._report(path, str(exc), file_env, env) + return None problem = _directory_problem(directories) if problem is not None: self._report(path, problem, file_env, env) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index b644ba0d54..ef11b51e0f 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -900,6 +900,63 @@ def test_an_unknown_user_s_home_is_left_as_written( assert resolved == str(tmp_path / "~no-such-user-headroom" / "allowed") +@_POSIX_USER +def test_a_symlink_chain_through_a_shared_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """priv/allow -> shared/mid -> safe/real: both ends are private, but whoever writes shared/ can repoint mid.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + priv, shared, safe = tmp_path / "priv", tmp_path / "shared", tmp_path / "safe" + for directory in (priv, shared, safe): + directory.mkdir(mode=0o700) + _replace(safe / "real", "http://127.0.0.1:4100") + (shared / "mid").symlink_to(safe / "real") + (priv / "allow").symlink_to(shared / "mid") + monkeypatch.setenv(FILE_ENV, str(priv / "allow")) + shared.chmod(0o777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + shared.chmod(0o700) + (warning,) = [r.getMessage() for r in caplog.records] + assert ( + "the directory of a symlink it passes through is writable by group or others (mode 0777)" + in warning + ) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +def test_a_relative_symlink_hop_resolves_against_the_link_s_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + priv, safe = tmp_path / "priv", tmp_path / "safe" + priv.mkdir(mode=0o700) + safe.mkdir(mode=0o700) + _replace(safe / "real", "http://127.0.0.1:4100") + (priv / "allow").symlink_to(Path("..") / "safe" / "real") + monkeypatch.setenv(FILE_ENV, str(priv / "allow")) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + directories = [d for d, _ in upstream_guard._allowlist_directories(str(priv / "allow"))] + assert directories == [os.path.realpath(priv), os.path.realpath(safe)] + + +def test_a_symlink_chain_past_the_walk_limit_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + monkeypatch.setattr(upstream_guard, "_MAX_SYMLINK_HOPS", 1) + _replace(tmp_path / "real", "http://127.0.0.1:4100") + (tmp_path / "mid").symlink_to(tmp_path / "real") + (tmp_path / "allow").symlink_to(tmp_path / "mid") + monkeypatch.setenv(FILE_ENV, str(tmp_path / "allow")) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert "its symlinks go more than 1 deep" in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From b6e29ede2d6175b5d667694217332bc7079bcd71 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 01:26:18 +0100 Subject: [PATCH 35/49] fix(settings): show a blank allowlist file variable as an override An explicit --allowed-base-urls-file "" exports an empty variable, which switches a stored allowlist file off. effective_values and env_override treated an empty variable as unset, so the dashboard showed the stored path as in force. A field may now declare blank_overrides, and the allowlist file does: a set but blank variable shows as an override with no value. Other fields keep treating a blank variable as unset. --- headroom/settings_store.py | 15 +++++++++-- tests/test_proxy/test_settings_store.py | 36 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/headroom/settings_store.py b/headroom/settings_store.py index 16d9e54c6c..c9f017d55b 100644 --- a/headroom/settings_store.py +++ b/headroom/settings_store.py @@ -56,6 +56,8 @@ class SettingField: minimum: float | None = None maximum: float | None = None tier: str = "advanced" # "basic" | "advanced" - Settings vs Advanced tab placement + # A set but blank variable is itself an override meaning "no value", as the CLI exports for an explicit blank option, rather than being treated as unset. + blank_overrides: bool = False # Curated registry. Env formats verified against each knob's Click option in @@ -739,6 +741,7 @@ class SettingField: "are re-read whenever it changes, with no restart." ), tier="advanced", + blank_overrides=True, ), ) @@ -998,7 +1001,9 @@ def effective_values(stored: dict[str, Any] | None = None) -> dict[str, Any]: for field in SETTINGS: value = stored[field.key] if field.key in stored else field.default env_raw = os.environ.get(field.env) - if env_raw is not None and env_raw != "": + if _env_overrides_blank(field, env_raw): + value = None + elif env_raw is not None and env_raw != "": try: value = _coerce(field, env_raw) except (ValueError, TypeError): @@ -1007,6 +1012,11 @@ def effective_values(stored: dict[str, Any] | None = None) -> dict[str, Any]: return result +def _env_overrides_blank(field: SettingField, env_raw: str | None) -> bool: + """Whether ``env_raw`` is a set but blank value that ``field`` treats as an override with no value.""" + return field.blank_overrides and env_raw is not None and env_raw.strip() == "" + + def _mask(field: SettingField, value: Any) -> Any: if field.secret and value not in (None, ""): return _MASK @@ -1046,7 +1056,8 @@ def to_schema() -> dict[str, Any]: "minimum": field.minimum, "maximum": field.maximum, "tier": field.tier, - "env_override": bool(os.environ.get(field.env)), + "env_override": bool(os.environ.get(field.env)) + or _env_overrides_blank(field, os.environ.get(field.env)), "value": _mask(field, effective.get(field.key)), "stored": _mask(field, stored.get(field.key)), } diff --git a/tests/test_proxy/test_settings_store.py b/tests/test_proxy/test_settings_store.py index 0669f22c37..c25ed255a5 100644 --- a/tests/test_proxy/test_settings_store.py +++ b/tests/test_proxy/test_settings_store.py @@ -193,6 +193,42 @@ def test_a_stored_nul_is_dropped_and_later_settings_still_apply(self, workspace, assert os.environ["HEADROOM_PORT"] == "9898" +class TestBlankAllowlistFileOverride: + """``--allowed-base-urls-file ""`` exports an empty variable that switches the stored file off.""" + + @pytest.mark.parametrize("blank", ["", " "]) + def test_a_blank_variable_overrides_the_stored_path(self, workspace, monkeypatch, blank): + _clear_env(monkeypatch) + settings_store.save({"allowed_base_urls_file": "/run/headroom/allowed"}) + monkeypatch.setenv("HEADROOM_ALLOWED_BASE_URLS_FILE", blank) + + assert settings_store.effective_values()["allowed_base_urls_file"] is None + schema = settings_store.to_schema() + field = next(f for f in schema["fields"] if f["key"] == "allowed_base_urls_file") + assert field["env_override"] is True + assert field["value"] is None + assert field["stored"] == "/run/headroom/allowed" + + def test_an_unset_variable_shows_the_stored_path(self, workspace, monkeypatch): + _clear_env(monkeypatch) + settings_store.save({"allowed_base_urls_file": "/run/headroom/allowed"}) + + schema = settings_store.to_schema() + field = next(f for f in schema["fields"] if f["key"] == "allowed_base_urls_file") + assert field["env_override"] is False + assert field["value"] == "/run/headroom/allowed" + + def test_other_fields_still_treat_a_blank_variable_as_unset(self, workspace, monkeypatch): + _clear_env(monkeypatch) + settings_store.save({"openai_base_url": "https://gw.example"}) + monkeypatch.setenv("OPENAI_TARGET_API_URL", "") + + schema = settings_store.to_schema() + field = next(f for f in schema["fields"] if f["key"] == "openai_base_url") + assert field["env_override"] is False + assert field["value"] == "https://gw.example" + + class TestEffectiveValues: def test_default_then_file_then_env(self, workspace, monkeypatch): _clear_env(monkeypatch) From 90193637356e3f33ae1eb4c02f18f83a72d9536a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 01:26:40 +0100 Subject: [PATCH 36/49] test(proxy): fail closed when an allowlist directory cannot be stat-ed A cached version must be withheld, not served, while the stat of one of its directories fails, and served again once it succeeds. --- tests/test_upstream_guard_allowlist_file.py | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index ef11b51e0f..71d85590b1 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -957,6 +957,31 @@ def test_a_symlink_chain_past_the_walk_limit_is_refused( assert "its symlinks go more than 1 deep" in warning +@_POSIX_USER +def test_a_cached_version_is_withheld_when_its_directory_cannot_be_stat_ed( + allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + directory = os.path.realpath(allow_file.parent) + real_stat = os.stat + + def directory_unreachable(path: object, *args: object, **kwargs: object) -> os.stat_result: + if os.fspath(path) == directory: # type: ignore[arg-type] + raise OSError(errno.EIO, os.strerror(errno.EIO), directory) + return real_stat(path, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(os, "stat", directory_unreachable) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert "its directory: Input/output error (EIO)" in warning + + monkeypatch.setattr(os, "stat", real_stat) + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 6b4548476983e067be3d3d3815270bfa9613372b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 01:26:50 +0100 Subject: [PATCH 37/49] docs(proxy): say the allowlist's checked directories are fixed when it is read Re-walking a symlink chain on every request would cost a realpath and a readlink per hop, so the list is kept with the version read; a link repointed to reach the same file is re-evaluated only when that file changes. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 6c45dab072..e90dd919b5 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -199,7 +199,7 @@ destination by name, which also turns off the resolve-and-pin check for it. Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, tabs or line breaks (newline, carriage return, vertical tab, form feed, the file, group and record separators, NEL, and the Unicode line and paragraph separators). A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. -The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Which directories are checked is worked out when the file is read: repointing a symlink in the chain at a different file is picked up on the next request, but repointing it so that it still reaches the very same file is only re-evaluated once that file changes. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directories checked are the one that holds the path as given and, when the path is a symlink, the one that holds every symlink it passes through and the one that holds the file it finally resolves to, since whoever can write any of them could repoint the chain. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. A path directly in a shared directory such as `/tmp` is therefore refused: anyone can create an entry there first, such as a symlink to a file of their choosing or (on systems that allow it) a hard link to a root-owned one, and the file it leads to cannot show who created it. Use a private subdirectory instead, for example one made with `mkdir -m 700`. Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index a992226278..e7d1bc78e1 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -444,7 +444,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, class _AllowlistFiles: """What each allowlist file held, re-read only when the file's identity changes. - A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs the same ``stat`` calls per check as a good one, not an open and a read on every request. A good version is served only while its directories still pass :func:`_directory_problem`, checked on every request; failing that it is withheld, not dropped, and served again once they pass. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. + A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs the same ``stat`` calls per check as a good one, not an open and a read on every request. A good version is served only while its directories still pass :func:`_directory_problem`, checked on every request; failing that it is withheld, not dropped, and served again once they pass. Which directories those are is worked out when the version is read and kept with it, since walking the chain again would cost a ``realpath`` and a ``readlink`` per hop on every request: a link repointed so that it still reaches the same file version (same identity) keeps the old list until the file changes. Repointing it at a different file changes the identity, which re-reads and re-walks. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together. The wait is bounded in bytes (one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``), not in time: a file on a stalled mount holds the reload, and every check that saw the change, for as long as the filesystem does, and a synchronous caller of :func:`is_safe_upstream_url` on the event loop blocks the loop meanwhile, as it already would on the ``stat``. Event-loop callers should use :func:`is_safe_upstream_url_async`, which runs the check on a worker thread. """ From aff3b0ca597a5c9ac458b85839fd4d77f341dab5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 01:59:45 +0100 Subject: [PATCH 38/49] fix(proxy): require every directory on the allowlist path to be trusted Only the directory directly holding the file and each symlink was checked, and realpath followed the rest silently. Under a world-writable shared/, another user could rename a private shared/headroom away and link /etc in its place, or repoint a directory symlink in the middle of the path, and the next check admitted 127.0.0.1 from /etc/hosts. The path is now resolved one entry at a time, following symlinks where they are met, and every directory it passes through, from / down, must be owned by the user or root and not writable by group or others, sticky or not (the StrictModes rule). With every such directory changeable only by trusted users, the open that re-resolves the path reaches the file that was checked, so no descriptor walk is needed. Each request costs an lstat per directory on the path. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 114 +++++---- tests/test_upstream_guard_allowlist_file.py | 244 ++++++++++++++++++-- 3 files changed, 296 insertions(+), 64 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index e90dd919b5..f9f78322cf 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Which directories are checked is worked out when the file is read: repointing a symlink in the chain at a different file is picked up on the next request, but repointing it so that it still reaches the very same file is only re-evaluated once that file changes. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. The directories checked are the one that holds the path as given and, when the path is a symlink, the one that holds every symlink it passes through and the one that holds the file it finally resolves to, since whoever can write any of them could repoint the chain. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. A path directly in a shared directory such as `/tmp` is therefore refused: anyone can create an entry there first, such as a symlink to a file of their choosing or (on systems that allow it) a hard link to a root-owned one, and the file it leads to cannot show who created it. Use a private subdirectory instead, for example one made with `mkdir -m 700`. Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. So must every directory that resolving the path passes through: every directory from `/` down to the one holding the file and, when the path meets a symlink anywhere along it (at its end or in the middle), every directory down to the one holding that link and on down its target, since whoever can write any of them could swap an entry and choose the file. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. Anything under a shared directory such as `/tmp` is therefore refused, a private subdirectory made there with `mkdir -m 700` included: whoever can write the shared directory can rename that subdirectory away and put a symlink to a file of their choosing (or, where allowed, a hard link to a root-owned one) in its place, and the file it leads to cannot show who did it. Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index e7d1bc78e1..ccde13b055 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -29,10 +29,24 @@ sessions are still using. Replace it atomically (write a sibling, then rename over it): a change is detected by the file's identity (device, inode, size, and modification and status-change times), and an unchanged - file is never read again; each check costs a ``stat`` of the file and of - the directories that could replace it, which are re-checked every time. A version that cannot be - used is remembered the same way, so a broken file is not re-opened on - every request. + file is never read again; each check costs a ``stat`` of the file and an + ``lstat`` of every directory on its path, which are re-checked every + time. A version that cannot be used is remembered the same way, so a + broken file is not re-opened on every request. + +What the file lists is only trusted if nobody but the user running the proxy +or root could have chosen which file the path reaches (the rule OpenSSH's +StrictModes applies to authorized_keys). The file must be owned by that user or +root and not writable by group or others, and so must every directory that +resolving the path passes through: every directory from ``/`` down to the one +holding the file, and, when the path meets a symlink anywhere along it, every +directory down to the one holding that link and on down its target. No +exception is made for sticky directories, so anything under ``/tmp``, +``/var/tmp`` or another shared directory is refused, a private subdirectory +there included. Because each of those directories can be changed only by the +user or root, nobody else can swap an entry between this check and the +``open`` that follows it, so the ``open`` resolving the path again by name +reaches the file the check judged, and no descriptor-relative walk is needed. Naming a file is itself the operator's choice of allowlist mode, so it never falls back to the public-host default, whatever state the file is in: @@ -48,7 +62,8 @@ before it knows its endpoints therefore gets a proxy that refuses every caller-supplied upstream, not one that briefly admits every public host. * missing or unusable (permissions, not UTF-8, not a regular file, larger - than ``_MAX_ALLOWLIST_FILE_BYTES``): the same as empty, logged at warning level once per distinct failure rather than on + than ``_MAX_ALLOWLIST_FILE_BYTES``, or on a path someone other than the + user or root could change; see below): the same as empty, logged at warning level once per distinct failure rather than on every request, and logged again if it recurs after the file was read. * the file variable set to blank: as if unset, mirroring how a blank ``HEADROOM_ALLOWED_BASE_URLS`` is treated. @@ -349,57 +364,72 @@ def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: raise _UnusableAllowlistFile(f"the file is {problem.description}", identity) -def _directory_problem(directories: list[tuple[str, str]]) -> str | None: - """Why someone other than this user or root could replace the file through one of ``directories``, else ``None``. +def _directory_problem(directories: list[str]) -> str | None: + """Why someone other than this user or root could change one of ``directories``, else ``None``. - Each must be owned by this user or root and not writable by group or others, sticky or not: in a shared directory such as ``/tmp`` anyone can plant the entry first (a symlink to a file of their choosing, or a hard link to a root-owned one), and the file it leads to cannot show who planted it. A directory's mode can change without the file changing, so this runs on every check, cached version or not, and its failure is never cached. Skipped where there is no ``os.geteuid`` (Windows). + ``directories`` is what :func:`_path_directories` found: every directory resolving the path passes through, ``/`` included. Each must be a directory (by ``lstat``: the list is symlink-free, so a symlink now is a swap) owned by this user or root and not writable by group or others, sticky or not: in a shared directory such as ``/tmp`` anyone can plant an entry first (a symlink to a file of their choosing, or a hard link to a root-owned one), and the file it leads to cannot show who planted it. A directory's mode can change without the file changing, so this runs on every check, cached version or not, at the cost of one ``lstat`` per directory, and its failure is never cached. Skipped where there is no ``os.geteuid`` (Windows). """ if not hasattr(os, "geteuid"): return None - for directory, which in directories: + for directory in directories: try: - info = os.stat(directory) + info = os.lstat(directory) except OSError as exc: - return f"{which}: {_os_error_reason(exc)}" + return f"the directory {_logged_path(directory)} on its path: {_os_error_reason(exc)}" + if not stat.S_ISDIR(info.st_mode): + return f"{_logged_path(directory)} on its path is no longer a directory" problem = foreign_write_access(info, root_may_own=True) if problem is not None: - return f"{which} is {problem.description}" + return f"the directory {_logged_path(directory)} on its path is {problem.description}" return None -# The most symlinks walked while finding the directories that hold a path's chain: Linux's MAXSYMLINKS (40), the larger of the common kernels' limits (macOS's is 32). The kernel refuses a longer chain before this walk would see it, since the file is stat-ed first, so the limit only bounds the walk. +# The most symlinks followed while resolving the path: Linux's MAXSYMLINKS (40), the larger of the common kernels' limits (macOS's is 32). The kernel refuses a longer chain before this walk would see it, since the file is stat-ed first, so the limit only bounds the walk. _MAX_SYMLINK_HOPS = 40 class _UnwalkableAllowlistPath(Exception): - """The path's chain of symlinks could not be walked to the file.""" + """The path could not be resolved to the file one entry at a time.""" -def _allowlist_directories(path: str) -> list[tuple[str, str]]: - """The directories whose write access could replace the file ``path`` names, each with how to describe it. +def _path_directories(path: str) -> list[str]: + """Every directory that resolving the absolute ``path`` passes through, from ``/`` down, each once. - Every hop counts: the directory holding ``path``, the directory holding each symlink the path passes through on the way to the file, and the directory holding the file. Whoever can write any of them could swap the entry there and so choose the file. Each directory is resolved (ancestor symlinks such as ``/tmp`` to ``/private/tmp`` followed) and checked once. Raises ``OSError`` when a link cannot be read and :class:`_UnwalkableAllowlistPath` past ``_MAX_SYMLINK_HOPS``. + The path is resolved as the kernel resolves it, one entry at a time, following each symlink where it is met (in the middle of the path as well as at its end, with a relative target taken from the link's own directory), and the directory holding every entry looked up is recorded. That makes the list the directories whose entries decide which file the path reaches: the directory holding the path's last entry, every directory above it, the directory holding each symlink and every directory above that, and so on to the file. Each is symlink-free, so a check on it cannot be redirected. Raises ``OSError`` when a link cannot be read and :class:`_UnwalkableAllowlistPath` past ``_MAX_SYMLINK_HOPS`` links. Returns nothing where there is no ``os.geteuid`` (Windows), where ownership is not judged. """ - directories: list[tuple[str, str]] = [] + if not hasattr(os, "geteuid"): + return [] + root = os.sep + pending = list(reversed(path.split(os.sep))) + current = root + directories: list[str] = [] seen: set[str] = set() - entry = path - for hop in range(_MAX_SYMLINK_HOPS + 1): - is_link = os.path.islink(entry) - if hop == 0: - which = "its directory" - elif is_link: - which = "the directory of a symlink it passes through" - else: - which = "the directory of the file its symlink resolves to" - directory = os.path.realpath(os.path.dirname(entry)) - if directory not in seen: - seen.add(directory) - directories.append((directory, which)) - if not is_link: - return directories - # An absolute target replaces the base; a relative one is relative to the link's own directory. - entry = os.path.join(os.path.dirname(entry), os.readlink(entry)) - raise _UnwalkableAllowlistPath(f"its symlinks go more than {_MAX_SYMLINK_HOPS} deep") + hops = 0 + while pending: + name = pending.pop() + if name in ("", "."): + continue + if name == "..": + # `current` is symlink-free, so its lexical parent is its real one. + current = os.path.dirname(current) + continue + if current not in seen: + seen.add(current) + directories.append(current) + entry = os.path.join(current, name) + if os.path.islink(entry): + hops += 1 + if hops > _MAX_SYMLINK_HOPS: + raise _UnwalkableAllowlistPath( + f"its symlinks go more than {_MAX_SYMLINK_HOPS} deep" + ) + target = os.readlink(entry) + if os.path.isabs(target): + current = root + pending.extend(reversed(target.split(os.sep))) + elif pending: + current = entry + return directories def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: @@ -444,7 +474,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, class _AllowlistFiles: """What each allowlist file held, re-read only when the file's identity changes. - A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs the same ``stat`` calls per check as a good one, not an open and a read on every request. A good version is served only while its directories still pass :func:`_directory_problem`, checked on every request; failing that it is withheld, not dropped, and served again once they pass. Which directories those are is worked out when the version is read and kept with it, since walking the chain again would cost a ``realpath`` and a ``readlink`` per hop on every request: a link repointed so that it still reaches the same file version (same identity) keeps the old list until the file changes. Repointing it at a different file changes the identity, which re-reads and re-walks. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. + A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs the same ``stat`` calls per check as a good one, not an open and a read on every request. A good version is served only while its directories still pass :func:`_directory_problem`, checked on every request; failing that it is withheld, not dropped, and served again once they pass. Which directories those are is worked out when the version is read and kept with it, since resolving the path again would cost a ``readlink`` per link and a lookup per entry on every request. Only the user or root can repoint a link on the path (every directory holding one is checked), and repointing it at a different file changes the identity, which re-reads and re-resolves; one repointed to reach the same file version keeps the old list until the file changes. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together. The wait is bounded in bytes (one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``), not in time: a file on a stalled mount holds the reload, and every check that saw the change, for as long as the filesystem does, and a synchronous caller of :func:`is_safe_upstream_url` on the event loop blocks the loop meanwhile, as it already would on the ``stat``. Event-loop callers should use :func:`is_safe_upstream_url_async`, which runs the check on a worker thread. """ @@ -453,14 +483,12 @@ def __init__(self) -> None: self._lock = threading.Lock() self._reload_lock = threading.Lock() # path -> (identity of the version read, its entries or None when unusable, the directories to re-check) - self._loaded: dict[ - str, tuple[_FileIdentity, Destinations | None, list[tuple[str, str]]] - ] = {} + self._loaded: dict[str, tuple[_FileIdentity, Destinations | None, list[str]]] = {} self._reported: dict[str, str] = {} def _cached( self, path: str, identity: _FileIdentity - ) -> tuple[Destinations | None, list[tuple[str, str]]] | None: + ) -> tuple[Destinations | None, list[str]] | None: """What the cached version held and its directories, if ``identity`` is that version, else ``None``.""" with self._lock: cached = self._loaded.get(path) @@ -471,7 +499,7 @@ def _cached( def _serve( self, path: str, - cached: tuple[Destinations | None, list[tuple[str, str]]], + cached: tuple[Destinations | None, list[str]], file_env: str, env: str, ) -> Destinations | None: @@ -510,7 +538,7 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: if cached is not None: return self._serve(path, cached, file_env, env) try: - directories = _allowlist_directories(path) + directories = _path_directories(path) except OSError as exc: self._report(path, _os_error_reason(exc), file_env, env) return None diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 71d85590b1..584090dfb4 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -49,6 +49,39 @@ def counting_read(path: str, st: os.stat_result): # noqa: ANN202 return reads +@pytest.fixture +def real_ancestors() -> None: + """Requested by a test that judges the real directories above ``tmp_path``.""" + + +def _on_its_path(directory: Path | str, description: str) -> str: + """The warning's wording for ``directory`` on the file's path failing the check.""" + return f"the directory {upstream_guard._logged_path(os.path.realpath(directory))} on its path is {description}" + + +def _strictly_above(directory: str, below: str) -> bool: + return below != directory and below.startswith(directory.rstrip(os.sep) + os.sep) + + +@pytest.fixture(autouse=True) +def _trust_the_directories_above_tmp_path( + request: pytest.FixtureRequest, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Treat the directories above ``tmp_path`` as trusted, which a test cannot arrange for real. + + Every directory on the allowlist file's path must be owned by the user or root and not writable by others, and ``tmp_path`` lives under the system's temporary directory: ``/tmp`` (sticky, world-writable) on Linux. So the check skips exactly the directories strictly above ``tmp_path``; ``tmp_path`` and everything a test makes inside it are judged for real. ``test_the_real_directories_above_tmp_path_are_judged`` opts out to check them as they are, and the ancestor rule itself is tested on fabricated ``lstat`` results. + """ + if "real_ancestors" in request.fixturenames: + return + below = os.path.realpath(tmp_path) + real_problem = upstream_guard._directory_problem + + def trusting_above(directories: list[str]) -> str | None: + return real_problem([d for d in directories if not _strictly_above(d, below)]) + + monkeypatch.setattr(upstream_guard, "_directory_problem", trusting_above) + + @pytest.fixture def allow_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: path = tmp_path / "allowed-base-urls" @@ -616,7 +649,7 @@ def test_a_file_in_a_group_writable_directory_is_refused_until_it_is_fixed( assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] assert len(warnings) == 1 - assert "its directory is writable by group or others (mode 0770)" in warnings[0] + assert _on_its_path(shared, "writable by group or others (mode 0770)") in warnings[0] @_POSIX_USER @@ -674,7 +707,7 @@ def test_a_symlink_planted_in_a_sticky_shared_directory_is_refused( finally: shared.chmod(0o700) (warning,) = [r.getMessage() for r in caplog.records] - assert "its directory is writable by group or others (mode 1777)" in warning + assert _on_its_path(shared, "writable by group or others (mode 1777)") in warning @_POSIX_USER @@ -724,7 +757,10 @@ def file_owned_by_root(info: os.stat_result, **kwargs: bool): # noqa: ANN202 assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False (warning,) = [r.getMessage() for r in caplog.records] - assert f"its directory is owned by uid {real_uid}, not by the user running the proxy" in warning + assert ( + _on_its_path(tmp_path, f"owned by uid {real_uid}, not by the user running the proxy") + in warning + ) @_POSIX_USER @@ -773,10 +809,7 @@ def test_a_symlink_into_a_shared_directory_is_refused( finally: shared.chmod(0o700) (warning,) = [r.getMessage() for r in caplog.records] - assert ( - "the directory of the file its symlink resolves to is writable by group or others (mode 0777)" - in warning - ) + assert _on_its_path(shared, "writable by group or others (mode 0777)") in warning assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True @@ -798,7 +831,7 @@ def test_a_symlink_in_a_shared_directory_is_refused( finally: shared.chmod(0o700) (warning,) = [r.getMessage() for r in caplog.records] - assert "its directory is writable by group or others (mode 0777)" in warning + assert _on_its_path(shared, "writable by group or others (mode 0777)") in warning @_POSIX_USER @@ -825,7 +858,9 @@ def test_a_cached_version_is_withheld_while_its_directory_is_unsafe( assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True assert reads == [] messages = [r.getMessage() for r in caplog.records] - assert len([m for m in messages if "its directory is writable by group or others" in m]) == 1 + assert ( + len([m for m in messages if _on_its_path(shared, "writable by group or others") in m]) == 1 + ) assert any(m.endswith("is usable again") for m in messages) @@ -919,10 +954,7 @@ def test_a_symlink_chain_through_a_shared_directory_is_refused( finally: shared.chmod(0o700) (warning,) = [r.getMessage() for r in caplog.records] - assert ( - "the directory of a symlink it passes through is writable by group or others (mode 0777)" - in warning - ) + assert _on_its_path(shared, "writable by group or others (mode 0777)") in warning assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True @@ -938,8 +970,9 @@ def test_a_relative_symlink_hop_resolves_against_the_link_s_directory( monkeypatch.setenv(FILE_ENV, str(priv / "allow")) assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True - directories = [d for d, _ in upstream_guard._allowlist_directories(str(priv / "allow"))] - assert directories == [os.path.realpath(priv), os.path.realpath(safe)] + directories = upstream_guard._path_directories(str(priv / "allow")) + assert directories[-2:] == [os.path.realpath(priv), os.path.realpath(safe)] + assert directories[0] == os.sep def test_a_symlink_chain_past_the_walk_limit_is_refused( @@ -965,23 +998,194 @@ def test_a_cached_version_is_withheld_when_its_directory_cannot_be_stat_ed( _replace(allow_file, "http://127.0.0.1:4100") assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True directory = os.path.realpath(allow_file.parent) - real_stat = os.stat + real_lstat = os.lstat def directory_unreachable(path: object, *args: object, **kwargs: object) -> os.stat_result: if os.fspath(path) == directory: # type: ignore[arg-type] raise OSError(errno.EIO, os.strerror(errno.EIO), directory) - return real_stat(path, *args, **kwargs) # type: ignore[arg-type] + return real_lstat(path, *args, **kwargs) # type: ignore[arg-type] - monkeypatch.setattr(os, "stat", directory_unreachable) + monkeypatch.setattr(os, "lstat", directory_unreachable) assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False (warning,) = [r.getMessage() for r in caplog.records] - assert "its directory: Input/output error (EIO)" in warning + assert ( + f"the directory {upstream_guard._logged_path(directory)} on its path: Input/output error (EIO)" + in warning + ) - monkeypatch.setattr(os, "stat", real_stat) + monkeypatch.setattr(os, "lstat", real_lstat) assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True +@_POSIX_USER +def test_a_private_directory_under_a_world_writable_one_is_refused_before_and_after_a_swap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Review case A2: shared/ (0777) holds a private headroom/ (0700); anyone can rename it and link /etc in its place.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + shared, etc_like = tmp_path / "shared", tmp_path / "etc-like" + shared.mkdir() + etc_like.mkdir(mode=0o700) + (etc_like / "allow").write_text("127.0.0.1\tlocalhost\n", encoding="utf-8", newline="\n") + (shared / "headroom").mkdir(mode=0o700) + _replace(shared / "headroom" / "allow", "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(shared / "headroom" / "allow")) + shared.chmod(0o777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (shared / "headroom").rename(shared / "moved") + (shared / "headroom").symlink_to(etc_like) + assert is_safe_upstream_url("http://127.0.0.1:22/") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + shared.chmod(0o700) + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert warnings + assert all( + _on_its_path(shared, "writable by group or others (mode 0777)") in w for w in warnings + ) + + +@_POSIX_USER +@pytest.mark.parametrize("shared_mode", [0o777, 0o755]) +def test_a_directory_symlink_in_the_middle_of_the_path_is_judged_where_it_lives( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, shared_mode: int +) -> None: + """Review case B: shared/cfg -> safe1, path shared/cfg/allow.txt. Whoever writes shared/ can repoint cfg.""" + shared, safe1, safe2 = tmp_path / "shared", tmp_path / "safe1", tmp_path / "safe2" + for directory in (shared, safe1, safe2): + directory.mkdir(mode=0o700) + _replace(safe1 / "allow.txt", "http://127.0.0.1:4100") + _replace(safe2 / "allow.txt", "http://127.0.0.1:4200") + (shared / "cfg").symlink_to(safe1) + monkeypatch.setenv(FILE_ENV, str(shared / "cfg" / "allow.txt")) + shared.chmod(shared_mode) + try: + first = is_safe_upstream_url("http://127.0.0.1:4100/v1") + (shared / "cfg").unlink() + (shared / "cfg").symlink_to(safe2) + second = is_safe_upstream_url("http://127.0.0.1:4200/v1") + finally: + shared.chmod(0o700) + + # Only the user can have repointed the link when shared/ is theirs alone. + expected = shared_mode == 0o755 + assert (first, second) == (expected, expected) + + +@_POSIX_USER +@pytest.mark.parametrize(("links", "admitted"), [(3, True), (4, False)]) +def test_the_symlink_limit_counts_every_link_followed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, links: int, admitted: bool +) -> None: + monkeypatch.setattr(upstream_guard, "_MAX_SYMLINK_HOPS", 3) + _replace(tmp_path / "real", "http://127.0.0.1:4100") + target = tmp_path / "real" + for n in range(links): + link = tmp_path / f"link-{n}" + link.symlink_to(target) + target = link + monkeypatch.setenv(FILE_ENV, str(target)) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is admitted + + +_ME, _ROOT, _OTHER = 1000, 0, 2000 + + +def _fabricated(uid: int, mode: int) -> os.stat_result: + return os.stat_result((stat.S_IFDIR | mode, 0, 0, 1, uid, 0, 0, 0, 0, 0)) + + +@pytest.mark.parametrize( + ("tree", "refused_at"), + [ + ( + { + "/": (_ROOT, 0o755), + "/home": (_ROOT, 0o755), + "/home/me": (_ME, 0o755), + "/home/me/cfg": (_ME, 0o700), + }, + None, + ), + ( + {"/": (_ROOT, 0o755), "/etc": (_ROOT, 0o755), "/etc/headroom": (_ROOT, 0o755)}, + None, + ), + ( + {"/": (_ROOT, 0o755), "/tmp": (_ROOT, 0o1777), "/tmp/mine": (_ME, 0o700)}, + ("/tmp", "writable by group or others (mode 1777)"), + ), + ( + {"/": (_ROOT, 0o755), "/srv": (_ROOT, 0o775), "/srv/headroom": (_ME, 0o700)}, + ("/srv", "writable by group or others (mode 0775)"), + ), + ( + { + "/": (_ROOT, 0o755), + "/home": (_ROOT, 0o755), + "/home/them": (_OTHER, 0o755), + "/home/them/cfg": (_ME, 0o700), + }, + ( + "/home/them", + "owned by uid 2000, not by the user running the proxy (uid 1000) or root", + ), + ), + ], + ids=["home", "root-controlled", "under-tmp", "group-writable-ancestor", "other-user-ancestor"], +) +def test_every_directory_on_the_path_must_be_trusted( + real_ancestors: None, + monkeypatch: pytest.MonkeyPatch, + tree: dict[str, tuple[int, int]], + refused_at: tuple[str, str] | None, +) -> None: + """The ancestor rule on fabricated ``lstat`` results, so it does not depend on this machine's /tmp.""" + monkeypatch.setattr(os, "geteuid", lambda: _ME) + monkeypatch.setattr(os, "lstat", lambda path: _fabricated(*tree[os.fspath(path)])) + + problem = upstream_guard._directory_problem(list(tree)) + + if refused_at is None: + assert problem is None + else: + directory, description = refused_at + assert ( + problem + == f"the directory {upstream_guard._logged_path(directory)} on its path is {description}" + ) + + +@_POSIX_USER +def test_the_real_directories_above_tmp_path_are_judged( + real_ancestors: None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No scaffolding: whether the file is used depends on this machine's real directories above tmp_path.""" + private = tmp_path / "private" + private.mkdir(mode=0o700) + _replace(private / "allow", "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(private / "allow")) + euid = os.geteuid() + above = [] + directory = os.path.realpath(tmp_path) + while True: + above.append(directory) + parent = os.path.dirname(directory) + if parent == directory: + break + directory = parent + trusted = all( + os.lstat(d).st_uid in (euid, 0) and not os.lstat(d).st_mode & (stat.S_IWGRP | stat.S_IWOTH) + for d in above + ) + + # On Linux tmp_path is under /tmp (sticky, world-writable), so this expects a refusal there. + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is trusted + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 8091961f8d80c2093b0c1c750735dcb231333825 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 02:00:09 +0100 Subject: [PATCH 39/49] docs(proxy): drop the ConfigMap claim and say where the allowlist file may live ConfigMap volumes are writable by group or others, so the allowlist rule refuses them; the docstrings and configuration docs said root ownership made them work. The docs now name the supported places (under the proxy user's home, or a root-controlled directory, including a bind mount of one) and say that anything under /tmp, /var/tmp or another sticky shared directory is refused, a private subdirectory there included. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/private_paths.py | 2 +- headroom/proxy/upstream_guard.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index f9f78322cf..6fab1395a2 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Which directories are checked is worked out when the file is read: repointing a symlink in the chain at a different file is picked up on the next request, but repointing it so that it still reaches the very same file is only re-evaluated once that file changes. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. So must every directory that resolving the path passes through: every directory from `/` down to the one holding the file and, when the path meets a symlink anywhere along it (at its end or in the middle), every directory down to the one holding that link and on down its target, since whoever can write any of them could swap an entry and choose the file. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. Anything under a shared directory such as `/tmp` is therefore refused, a private subdirectory made there with `mkdir -m 700` included: whoever can write the shared directory can rename that subdirectory away and put a symlink to a file of their choosing (or, where allowed, a hard link to a root-owned one) in its place, and the file it leads to cannot show who did it. Root is accepted because it can change anything anyway, so a root-owned bind mount or Kubernetes ConfigMap volume works for a proxy that runs as another user; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. So must every directory that resolving the path passes through: every directory from `/` down to the one holding the file and, when the path meets a symlink anywhere along it (at its end or in the middle), every directory down to the one holding that link and on down its target, since whoever can write any of them could swap an entry and choose the file. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. Anything under a shared directory such as `/tmp` is therefore refused, a private subdirectory made there with `mkdir -m 700` included: whoever can write the shared directory can rename that subdirectory away and put a symlink to a file of their choosing (or, where allowed, a hard link to a root-owned one) in its place, and the file it leads to cannot show who did it. Root is accepted because it can change anything anyway; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Supported places for the file are therefore a directory under the home directory of the user running the proxy (where every directory from `/` down is owned by that user or root and none is writable by group or others), or a root-controlled directory such as one under `/etc`, including a bind mount of such a directory into a container whose every directory above it meets the same rule. A Kubernetes ConfigMap volume does not qualify: its directory is writable by group or others, so it is refused. Anything under `/tmp`, `/var/tmp` or another sticky shared directory is refused, including a private subdirectory made there with `mkdir -m 700`, because the directory above it is writable by others. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/private_paths.py b/headroom/proxy/private_paths.py index 188cf87408..01dbe4a8ab 100644 --- a/headroom/proxy/private_paths.py +++ b/headroom/proxy/private_paths.py @@ -30,7 +30,7 @@ def foreign_write_access( The sticky bit is never a substitute for write permission: in a sticky shared directory anyone can still create a new entry, such as a symlink to a file of their choosing or (where hard links to others' files are allowed) a hard link to one, at a path nobody has claimed yet, and checking the file that entry leads to cannot tell who made it. - ``root_may_own`` relaxes the owner rule for paths the proxy only reads, never binds; it is off by default, which is the ``--uds`` rule. It also accepts uid 0, which can change anything anyway, so trusting it adds no one (bind mounts and Kubernetes ConfigMap volumes are root-owned while the proxy runs as another user). + ``root_may_own`` relaxes the owner rule for paths the proxy only reads, never binds; it is off by default, which is the ``--uds`` rule. It also accepts uid 0, which can change anything anyway, so trusting it adds no one, and it lets a proxy running as another user read a root-owned file, such as one in a root-owned bind-mounted directory. """ euid = os.geteuid() if info.st_uid != euid and not (root_may_own and info.st_uid == 0): diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index ccde13b055..1699b405d6 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -355,7 +355,7 @@ def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowl def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: """Raise :class:`_UnusableAllowlistFile` if a user other than this one or root could change the file itself. - The ``--uds`` directory rule (:func:`headroom.proxy.private_paths.foreign_write_access`), relaxed for a file that is only read: owned by this user or root and not writable by group or others. Root is trusted because it can change anything anyway, which is what lets root-owned bind mounts and ConfigMap volumes work for a proxy running as another user. A permission or owner change moves the file's status-change time, so the failure is remembered against its identity. Skipped where there is no ``os.geteuid`` (Windows), which has no POSIX ownership and modes to judge. + The ``--uds`` directory rule (:func:`headroom.proxy.private_paths.foreign_write_access`), relaxed for a file that is only read: owned by this user or root and not writable by group or others. Root is trusted because it can change anything anyway, which is what lets a proxy running as another user read a root-owned file in a root-owned directory. A permission or owner change moves the file's status-change time, so the failure is remembered against its identity. Skipped where there is no ``os.geteuid`` (Windows), which has no POSIX ownership and modes to judge. """ if not hasattr(os, "geteuid"): return From cac62191ed9feb55289c0bd94fd657d1bb2039fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 02:34:18 +0100 Subject: [PATCH 40/49] fix(proxy): refuse an allowlist path the walk and the kernel resolve differently The walk follows a symlink by its readlink text, but the kernel follows a /proc//root magic link into another mount namespace, so a root proxy could check the host's directories while reading a file in a container's world-writable tmpfs. At each reload the walk's final entry is lstat-ed and compared, by device and inode, with a stat of the path taken straight after it; the file is refused when they never agree, because the path does not resolve to the file the permission check inspected. The walk is repeated up to _RESOLVE_ATTEMPTS times so that a replacement racing the two lookups is not mistaken for a disagreement. --- headroom/proxy/upstream_guard.py | 39 +++++++++++++++++---- tests/test_upstream_guard_allowlist_file.py | 30 +++++++++++++++- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 1699b405d6..442a4e3473 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -392,18 +392,19 @@ class _UnwalkableAllowlistPath(Exception): """The path could not be resolved to the file one entry at a time.""" -def _path_directories(path: str) -> list[str]: - """Every directory that resolving the absolute ``path`` passes through, from ``/`` down, each once. +def _path_directories(path: str) -> tuple[list[str], str | None]: + """Every directory that resolving the absolute ``path`` passes through, from ``/`` down, each once, and the entry it ends at. - The path is resolved as the kernel resolves it, one entry at a time, following each symlink where it is met (in the middle of the path as well as at its end, with a relative target taken from the link's own directory), and the directory holding every entry looked up is recorded. That makes the list the directories whose entries decide which file the path reaches: the directory holding the path's last entry, every directory above it, the directory holding each symlink and every directory above that, and so on to the file. Each is symlink-free, so a check on it cannot be redirected. Raises ``OSError`` when a link cannot be read and :class:`_UnwalkableAllowlistPath` past ``_MAX_SYMLINK_HOPS`` links. Returns nothing where there is no ``os.geteuid`` (Windows), where ownership is not judged. + The path is resolved as the kernel resolves it, one entry at a time, following each symlink where it is met (in the middle of the path as well as at its end, with a relative target taken from the link's own directory), and the directory holding every entry looked up is recorded. That makes the list the directories whose entries decide which file the path reaches: the directory holding the path's last entry, every directory above it, the directory holding each symlink and every directory above that, and so on to the file. Each is symlink-free, so a check on it cannot be redirected. The entry it ends at is what the walk believes the path names; the caller compares it with what the kernel actually opens, since a link the kernel follows by something other than its text (a ``/proc//root`` magic link) would otherwise send the check to one file and the read to another. Raises ``OSError`` when a link cannot be read and :class:`_UnwalkableAllowlistPath` past ``_MAX_SYMLINK_HOPS`` links. Returns no directories and no entry where there is no ``os.geteuid`` (Windows), where ownership is not judged. """ if not hasattr(os, "geteuid"): - return [] + return [], None root = os.sep pending = list(reversed(path.split(os.sep))) current = root directories: list[str] = [] seen: set[str] = set() + final: str | None = None hops = 0 while pending: name = pending.pop() @@ -427,9 +428,33 @@ def _path_directories(path: str) -> list[str]: if os.path.isabs(target): current = root pending.extend(reversed(target.split(os.sep))) - elif pending: + continue + final = entry + if pending: current = entry - return directories + return directories, final + + +# How many times the walk is repeated when its final entry and the kernel's file differ. A replace landing between the two back-to-back lookups makes them differ once; a link the kernel follows by something other than its text (a /proc//root magic link) makes them differ every time. Five consecutive races within a pair of adjacent syscalls is far past any real writer. +_RESOLVE_ATTEMPTS = 5 + + +def _walk_agreeing_with_the_kernel(path: str) -> list[str]: + """The directories on ``path`` (:func:`_path_directories`), once the walk's final entry is the file the kernel resolves the path to. + + Compares the device and inode of an ``lstat`` of the walk's final entry with a ``stat`` of ``path`` taken straight after it, repeating the walk up to ``_RESOLVE_ATTEMPTS`` times so that a replacement racing the two lookups is not mistaken for a disagreement. Raises :class:`_UnwalkableAllowlistPath` when they never agree, and ``OSError`` from either lookup. + """ + for _ in range(_RESOLVE_ATTEMPTS): + directories, final = _path_directories(path) + if final is None: + return directories + walked = os.lstat(final) + opened = os.stat(path) + if (walked.st_dev, walked.st_ino) == (opened.st_dev, opened.st_ino): + return directories + raise _UnwalkableAllowlistPath( + "the path does not resolve to the file the permission check inspected" + ) def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: @@ -538,7 +563,7 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: if cached is not None: return self._serve(path, cached, file_env, env) try: - directories = _path_directories(path) + directories = _walk_agreeing_with_the_kernel(path) except OSError as exc: self._report(path, _os_error_reason(exc), file_env, env) return None diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 584090dfb4..80869d2717 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -970,7 +970,8 @@ def test_a_relative_symlink_hop_resolves_against_the_link_s_directory( monkeypatch.setenv(FILE_ENV, str(priv / "allow")) assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True - directories = upstream_guard._path_directories(str(priv / "allow")) + directories, final = upstream_guard._path_directories(str(priv / "allow")) + assert final == os.path.join(os.path.realpath(safe), "real") assert directories[-2:] == [os.path.realpath(priv), os.path.realpath(safe)] assert directories[0] == os.sep @@ -1186,6 +1187,33 @@ def test_the_real_directories_above_tmp_path_are_judged( assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is trusted +@_POSIX_USER +def test_a_link_the_kernel_follows_elsewhere_than_its_text_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A /proc//root magic link reads as one path and leads to another; simulated by a readlink that disagrees with the kernel.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + priv = tmp_path / "priv" + priv.mkdir(mode=0o700) + _replace(priv / "opened", "http://127.0.0.1:4100") + _replace(priv / "inspected", "http://127.0.0.1:4200") + (priv / "allow").symlink_to(priv / "opened") + monkeypatch.setenv(FILE_ENV, str(priv / "allow")) + real_readlink = os.readlink + + def readlink_text_disagrees(path: object, *args: object, **kwargs: object) -> str: + if os.fspath(path) == os.path.join(os.path.realpath(priv), "allow"): # type: ignore[arg-type] + return str(priv / "inspected") + return real_readlink(path, *args, **kwargs) # type: ignore[arg-type,no-any-return] + + monkeypatch.setattr(os, "readlink", readlink_text_disagrees) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert "the path does not resolve to the file the permission check inspected" in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 7de1af1638015ba160bebd61525dd656b6b0754a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 02:37:07 +0100 Subject: [PATCH 41/49] fix(proxy): leave .. and a trailing slash in the allowlist path to the kernel resolve_allowlist_file_path used os.path.abspath, which collapses .. and drops a trailing slash before anything is resolved, so a/link/../allow read a/allow while the kernel reads the link target's parent, and allow/ on a regular file was accepted. A relative path is now joined to the working directory without normalising, and the walk and the kernel both take .. after following the symlink before it. --- headroom/proxy/upstream_guard.py | 4 +-- tests/test_upstream_guard_allowlist_file.py | 31 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 442a4e3473..aefdfe7b81 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -718,12 +718,12 @@ def _parse_variable(env: str, raw: str) -> Destinations: def resolve_allowlist_file_path(raw: str) -> str: """Return the file ``raw`` names, with ``~`` expanded and a relative path made absolute. - A relative path resolves against the working directory at its first use in this process and keeps that meaning afterwards, so a later change of directory cannot repoint the allowlist. ``~user`` for a user that does not exist is left as written, as ``os.path.expanduser`` leaves it, and so is a relative path like any other. Symlinks are not resolved, so a supervisor may swap a link's target. ``raw`` must already be stripped and not blank. + A relative path resolves against the working directory at its first use in this process and keeps that meaning afterwards, so a later change of directory cannot repoint the allowlist. ``~user`` for a user that does not exist is left as written, as ``os.path.expanduser`` leaves it, and so is a relative path like any other. Symlinks are not resolved, so a supervisor may swap a link's target, and nothing else is normalised either: ``..`` and a trailing slash are left for the kernel and for :func:`_path_directories`, which both take ``..`` after following any symlink before it (``dir/link/..`` is the link target's parent, not ``dir``), and a trailing slash makes a regular file fail to resolve, as it would for ``open``. ``raw`` must already be stripped and not blank. """ with _RESOLVED_ALLOWLIST_PATHS_LOCK: resolved = _RESOLVED_ALLOWLIST_PATHS.get(raw) if resolved is None: - resolved = os.path.abspath(os.path.expanduser(raw)) + resolved = os.path.join(os.getcwd(), os.path.expanduser(raw)) _RESOLVED_ALLOWLIST_PATHS[raw] = resolved return resolved diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 80869d2717..23e775cf08 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -1214,6 +1214,37 @@ def readlink_text_disagrees(path: object, *args: object, **kwargs: object) -> st assert "the path does not resolve to the file the permission check inspected" in warning +def test_dot_dot_after_a_symlink_is_taken_where_the_kernel_takes_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``a/link/../allow`` with ``link -> b/sub`` is ``b/allow`` to the kernel, not the lexical ``a/allow``.""" + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + a, b = tmp_path / "a", tmp_path / "b" + a.mkdir(mode=0o700) + (b / "sub").mkdir(parents=True, mode=0o700) + b.chmod(0o700) + _replace(a / "allow", "http://127.0.0.1:4100") + _replace(b / "allow", "http://127.0.0.1:4200") + (a / "link").symlink_to(b / "sub") + monkeypatch.setenv(FILE_ENV, f"{a}/link/../allow") + + assert is_safe_upstream_url("http://127.0.0.1:4200/v1") is True + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + + +def test_a_trailing_slash_on_a_regular_file_is_refused( + allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + _replace(allow_file, "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, f"{allow_file}/") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert "Not a directory (ENOTDIR)" in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From ca56a484b62680e27ec9abd6b14f9c8e17f24bd4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 02:38:04 +0100 Subject: [PATCH 42/49] fix(proxy): say how to fix an allowlist file refused for its permissions The refusal named the file or directory and the mode or owner, but not what to do. A group- or other-writable file or directory now adds "Run chmod go-w on ", and a wrong owner adds that it must be owned by the proxy's uid or root (chown), or the file kept elsewhere. The --uds messages are unchanged. --- headroom/proxy/upstream_guard.py | 21 ++++++++++++++++++--- tests/test_upstream_guard_allowlist_file.py | 20 +++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index aefdfe7b81..2fcd0a1228 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -107,7 +107,7 @@ from dataclasses import dataclass from urllib.parse import urlparse -from headroom.proxy.private_paths import foreign_write_access +from headroom.proxy.private_paths import ForeignWriteAccess, foreign_write_access logger = logging.getLogger(__name__) @@ -352,6 +352,16 @@ def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowl return cls(_os_error_reason(exc), identity, lasting=exc.errno in _FILE_ERRNOS) +def _remedy(problem: ForeignWriteAccess, subject: str) -> str: + """What the operator can do about ``problem`` with ``subject``, for the refusal's log line.""" + if problem.other_owner: + return ( + f"Make {subject} owned by uid {os.geteuid()} or root (chown), or keep the allowlist " + "file where every directory on its path is" + ) + return f"Run chmod go-w on {subject}" + + def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: """Raise :class:`_UnusableAllowlistFile` if a user other than this one or root could change the file itself. @@ -361,7 +371,9 @@ def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: return problem = foreign_write_access(st, root_may_own=True) if problem is not None: - raise _UnusableAllowlistFile(f"the file is {problem.description}", identity) + raise _UnusableAllowlistFile( + f"the file is {problem.description}. {_remedy(problem, 'the file')}", identity + ) def _directory_problem(directories: list[str]) -> str | None: @@ -380,7 +392,10 @@ def _directory_problem(directories: list[str]) -> str | None: return f"{_logged_path(directory)} on its path is no longer a directory" problem = foreign_write_access(info, root_may_own=True) if problem is not None: - return f"the directory {_logged_path(directory)} on its path is {problem.description}" + return ( + f"the directory {_logged_path(directory)} on its path is {problem.description}." + f" {_remedy(problem, _logged_path(directory))}" + ) return None diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 23e775cf08..747c3fc8f1 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -622,7 +622,9 @@ def test_a_group_writable_file_is_refused( assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False (warning,) = [r.getMessage() for r in caplog.records] - assert "the file is writable by group or others (mode 0620)" in warning + assert ( + "the file is writable by group or others (mode 0620). Run chmod go-w on the file" in warning + ) allow_file.chmod(0o600) assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True @@ -650,6 +652,9 @@ def test_a_file_in_a_group_writable_directory_is_refused_until_it_is_fixed( warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] assert len(warnings) == 1 assert _on_its_path(shared, "writable by group or others (mode 0770)") in warnings[0] + assert ( + f"Run chmod go-w on {upstream_guard._logged_path(os.path.realpath(shared))}" in warnings[0] + ) @_POSIX_USER @@ -1154,10 +1159,15 @@ def test_every_directory_on_the_path_must_be_trusted( assert problem is None else: directory, description = refused_at - assert ( - problem - == f"the directory {upstream_guard._logged_path(directory)} on its path is {description}" - ) + logged = upstream_guard._logged_path(directory) + if description.startswith("owned by"): + remedy = ( + f"Make {logged} owned by uid {_ME} or root (chown), or keep the allowlist file " + "where every directory on its path is" + ) + else: + remedy = f"Run chmod go-w on {logged}" + assert problem == f"the directory {logged} on its path is {description}. {remedy}" @_POSIX_USER From 37150df80c14606f691ecef72a2e1cfb13221f6f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 02:38:32 +0100 Subject: [PATCH 43/49] test(proxy): refuse an unsafe directory at every depth above the allowlist file With the tmp_path scaffolding off, each real directory above tmp_path in turn reports mode 0777 (the rest root-owned 0755), and every one must make is_safe_upstream_url refuse. A walk that skipped the second level, or everything between / and depth 4, passed every earlier test; both now fail this one. --- tests/test_upstream_guard_allowlist_file.py | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 747c3fc8f1..8db84c0f00 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -1255,6 +1255,56 @@ def test_a_trailing_slash_on_a_regular_file_is_refused( assert "Not a directory (ENOTDIR)" in warning +@_POSIX_USER +def test_an_unsafe_directory_at_any_depth_above_tmp_path_is_refused( + real_ancestors: None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """With the scaffolding off, each real directory above tmp_path in turn reports mode 0777; every one must refuse. + + The others report as root-owned 0755 so the outcome depends only on the one made unsafe, on any machine (on Linux /tmp would otherwise refuse them all). A walk that skipped any depth, the second level or everything between / and depth 4, fails here. + """ + private = tmp_path / "private" + private.mkdir(mode=0o700) + _replace(private / "allow", "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(private / "allow")) + below = os.path.realpath(tmp_path) + above = [d for d in _ancestors_of(below) if d != below] + assert above[0] == os.sep and len(above) >= 2 + real_lstat = os.lstat + unsafe: list[str] = [] + + def ancestors_as_configured(path: object, *args: object, **kwargs: object) -> os.stat_result: + real = real_lstat(path, *args, **kwargs) # type: ignore[arg-type] + name = os.fspath(path) # type: ignore[arg-type] + if name in above: + mode = 0o777 if unsafe and name == unsafe[0] else 0o755 + return os.stat_result( + (stat.S_IFDIR | mode, real.st_ino, real.st_dev, 1, 0, 0, 0, 0, 0, 0) + ) + return real + + monkeypatch.setattr(os, "lstat", ancestors_as_configured) + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + for directory in above: + unsafe[:] = [directory] + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False, directory + + unsafe.clear() + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +def _ancestors_of(directory: str) -> list[str]: + """``directory`` and every directory above it, from ``/`` down.""" + chain = [] + while True: + chain.append(directory) + parent = os.path.dirname(directory) + if parent == directory: + return list(reversed(chain)) + directory = parent + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From a23f8b26ee255738acae752e11b3f75e606b734c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 02:38:50 +0100 Subject: [PATCH 44/49] docs(proxy): state what the allowlist path check does not judge, and its cost Say the rule is stricter than OpenSSH StrictModes but, like it, judges owners and mode bits only: macOS and NFSv4 ACLs can grant write access the mode bits hide (POSIX ACLs show through the mask), and ownership is what the filesystem reports (FUSE allow_other, NFS without root_squash). The configuration docs also gave the old per-request cost; it is a stat of the file plus an lstat of every directory from / down on its path. --- docs/content/docs/configuration.mdx | 4 ++-- headroom/proxy/upstream_guard.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 6fab1395a2..f6ecb5db6f 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -199,9 +199,9 @@ destination by name, which also turns off the resolve-and-pin check for it. Entries are bare hosts (every port on that host) or URLs (that exact origin), separated by commas, tabs or line breaks (newline, carriage return, vertical tab, form feed, the file, group and record separators, NEL, and the Unicode line and paragraph separators). A scheme-less entry must be a hostname and nothing else: one with a port, even an empty one (`127.0.0.1:4100`, `127.0.0.1:`), a path (`gw.internal/v1`), userinfo (`user@gw.internal`), a query or a fragment is skipped as invalid, as is any entry containing whitespace. Write `http://127.0.0.1:4100` for that one origin, or the bare host for every port. Before this, such entries were read as a bare host and admitted every port, so a `HEADROOM_ALLOWED_BASE_URLS` (or `HEADROOM_PROXIED_BASE_URLS`) that relied on one now admits nothing for it. Any entry that cannot be parsed (an unbalanced `[`, a host that Unicode normalisation turns into URL syntax) is skipped too, and the rest of the list still applies; the proxy logs one warning per distinct variable value that held any. -The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file and of its directory (and of the resolved file's directory for a symlink), and the directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Which directories are checked is worked out when the file is read: repointing a symlink in the chain at a different file is picked up on the next request, but repointing it so that it still reaches the very same file is only re-evaluated once that file changes. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. +The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file plus an `lstat` of every directory from `/` down that resolving its path passes through, and those directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Which directories are checked is worked out when the file is read: repointing a symlink in the chain at a different file is picked up on the next request, but repointing it so that it still reaches the very same file is only re-evaluated once that file changes. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. So must every directory that resolving the path passes through: every directory from `/` down to the one holding the file and, when the path meets a symlink anywhere along it (at its end or in the middle), every directory down to the one holding that link and on down its target, since whoever can write any of them could swap an entry and choose the file. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. Anything under a shared directory such as `/tmp` is therefore refused, a private subdirectory made there with `mkdir -m 700` included: whoever can write the shared directory can rename that subdirectory away and put a symlink to a file of their choosing (or, where allowed, a hard link to a root-owned one) in its place, and the file it leads to cannot show who did it. Root is accepted because it can change anything anyway; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Supported places for the file are therefore a directory under the home directory of the user running the proxy (where every directory from `/` down is owned by that user or root and none is writable by group or others), or a root-controlled directory such as one under `/etc`, including a bind mount of such a directory into a container whose every directory above it meets the same rule. A Kubernetes ConfigMap volume does not qualify: its directory is writable by group or others, so it is refused. Anything under `/tmp`, `/var/tmp` or another sticky shared directory is refused, including a private subdirectory made there with `mkdir -m 700`, because the directory above it is writable by others. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. So must every directory that resolving the path passes through: every directory from `/` down to the one holding the file and, when the path meets a symlink anywhere along it (at its end or in the middle), every directory down to the one holding that link and on down its target, since whoever can write any of them could swap an entry and choose the file. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. Anything under a shared directory such as `/tmp` is therefore refused, a private subdirectory made there with `mkdir -m 700` included: whoever can write the shared directory can rename that subdirectory away and put a symlink to a file of their choosing (or, where allowed, a hard link to a root-owned one) in its place, and the file it leads to cannot show who did it. This is stricter than OpenSSH's StrictModes (there is no exception for sticky directories, and every directory a symlink passes through counts), but like it, it judges owners and mode bits only: access control lists are not read, so a macOS ACL such as `everyone allow add_file,delete_child` on a directory or `allow write,append` on the file, or an NFSv4 ACL, can grant write access the check cannot see (a POSIX ACL is covered, because its mask shows as the group bits), and ownership is whatever the filesystem reports, which a FUSE mount with `allow_other` or NFS without `root_squash` may not make trustworthy. Root is accepted because it can change anything anyway; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Supported places for the file are therefore a directory under the home directory of the user running the proxy (where every directory from `/` down is owned by that user or root and none is writable by group or others), or a root-controlled directory such as one under `/etc`, including a bind mount of such a directory into a container whose every directory above it meets the same rule. A Kubernetes ConfigMap volume does not qualify: its directory is writable by group or others, so it is refused. Anything under `/tmp`, `/var/tmp` or another sticky shared directory is refused, including a private subdirectory made there with `mkdir -m 700`, because the directory above it is writable by others. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 2fcd0a1228..19b023b82f 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -35,8 +35,10 @@ broken file is not re-opened on every request. What the file lists is only trusted if nobody but the user running the proxy -or root could have chosen which file the path reaches (the rule OpenSSH's -StrictModes applies to authorized_keys). The file must be owned by that user or +or root could have chosen which file the path reaches. The rule is stricter +than OpenSSH's StrictModes for authorized_keys (no sticky exception, and every +directory a symlink passes through counts), but like it, it judges owners and +mode bits only. The file must be owned by that user or root and not writable by group or others, and so must every directory that resolving the path passes through: every directory from ``/`` down to the one holding the file, and, when the path meets a symlink anywhere along it, every @@ -47,6 +49,12 @@ user or root, nobody else can swap an entry between this check and the ``open`` that follows it, so the ``open`` resolving the path again by name reaches the file the check judged, and no descriptor-relative walk is needed. +What is not judged: access control lists, which can grant write access the +mode bits do not show (a macOS ACL such as ``everyone allow +add_file,delete_child`` on a directory or ``allow write,append`` on the file, +and NFSv4 ACLs; a POSIX ACL is covered, because its mask appears as the group +bits), and the truth of the ownership a filesystem reports (a FUSE mount with +``allow_other``, NFS without ``root_squash``). Naming a file is itself the operator's choice of allowlist mode, so it never falls back to the public-host default, whatever state the file is in: From 2aa0e75002ddea398c99206ca45c935928acd39c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 03:05:41 +0100 Subject: [PATCH 45/49] fix(proxy): resolve an absolute allowlist path without the working directory resolve_allowlist_file_path called os.getcwd() for every path, so with the working directory deleted even an absolute path raised FileNotFoundError out of allowlisted_destinations and every check. The working directory is now consulted only for a relative path. When that fails the guard reports it once and refuses for that check (retried next time, nothing cached), and the CLI exits with a one-line error instead of a traceback. --- headroom/cli/proxy.py | 12 +++-- headroom/proxy/upstream_guard.py | 24 ++++++++-- tests/test_upstream_guard_allowlist_file.py | 52 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index eeebd366aa..d9b5bfb1fb 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -1380,9 +1380,15 @@ def proxy( allowed_base_urls_file = allowed_base_urls_file.strip() if allowed_base_urls_file: # The guard reads the environment on every check, in this process and in any worker it spawns, so the option is handed over there, resolved the way the guard resolves it so a worker started in another directory still names the same file. - os.environ[ALLOWED_BASE_URLS_FILE_ENV] = resolve_allowlist_file_path( - allowed_base_urls_file - ) + try: + os.environ[ALLOWED_BASE_URLS_FILE_ENV] = resolve_allowlist_file_path( + allowed_base_urls_file + ) + except OSError as exc: + raise click.ClickException( + f"cannot resolve --allowed-base-urls-file {allowed_base_urls_file!r} against " + f"the working directory: {exc.strerror}" + ) from None else: # Blank means no file, as a blank HEADROOM_ALLOWED_BASE_URLS means no list. Set to "" rather than removed: create_app re-applies stored settings with setdefault (in this process and in every worker), which would put a stored path back into a missing variable but leaves a present one alone, so an explicit `--allowed-base-urls-file ""` overrides a stored or inherited path. os.environ[ALLOWED_BASE_URLS_FILE_ENV] = "" diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 19b023b82f..ff29f587af 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -622,6 +622,10 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: ) return entries + def report_unresolvable(self, raw_path: str, reason: str, file_env: str, env: str) -> None: + """Report, once, a path that could not even be resolved to an absolute one.""" + self._report(raw_path, reason, file_env, env) + def _report(self, path: str, reason: str, file_env: str, env: str) -> None: """Log a failure once, until it changes or the file is read again.""" with self._lock: @@ -741,12 +745,13 @@ def _parse_variable(env: str, raw: str) -> Destinations: def resolve_allowlist_file_path(raw: str) -> str: """Return the file ``raw`` names, with ``~`` expanded and a relative path made absolute. - A relative path resolves against the working directory at its first use in this process and keeps that meaning afterwards, so a later change of directory cannot repoint the allowlist. ``~user`` for a user that does not exist is left as written, as ``os.path.expanduser`` leaves it, and so is a relative path like any other. Symlinks are not resolved, so a supervisor may swap a link's target, and nothing else is normalised either: ``..`` and a trailing slash are left for the kernel and for :func:`_path_directories`, which both take ``..`` after following any symlink before it (``dir/link/..`` is the link target's parent, not ``dir``), and a trailing slash makes a regular file fail to resolve, as it would for ``open``. ``raw`` must already be stripped and not blank. + A relative path resolves against the working directory at its first use in this process and keeps that meaning afterwards, so a later change of directory cannot repoint the allowlist. ``~user`` for a user that does not exist is left as written, as ``os.path.expanduser`` leaves it, and so is a relative path like any other. Symlinks are not resolved, so a supervisor may swap a link's target, and nothing else is normalised either: ``..`` and a trailing slash are left for the kernel and for :func:`_path_directories`, which both take ``..`` after following any symlink before it (``dir/link/..`` is the link target's parent, not ``dir``), and a trailing slash makes a regular file fail to resolve, as it would for ``open``. ``raw`` must already be stripped and not blank. Raises ``OSError`` when a relative path needs a working directory that no longer exists; that is not remembered, so a later call tries again. An absolute path never consults the working directory. """ with _RESOLVED_ALLOWLIST_PATHS_LOCK: resolved = _RESOLVED_ALLOWLIST_PATHS.get(raw) if resolved is None: - resolved = os.path.join(os.getcwd(), os.path.expanduser(raw)) + expanded = os.path.expanduser(raw) + resolved = expanded if os.path.isabs(expanded) else os.path.join(os.getcwd(), expanded) _RESOLVED_ALLOWLIST_PATHS[raw] = resolved return resolved @@ -766,8 +771,19 @@ def allowlisted_destinations( raw_path = (os.environ.get(file_env) or "").strip() if not raw_path: return listed - path = resolve_allowlist_file_path(raw_path) - from_file = _ALLOWLIST_FILES.load(path, file_env, env) or _NO_DESTINATIONS + try: + path = resolve_allowlist_file_path(raw_path) + except OSError as exc: + # A relative path with no working directory to resolve it against: refused for this check, reported once, and tried again on the next. + _ALLOWLIST_FILES.report_unresolvable( + raw_path, + f"cannot resolve the relative path against the working directory: {_os_error_reason(exc)}", + file_env, + env, + ) + from_file = _NO_DESTINATIONS + else: + from_file = _ALLOWLIST_FILES.load(path, file_env, env) or _NO_DESTINATIONS if listed is None: return from_file return listed[0] | from_file[0], listed[1] | from_file[1] diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 8db84c0f00..90116b00db 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -1305,6 +1305,39 @@ def _ancestors_of(directory: str) -> list[str]: directory = parent +@_POSIX_USER +def test_an_absolute_path_loads_with_the_working_directory_gone( + allow_file: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + _replace(allow_file, "http://127.0.0.1:4100") + gone = tmp_path / "gone" + gone.mkdir() + monkeypatch.chdir(gone) + gone.rmdir() + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + + +@_POSIX_USER +def test_a_relative_path_with_the_working_directory_gone_is_refused_not_raised( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + gone = tmp_path / "gone" + gone.mkdir() + monkeypatch.chdir(gone) + gone.rmdir() + monkeypatch.setenv(FILE_ENV, "allowed") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert "cannot resolve the relative path against the working directory" in warning + assert "allowed" not in upstream_guard._RESOLVED_ALLOWLIST_PATHS + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: @@ -1714,6 +1747,25 @@ def test_a_home_relative_path_is_expanded(self, tmp_path: Path) -> None: assert result.exit_code == 0, result.output assert seen == str(tmp_path / "allowed") + @_POSIX_USER + def test_a_relative_option_with_the_working_directory_gone_is_a_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + gone = tmp_path / "gone" + gone.mkdir() + monkeypatch.chdir(gone) + gone.rmdir() + + result, seen = self._invoke(["--allowed-base-urls-file", "allowed"], {FILE_ENV: None}) + + assert result.exit_code == 1, result.output + assert ( + "cannot resolve --allowed-base-urls-file 'allowed' against the working directory" + in result.output + ) + assert seen is None + def test_the_option_is_in_the_help(self) -> None: result = CliRunner().invoke(main, ["proxy", "--help"]) From 5c03bd1e47fd88b7ea8079c002b0a9c333109003 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 03:07:04 +0100 Subject: [PATCH 46/49] fix(proxy): suggest only safe remedies for an allowlist path refusal The refusal advised "Run chmod go-w on '/private/tmp'" for a sticky shared directory, chown for another user's home, and ended on a truncated clause. A directory refusal now leads with moving the allowlist file under a directory only the user or root can write, and adds "or run chmod go-w on " only when the directory is the proxy user's own and not sticky. A refusal of the file itself suggests chmod go-w when the user owns it, or replacing it with a copy they or root own, since moving it would not help. --- headroom/proxy/upstream_guard.py | 36 +++++++++---- tests/test_upstream_guard_allowlist_file.py | 60 ++++++++++++++++----- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index ff29f587af..136c1005ae 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -360,14 +360,32 @@ def from_os_error(cls, exc: OSError, identity: _FileIdentity) -> _UnusableAllowl return cls(_os_error_reason(exc), identity, lasting=exc.errno in _FILE_ERRNOS) -def _remedy(problem: ForeignWriteAccess, subject: str) -> str: - """What the operator can do about ``problem`` with ``subject``, for the refusal's log line.""" +_MOVE_THE_FILE = "Move the allowlist file under a directory only you or root can write" + + +def _file_remedy(problem: ForeignWriteAccess, st: os.stat_result) -> str: + """What to do about the file itself being changeable by another user, for the refusal's log line. + + Moving it does not help, since the file travels with its owner and mode. + """ if problem.other_owner: - return ( - f"Make {subject} owned by uid {os.geteuid()} or root (chown), or keep the allowlist " - "file where every directory on its path is" - ) - return f"Run chmod go-w on {subject}" + return f"Replace it with a copy owned by uid {os.geteuid()} or root that only its owner can write" + if st.st_uid == os.geteuid(): + return "Run chmod go-w on the file" + return "Have root run chmod go-w on it, or replace it with a copy only you can write" + + +def _directory_remedy(problem: ForeignWriteAccess, info: os.stat_result, directory: str) -> str: + """What to do about a directory on the path, for the refusal's log line. + + Moving the file always works. Tightening the directory is offered only when it is the proxy user's own and not a sticky shared one: another user's directory (a home, a mount) is not the proxy's to change, root's needs root, and a sticky directory such as ``/tmp`` is shared on purpose, so making it private would break everything else that uses it. + """ + own_private_candidate = ( + not problem.other_owner and info.st_uid == os.geteuid() and not info.st_mode & stat.S_ISVTX + ) + if own_private_candidate: + return f"{_MOVE_THE_FILE}, or run chmod go-w on {_logged_path(directory)}" + return _MOVE_THE_FILE def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: @@ -380,7 +398,7 @@ def _require_private_file(st: os.stat_result, identity: _FileIdentity) -> None: problem = foreign_write_access(st, root_may_own=True) if problem is not None: raise _UnusableAllowlistFile( - f"the file is {problem.description}. {_remedy(problem, 'the file')}", identity + f"the file is {problem.description}. {_file_remedy(problem, st)}", identity ) @@ -402,7 +420,7 @@ def _directory_problem(directories: list[str]) -> str | None: if problem is not None: return ( f"the directory {_logged_path(directory)} on its path is {problem.description}." - f" {_remedy(problem, _logged_path(directory))}" + f" {_directory_remedy(problem, info, directory)}" ) return None diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 90116b00db..a9ed075ae2 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -653,8 +653,9 @@ def test_a_file_in_a_group_writable_directory_is_refused_until_it_is_fixed( assert len(warnings) == 1 assert _on_its_path(shared, "writable by group or others (mode 0770)") in warnings[0] assert ( - f"Run chmod go-w on {upstream_guard._logged_path(os.path.realpath(shared))}" in warnings[0] - ) + "Move the allowlist file under a directory only you or root can write, " + f"or run chmod go-w on {upstream_guard._logged_path(os.path.realpath(shared))}" + ) in warnings[0] @_POSIX_USER @@ -1105,7 +1106,7 @@ def _fabricated(uid: int, mode: int) -> os.stat_result: @pytest.mark.parametrize( - ("tree", "refused_at"), + ("tree", "refused_at", "remedy"), [ ( { @@ -1115,18 +1116,24 @@ def _fabricated(uid: int, mode: int) -> os.stat_result: "/home/me/cfg": (_ME, 0o700), }, None, + None, ), ( {"/": (_ROOT, 0o755), "/etc": (_ROOT, 0o755), "/etc/headroom": (_ROOT, 0o755)}, None, + None, ), ( {"/": (_ROOT, 0o755), "/tmp": (_ROOT, 0o1777), "/tmp/mine": (_ME, 0o700)}, ("/tmp", "writable by group or others (mode 1777)"), + # Never "chmod go-w /tmp": it is shared on purpose. + "Move the allowlist file under a directory only you or root can write", ), ( {"/": (_ROOT, 0o755), "/srv": (_ROOT, 0o775), "/srv/headroom": (_ME, 0o700)}, ("/srv", "writable by group or others (mode 0775)"), + # Root's directory: tightening it needs root, so only moving is suggested. + "Move the allowlist file under a directory only you or root can write", ), ( { @@ -1139,17 +1146,33 @@ def _fabricated(uid: int, mode: int) -> os.stat_result: "/home/them", "owned by uid 2000, not by the user running the proxy (uid 1000) or root", ), + # Another user's home is not the proxy's to chown. + "Move the allowlist file under a directory only you or root can write", ), + ( + {"/": (_ROOT, 0o755), "/home": (_ROOT, 0o755), "/home/me": (_ME, 0o770)}, + ("/home/me", "writable by group or others (mode 0770)"), + "Move the allowlist file under a directory only you or root can write" + + ", or run chmod go-w on '/home/me'", + ), + ], + ids=[ + "home", + "root-controlled", + "under-tmp", + "group-writable-ancestor", + "other-user-ancestor", + "own-group-writable", ], - ids=["home", "root-controlled", "under-tmp", "group-writable-ancestor", "other-user-ancestor"], ) def test_every_directory_on_the_path_must_be_trusted( real_ancestors: None, monkeypatch: pytest.MonkeyPatch, tree: dict[str, tuple[int, int]], refused_at: tuple[str, str] | None, + remedy: str | None, ) -> None: - """The ancestor rule on fabricated ``lstat`` results, so it does not depend on this machine's /tmp.""" + """The ancestor rule, and the remedy it suggests, on fabricated ``lstat`` results, so it does not depend on this machine's /tmp.""" monkeypatch.setattr(os, "geteuid", lambda: _ME) monkeypatch.setattr(os, "lstat", lambda path: _fabricated(*tree[os.fspath(path)])) @@ -1160,13 +1183,6 @@ def test_every_directory_on_the_path_must_be_trusted( else: directory, description = refused_at logged = upstream_guard._logged_path(directory) - if description.startswith("owned by"): - remedy = ( - f"Make {logged} owned by uid {_ME} or root (chown), or keep the allowlist file " - "where every directory on its path is" - ) - else: - remedy = f"Run chmod go-w on {logged}" assert problem == f"the directory {logged} on its path is {description}. {remedy}" @@ -1338,6 +1354,26 @@ def test_a_relative_path_with_the_working_directory_gone_is_refused_not_raised( assert "allowed" not in upstream_guard._RESOLVED_ALLOWLIST_PATHS +@_POSIX_USER +def test_a_sticky_directory_on_the_path_is_never_advised_chmod( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + shared = tmp_path / "shared" + shared.mkdir() + (shared / "mine").mkdir(mode=0o700) + _replace(shared / "mine" / "allow", "http://127.0.0.1:4100") + monkeypatch.setenv(FILE_ENV, str(shared / "mine" / "allow")) + shared.chmod(0o1777) + try: + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + finally: + shared.chmod(0o700) + (warning,) = [r.getMessage() for r in caplog.records] + assert "Move the allowlist file under a directory only you or root can write)" in warning + assert "chmod" not in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 87f4dee22d97d1fbbdabbd68d2c04f5d9c740e6e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 03:08:57 +0100 Subject: [PATCH 47/49] fix(proxy): read only the allowlist file the walk agreed on The open re-resolves the path by name, and its descriptor was checked only for being a private regular file, so a file swapped in after the walk agreed could be read in place of the one inspected. The descriptor's device and inode must now match the stat the walk agreed on; a mismatch is retried like a disagreement within the same _RESOLVE_ATTEMPTS, then refused with the same reason. The pre-open checks use that agreed stat rather than the first stat taken by load. --- headroom/proxy/upstream_guard.py | 87 ++++++++++++--------- tests/test_upstream_guard_allowlist_file.py | 62 ++++++++++++++- 2 files changed, 111 insertions(+), 38 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 136c1005ae..4c23e044fd 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -476,32 +476,34 @@ def _path_directories(path: str) -> tuple[list[str], str | None]: return directories, final -# How many times the walk is repeated when its final entry and the kernel's file differ. A replace landing between the two back-to-back lookups makes them differ once; a link the kernel follows by something other than its text (a /proc//root magic link) makes them differ every time. Five consecutive races within a pair of adjacent syscalls is far past any real writer. +# How many times the walk and read are repeated when they reach different files. A replace landing between two back-to-back lookups (the walk's final entry and the stat of the path, or that stat and the open) makes them differ once; a link the kernel follows by something other than its text (a /proc//root magic link) makes them differ every time. Five consecutive races within a pair of adjacent syscalls is far past any real writer. _RESOLVE_ATTEMPTS = 5 -def _walk_agreeing_with_the_kernel(path: str) -> list[str]: - """The directories on ``path`` (:func:`_path_directories`), once the walk's final entry is the file the kernel resolves the path to. +class _ResolvedElsewhere(Exception): + """Two lookups of the path reached different files: the walk and the kernel, or the stat and the open.""" - Compares the device and inode of an ``lstat`` of the walk's final entry with a ``stat`` of ``path`` taken straight after it, repeating the walk up to ``_RESOLVE_ATTEMPTS`` times so that a replacement racing the two lookups is not mistaken for a disagreement. Raises :class:`_UnwalkableAllowlistPath` when they never agree, and ``OSError`` from either lookup. + +_RESOLVED_ELSEWHERE = "the path does not resolve to the file the permission check inspected" + + +def _walk_once(path: str) -> tuple[list[str], os.stat_result]: + """The directories on ``path`` (:func:`_path_directories`) and the ``stat`` of the file the walk agrees the path reaches. + + Compares the device and inode of an ``lstat`` of the walk's final entry with a ``stat`` of ``path`` taken straight after it. Raises :class:`_ResolvedElsewhere` when they differ, :class:`_UnwalkableAllowlistPath` from the walk and ``OSError`` from either lookup. The caller repeats it up to ``_RESOLVE_ATTEMPTS`` times. """ - for _ in range(_RESOLVE_ATTEMPTS): - directories, final = _path_directories(path) - if final is None: - return directories - walked = os.lstat(final) - opened = os.stat(path) - if (walked.st_dev, walked.st_ino) == (opened.st_dev, opened.st_ino): - return directories - raise _UnwalkableAllowlistPath( - "the path does not resolve to the file the permission check inspected" - ) + directories, final = _path_directories(path) + walked = None if final is None else os.lstat(final) + opened = os.stat(path) + if walked is not None and (walked.st_dev, walked.st_ino) != (opened.st_dev, opened.st_ino): + raise _ResolvedElsewhere(_RESOLVED_ELSEWHERE) + return directories, opened def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, str]: - """Return the identity of the file read and its text; ``st`` is the caller's ``stat`` of it. + """Return the identity of the file read and its text; ``st`` is the ``stat`` the walk agreed on. - Raises :class:`_UnusableAllowlistFile` when it is not a regular file, cannot be opened or read, is larger than ``_MAX_ALLOWLIST_FILE_BYTES`` or is not UTF-8. The type is checked on ``st`` before anything is opened, so a device or terminal named by mistake is never opened, and again on the descriptor, for one swapped in after the ``stat``. The size is checked on the descriptor before anything is read. + Raises :class:`_UnusableAllowlistFile` when it is not a regular file, cannot be opened or read, is larger than ``_MAX_ALLOWLIST_FILE_BYTES`` or is not UTF-8. The type is checked on ``st`` before anything is opened, so a device or terminal named by mistake is never opened, and again on the descriptor, for one swapped in after the ``stat``. The size is checked on the descriptor before anything is read. The descriptor must be the file ``st`` describes (same device and inode); otherwise the open resolved the path to a different file from the one the walk inspected, and :class:`_ResolvedElsewhere` is raised for the caller to retry. """ identity = _FileIdentity.of(st) if not stat.S_ISREG(st.st_mode): @@ -512,7 +514,10 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, except OSError as exc: raise _UnusableAllowlistFile.from_os_error(exc, identity) from exc with os.fdopen(fd, "rb") as handle: + inspected = st st = os.fstat(handle.fileno()) + if (st.st_dev, st.st_ino) != (inspected.st_dev, inspected.st_ino): + raise _ResolvedElsewhere(_RESOLVED_ELSEWHERE) # The identity recorded is the descriptor's, not the caller's `stat`'s. A rename between the two means the bytes read are the newer file's; filed under the older identity, the next check would see a mismatch and read the file again for nothing. identity = _FileIdentity.of(st) if not stat.S_ISREG(st.st_mode): @@ -603,26 +608,36 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: cached = self._cached(path, identity) if cached is not None: return self._serve(path, cached, file_env, env) - try: - directories = _walk_agreeing_with_the_kernel(path) - except OSError as exc: - self._report(path, _os_error_reason(exc), file_env, env) - return None - except _UnwalkableAllowlistPath as exc: - self._report(path, str(exc), file_env, env) - return None - problem = _directory_problem(directories) - if problem is not None: - self._report(path, problem, file_env, env) - return None - try: - identity, raw = _read_allowlist_file(path, st) - except _UnusableAllowlistFile as exc: - if exc.lasting: - with self._lock: - self._loaded[path] = (exc.identity, None, directories) - self._report(path, str(exc), file_env, env) + for _ in range(_RESOLVE_ATTEMPTS): + try: + directories, agreed = _walk_once(path) + except _ResolvedElsewhere: + continue + except OSError as exc: + self._report(path, _os_error_reason(exc), file_env, env) + return None + except _UnwalkableAllowlistPath as exc: + self._report(path, str(exc), file_env, env) + return None + problem = _directory_problem(directories) + if problem is not None: + self._report(path, problem, file_env, env) + return None + try: + read_identity, raw = _read_allowlist_file(path, agreed) + except _ResolvedElsewhere: + continue + except _UnusableAllowlistFile as exc: + if exc.lasting: + with self._lock: + self._loaded[path] = (exc.identity, None, directories) + self._report(path, str(exc), file_env, env) + return None + break + else: + self._report(path, _RESOLVED_ELSEWHERE, file_env, env) return None + identity = read_identity entries, skipped = _parse_destinations(raw, comments=True) with self._lock: self._loaded[path] = (identity, entries, directories) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index a9ed075ae2..1353834e7d 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -396,12 +396,18 @@ def test_a_hash_entry_in_the_variable_is_not_a_comment(monkeypatch: pytest.Monke def test_the_identity_cached_is_the_one_read_not_the_one_first_stat_ed( allow_file: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A rename between the guard's ``stat`` and its ``open`` must not cost a second read.""" + """A rename between the guard's ``stat`` and its ``open`` is retried and then served from the cache. + + The open then reaches a file other than the one the walk agreed on, so that attempt is retried; the version the retry reads is the one cached, so the next check is a cache hit with no read. + """ _replace(allow_file, "http://127.0.0.1:4100") real_read = upstream_guard._read_allowlist_file + replaced = [False] def replaced_after_stat(path: str, st: os.stat_result): # noqa: ANN202 - _replace(allow_file, "http://127.0.0.1:4200") + if not replaced[0]: + replaced[0] = True + _replace(allow_file, "http://127.0.0.1:4200") return real_read(path, st) monkeypatch.setattr(upstream_guard, "_read_allowlist_file", replaced_after_stat) @@ -1374,6 +1380,58 @@ def test_a_sticky_directory_on_the_path_is_never_advised_chmod( assert "chmod" not in warning +def _with_other_inode(real: os.stat_result) -> os.stat_result: + """``real`` as the stat of a different file: same everything but the inode.""" + fields = list(real[:10]) + fields[1] = real.st_ino + 1 + extras = { + name: getattr(real, name) + for name in ( + "st_atime_ns", + "st_mtime_ns", + "st_ctime_ns", + "st_blksize", + "st_blocks", + "st_rdev", + ) + if hasattr(real, name) + } + return os.stat_result(fields, extras) + + +@pytest.mark.parametrize(("mismatches", "admitted"), [(1, True), (99, False)]) +def test_the_file_opened_must_be_the_file_the_walk_agreed_on( + allow_file: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + mismatches: int, + admitted: bool, +) -> None: + """The open resolves the path again by name; a descriptor for another file is a race once and a refusal if it persists.""" + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + _replace(allow_file, "http://127.0.0.1:4100") + inode = allow_file.stat().st_ino + real_fstat = os.fstat + remaining = [mismatches] + + def opened_something_else(fd: int) -> os.stat_result: + real = real_fstat(fd) + if real.st_ino == inode and remaining[0] > 0: + remaining[0] -= 1 + return _with_other_inode(real) + return real + + monkeypatch.setattr(os, "fstat", opened_something_else) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is admitted + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + if admitted: + assert warnings == [] + else: + (warning,) = warnings + assert "the path does not resolve to the file the permission check inspected" in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From f2beba4b8ed16319239de258455b3bc573f96e91 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 03:09:20 +0100 Subject: [PATCH 48/49] fix(proxy): report a path ending in .. as not a regular file A path ending in .. (or .) names a directory, but the walk's final entry stayed the last name it looked up, so the reason given was that the path did not resolve to the file the check inspected. The walk now ends at the directory such a path names, which agrees with the kernel, and the refusal says the file is not a regular file. --- headroom/proxy/upstream_guard.py | 10 ++++++---- tests/test_upstream_guard_allowlist_file.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 4c23e044fd..15296a23bd 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -449,11 +449,13 @@ def _path_directories(path: str) -> tuple[list[str], str | None]: hops = 0 while pending: name = pending.pop() - if name in ("", "."): + if name == "": continue - if name == "..": - # `current` is symlink-free, so its lexical parent is its real one. - current = os.path.dirname(current) + if name in (".", ".."): + # `current` is symlink-free, so its lexical parent is its real one. A path ending here names that directory, so it is the entry the walk ends at. + if name == "..": + current = os.path.dirname(current) + final = current continue if current not in seen: seen.add(current) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 1353834e7d..3ba1a46e58 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -1432,6 +1432,20 @@ def opened_something_else(fd: int) -> os.stat_result: assert "the path does not resolve to the file the permission check inspected" in warning +@pytest.mark.parametrize("suffix", ["sub/..", "sub/.", "."]) +def test_a_path_ending_in_a_directory_is_refused_as_not_a_regular_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, suffix: str +) -> None: + caplog.set_level(logging.WARNING, logger="headroom.proxy.upstream_guard") + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + (tmp_path / "sub").mkdir(mode=0o700) + monkeypatch.setenv(FILE_ENV, f"{tmp_path}/{suffix}") + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + (warning,) = [r.getMessage() for r in caplog.records] + assert "(not a regular file)" in warning + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: From 4f489e9964e74c1388e9518ffb4417ad024f1ee4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 12 Oct 2026 03:10:06 +0100 Subject: [PATCH 49/49] fix(proxy): cache an allowlist path that keeps resolving elsewhere A lasting disagreement between the walk and the kernel, such as a magic link, repeated every walk on every request: five walks and their lstats under the reload lock, and on the event loop for a synchronous caller. It is now cached against the identity of the path's stat, like any other unusable version, so it costs one stat per request until the file changes. The docs say which refusals are cached and which are retried. --- docs/content/docs/configuration.mdx | 2 +- headroom/proxy/upstream_guard.py | 5 +++- tests/test_upstream_guard_allowlist_file.py | 30 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index f6ecb5db6f..ab81033631 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -201,7 +201,7 @@ Entries are bare hosts (every port on that host) or URLs (that exact origin), se The variable is fixed for the life of the proxy process. When an allowed endpoint can move (a supervisor that lists its own loopback port, for example), put the entries in a file and pass it as `headroom proxy --allowed-base-urls-file PATH`, name it in `HEADROOM_ALLOWED_BASE_URLS_FILE`, or set the Upstream allowlist file in the dashboard's settings, instead of the variable or as well. The path is read at startup and a blank value means no file; a leading `~` is expanded (a `~name` for a user that does not exist is left as written, and so is resolved as a relative path), and a relative path resolves against the proxy's working directory when it is first used and keeps that meaning for the life of the process. Changing the path needs a restart, but the file's contents do not. Its entries are merged with the variable's, and the proxy re-reads the file on the next request after it changes, with no restart; a change is detected by the file's device, inode, size, modification time and status-change time, so an unchanged file is never read again: each request costs a `stat` of the file plus an `lstat` of every directory from `/` down that resolving its path passes through, and those directories are re-checked on every request, so one that becomes writable by others stops a cached version being used straight away, without waiting for the file to change. Which directories are checked is worked out when the file is read: repointing a symlink in the chain at a different file is picked up on the next request, but repointing it so that it still reaches the very same file is only re-evaluated once that file changes. Replace it atomically (write a sibling file, then rename it over the original). The file takes the variable's format, plus comments and an optional UTF-8 byte-order mark. A comment starts at a `#` at the beginning of the file or after any whitespace character and runs to the next line break, so `http://127.0.0.1:4100 # front door` lists that origin. A `#` straight after an origin in a URL (`http://127.0.0.1:4100#x`) begins the URL's fragment, which an origin ignores, so that entry is accepted; a `#` anywhere else makes its entry invalid. In `HEADROOM_ALLOWED_BASE_URLS` a `#` has no comment meaning at all: it is part of whichever entry holds it, and it does not hide anything after the next comma. Each load logs how many hosts and origins it found and how many invalid entries it skipped. -Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. So must every directory that resolving the path passes through: every directory from `/` down to the one holding the file and, when the path meets a symlink anywhere along it (at its end or in the middle), every directory down to the one holding that link and on down its target, since whoever can write any of them could swap an entry and choose the file. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. Anything under a shared directory such as `/tmp` is therefore refused, a private subdirectory made there with `mkdir -m 700` included: whoever can write the shared directory can rename that subdirectory away and put a symlink to a file of their choosing (or, where allowed, a hard link to a root-owned one) in its place, and the file it leads to cannot show who did it. This is stricter than OpenSSH's StrictModes (there is no exception for sticky directories, and every directory a symlink passes through counts), but like it, it judges owners and mode bits only: access control lists are not read, so a macOS ACL such as `everyone allow add_file,delete_child` on a directory or `allow write,append` on the file, or an NFSv4 ACL, can grant write access the check cannot see (a POSIX ACL is covered, because its mask shows as the group bits), and ownership is whatever the filesystem reports, which a FUSE mount with `allow_other` or NFS without `root_squash` may not make trustworthy. Root is accepted because it can change anything anyway; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Supported places for the file are therefore a directory under the home directory of the user running the proxy (where every directory from `/` down is owned by that user or root and none is writable by group or others), or a root-controlled directory such as one under `/etc`, including a bind mount of such a directory into a container whose every directory above it meets the same rule. A Kubernetes ConfigMap volume does not qualify: its directory is writable by group or others, so it is refused. Anything under `/tmp`, `/var/tmp` or another sticky shared directory is refused, including a private subdirectory made there with `mkdir -m 700`, because the directory above it is writable by others. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used is not tried again until it changes. +Whoever can change the file, or the directory that holds it, could list any upstream they like, so both are checked. The file must be owned by the user running the proxy or by root and must not be writable by group or others. So must every directory that resolving the path passes through: every directory from `/` down to the one holding the file and, when the path meets a symlink anywhere along it (at its end or in the middle), every directory down to the one holding that link and on down its target, since whoever can write any of them could swap an entry and choose the file. Each must be owned by the user running the proxy or by root and must not be writable by group or others, whether or not the sticky bit is set. Anything under a shared directory such as `/tmp` is therefore refused, a private subdirectory made there with `mkdir -m 700` included: whoever can write the shared directory can rename that subdirectory away and put a symlink to a file of their choosing (or, where allowed, a hard link to a root-owned one) in its place, and the file it leads to cannot show who did it. This is stricter than OpenSSH's StrictModes (there is no exception for sticky directories, and every directory a symlink passes through counts), but like it, it judges owners and mode bits only: access control lists are not read, so a macOS ACL such as `everyone allow add_file,delete_child` on a directory or `allow write,append` on the file, or an NFSv4 ACL, can grant write access the check cannot see (a POSIX ACL is covered, because its mask shows as the group bits), and ownership is whatever the filesystem reports, which a FUSE mount with `allow_other` or NFS without `root_squash` may not make trustworthy. Root is accepted because it can change anything anyway; that is the one way this rule is looser than the one for the `--uds` socket's directory, which must belong to the user running the proxy, because the proxy only reads the allowlist. Supported places for the file are therefore a directory under the home directory of the user running the proxy (where every directory from `/` down is owned by that user or root and none is writable by group or others), or a root-controlled directory such as one under `/etc`, including a bind mount of such a directory into a container whose every directory above it meets the same rule. A Kubernetes ConfigMap volume does not qualify: its directory is writable by group or others, so it is refused. Anything under `/tmp`, `/var/tmp` or another sticky shared directory is refused, including a private subdirectory made there with `mkdir -m 700`, because the directory above it is writable by others. Windows, which has no POSIX ownership or modes, skips this check. Naming a file always means allowlist mode. If it is empty, missing, unreadable, not UTF-8, not a regular file (a FIFO or a device), larger than 1 MiB, or changeable by another user, only the variable's entries are admitted (none when the variable is unset), any other `x-headroom-base-url` is not used (`/v1/messages`, `/v1/alpha/search` and the catch-all passthrough answer 400; the OpenAI chat-completions and responses handlers log it and send the request to the configured upstream instead), and the proxy logs a warning naming the file and the reason once per distinct failure. An oversized file is refused whole, never read in part, and a file that cannot be used (including one whose path keeps resolving to a different file than the one the check inspected) is not tried again until it changes. A refusal because of a directory on the path, or a transient error such as running out of file descriptors, is retried on the next request instead. ## SmartCrusher Configuration diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 15296a23bd..46eb724b6a 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -547,7 +547,7 @@ def _read_allowlist_file(path: str, st: os.stat_result) -> tuple[_FileIdentity, class _AllowlistFiles: """What each allowlist file held, re-read only when the file's identity changes. - A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs the same ``stat`` calls per check as a good one, not an open and a read on every request. A good version is served only while its directories still pass :func:`_directory_problem`, checked on every request; failing that it is withheld, not dropped, and served again once they pass. Which directories those are is worked out when the version is read and kept with it, since resolving the path again would cost a ``readlink`` per link and a lookup per entry on every request. Only the user or root can repoint a link on the path (every directory holding one is checked), and repointing it at a different file changes the identity, which re-reads and re-resolves; one repointed to reach the same file version keeps the old list until the file changes. A failure that is not the file's own (see ``_FILE_ERRNOS``) is not cached: that check fails closed and the next one tries again. + A version that could not be used is cached too, as ``None``, so a file that is oversized, not UTF-8 or unreadable costs the same ``stat`` calls per check as a good one, not an open and a read on every request. A good version is served only while its directories still pass :func:`_directory_problem`, checked on every request; failing that it is withheld, not dropped, and served again once they pass. Which directories those are is worked out when the version is read and kept with it, since resolving the path again would cost a ``readlink`` per link and a lookup per entry on every request. Only the user or root can repoint a link on the path (every directory holding one is checked), and repointing it at a different file changes the identity, which re-reads and re-resolves; one repointed to reach the same file version keeps the old list until the file changes. A path that never resolves to the file the walk inspected within ``_RESOLVE_ATTEMPTS`` is cached the same way, against the identity of the path's ``stat``, so a magic link costs one ``stat`` per request rather than every walk. A failure that is not the file's own (see ``_FILE_ERRNOS``), and a directory failure, are not cached: that check fails closed and the next one tries again. Thread-safe: the event loop and the resolver pool both reach the guard. Checks against an unchanged file take only the bookkeeping lock. Checks that see a change queue on one reload lock and re-check the cache once they hold it, so a change is read and logged once however many requests arrive together. The wait is bounded in bytes (one read of at most ``_MAX_ALLOWLIST_FILE_BYTES``), not in time: a file on a stalled mount holds the reload, and every check that saw the change, for as long as the filesystem does, and a synchronous caller of :func:`is_safe_upstream_url` on the event loop blocks the loop meanwhile, as it already would on the ``stat``. Event-loop callers should use :func:`is_safe_upstream_url_async`, which runs the check on a worker thread. """ @@ -637,6 +637,9 @@ def load(self, path: str, file_env: str, env: str) -> Destinations | None: return None break else: + # Remembered against the identity load() stat-ed: a lasting disagreement (a magic link) would otherwise repeat every walk on every request, under the reload lock. A replacement racing every attempt changes that identity, so the next check tries again. + with self._lock: + self._loaded[path] = (identity, None, []) self._report(path, _RESOLVED_ELSEWHERE, file_env, env) return None identity = read_identity diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py index 3ba1a46e58..484ae8dcf3 100644 --- a/tests/test_upstream_guard_allowlist_file.py +++ b/tests/test_upstream_guard_allowlist_file.py @@ -1446,6 +1446,36 @@ def test_a_path_ending_in_a_directory_is_refused_as_not_a_regular_file( assert "(not a regular file)" in warning +def test_a_lasting_disagreement_is_not_walked_again_until_the_file_changes( + allow_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _replace(allow_file, "http://127.0.0.1:4100") + real_fstat = os.fstat + real_walk = upstream_guard._path_directories + walks: list[str] = [] + + def always_something_else(fd: int) -> os.stat_result: + return _with_other_inode(real_fstat(fd)) + + def counting_walk(path: str): # noqa: ANN202 + walks.append(path) + return real_walk(path) + + monkeypatch.setattr(os, "fstat", always_something_else) + monkeypatch.setattr(upstream_guard, "_path_directories", counting_walk) + + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert len(walks) == upstream_guard._RESOLVE_ATTEMPTS + for _ in range(3): + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is False + assert len(walks) == upstream_guard._RESOLVE_ATTEMPTS + + monkeypatch.setattr(os, "fstat", real_fstat) + _replace(allow_file, "http://127.0.0.1:4100") + assert is_safe_upstream_url("http://127.0.0.1:4100/v1") is True + assert len(walks) == upstream_guard._RESOLVE_ATTEMPTS + 1 + + def test_concurrent_checks_after_a_change_read_and_log_it_once( allow_file: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: