diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 93e49cae1f..ab81033631 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -197,6 +197,12 @@ 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, 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 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 (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 Fine-tune JSON compression behavior: diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index eff91b5b87..d9b5bfb1fb 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, 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. " + "A blank value means no file (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,28 @@ 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, + 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, resolved the way the guard resolves it so a worker started in another directory still names the same 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] = "" + config = ProxyConfig( host=host, port=port, diff --git a/headroom/proxy/private_paths.py b/headroom/proxy/private_paths.py new file mode 100644 index 0000000000..01dbe4a8ab --- /dev/null +++ b/headroom/proxy/private_paths.py @@ -0,0 +1,44 @@ +"""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, *, 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 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, 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): + 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): + 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: diff --git a/headroom/proxy/upstream_guard.py b/headroom/proxy/upstream_guard.py index 1872895e8d..46eb724b6a 100644 --- a/headroom/proxy/upstream_guard.py +++ b/headroom/proxy/upstream_guard.py @@ -9,10 +9,72 @@ * 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, tabs or line breaks), bare hosts permit every safe scheme/port for that + 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. + 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 ``#`` 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 + 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 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 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 +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. +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: + + * 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 unusable (permissions, not UTF-8, not a regular file, larger + 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. 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 @@ -30,16 +92,22 @@ 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. """ from __future__ import annotations import asyncio +import errno import ipaddress +import logging import os +import re import socket +import stat +import threading import time from concurrent.futures import ThreadPoolExecutor from concurrent.futures import TimeoutError as _FutureTimeout @@ -47,7 +115,12 @@ from dataclasses import dataclass from urllib.parse import urlparse +from headroom.proxy.private_paths import ForeignWriteAccess, foreign_write_access + +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,39 +278,557 @@ 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(): +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 (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 + size: int + mtime_ns: int + ctime_ns: int + + @classmethod + 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` 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. +_FILE_ERRNOS = frozenset( + { + errno.EACCES, + errno.EPERM, + errno.ELOOP, + errno.ENOENT, + errno.ENOTDIR, + errno.EISDIR, + errno.ENAMETOOLONG, + } +) + + +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. + + ``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(_os_error_reason(exc), identity, lasting=exc.errno in _FILE_ERRNOS) + + +_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"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: + """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 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 + problem = foreign_write_access(st, root_may_own=True) + if problem is not None: + raise _UnusableAllowlistFile( + f"the file is {problem.description}. {_file_remedy(problem, st)}", identity + ) + + +def _directory_problem(directories: list[str]) -> str | None: + """Why someone other than this user or root could change one of ``directories``, else ``None``. + + ``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 - hosts: set[str] = set() - origins: set[tuple[str, str, int]] = set() - for item in raw.split(","): - item = item.strip() - if not item: + for directory in directories: + try: + info = os.lstat(directory) + except OSError as 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"the directory {_logged_path(directory)} on its path is {problem.description}." + f" {_directory_remedy(problem, info, directory)}" + ) + return None + + +# 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 could not be resolved to the file one entry at a time.""" + + +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. 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 [], 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() + if name == "": continue - if "://" not in item: - parsed = urlparse(f"//{item}") - if parsed.hostname: - hosts.add(parsed.hostname.lower()) + 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 - parsed = urlparse(item) - if parsed.scheme.lower() not in _SAFE_SCHEMES or not parsed.hostname: + 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))) continue + final = entry + if pending: + current = entry + return directories, final + + +# 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 + + +class _ResolvedElsewhere(Exception): + """Two lookups of the path reached different files: the walk and the kernel, or the stat and the open.""" + + +_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. + """ + 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 ``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. 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): + raise _UnusableAllowlistFile("not a regular file", identity) + _require_private_file(st, identity) + try: + 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: + 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): + raise _UnusableAllowlistFile("not a regular file", identity) + _require_private_file(st, 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: - port = parsed.port - except ValueError: + # 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.from_os_error(exc, identity) from exc + if len(data) > _MAX_ALLOWLIST_FILE_BYTES: + 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: + """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 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. + """ + + 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[str]]] = {} + self._reported: dict[str, str] = {} + + def _cached( + self, path: str, identity: _FileIdentity + ) -> 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) + if cached is not None and cached[0] == identity: + return cached[1], cached[2] + return None + + def _serve( + self, + path: str, + cached: tuple[Destinations | None, list[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. + + ``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: + st = os.stat(path) + except OSError as exc: + self._report(path, _os_error_reason(exc), file_env, env) + return None + identity = _FileIdentity.of(st) + 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. + cached = self._cached(path, identity) + if cached is not None: + return self._serve(path, cached, 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: + # 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 + entries, skipped = _parse_destinations(raw, comments=True) + with self._lock: + self._loaded[path] = (identity, entries, directories) + 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", + file_env, + "read again after failing:" if recovered else "loaded", + _logged_path(path), + len(entries[0]), + len(entries[1]), + skipped, + "y" if skipped == 1 else "ies", + ) + 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: + if self._reported.get(path) == reason: + return + 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). 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, + _logged_path(path), + reason, + env, + ) + + +_ALLOWLIST_FILES = _AllowlistFiles() + +# 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 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: + """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): + # 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: + return _parse_bare_host(item) + 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, *, comments: bool = False) -> tuple[Destinations, int]: + """Parse hosts and URLs separated by commas, tabs or line breaks (``_LINE_BREAKS``). + + 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. + """ + hosts: set[str] = set() + origins: set[tuple[str, str, int]] = set() + skipped = 0 + if comments: + raw = _COMMENT.sub("", raw) + for item in _SEPARATORS.split(raw): + item = item.strip() + if not item: 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 hosts, 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 + + +# 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. ``~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: + 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 + + +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_variable(env, raw) if raw and raw.strip() else None + if file_env is None: + return listed + raw_path = (os.environ.get(file_env) or "").strip() + if not raw_path: + return listed + 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] 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/headroom/settings_store.py b/headroom/settings_store.py index 4e36fc73bb..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 @@ -725,6 +727,22 @@ 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", + blank_overrides=True, + ), ) _BY_KEY: dict[str, SettingField] = {f.key: f for f in SETTINGS} @@ -813,7 +831,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 @@ -829,9 +849,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"): @@ -971,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): @@ -980,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 @@ -1019,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_private_paths.py b/tests/test_private_paths.py new file mode 100644 index 0000000000..1183acde7b --- /dev/null +++ b/tests/test_private_paths.py @@ -0,0 +1,99 @@ +"""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}), + ], + ids=[ + "own-file", + "own-dir", + "root-file", + "root-dir-0755", + ], +) +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}, + "writable by group or others (mode 1666)", + ), + (_stat(ME, DIR | 0o1777), {}, "writable by group or others (mode 1777)"), + ( + _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=[ + "uds-root-dir", + "other-owner-file", + "group-writable-file", + "sticky-file-not-shared", + "uds-sticky-dir", + "root-sticky-dir", + "own-sticky-dir", + ], +) +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_proxy/test_settings_store.py b/tests/test_proxy/test_settings_store.py index 58f6bce660..c25ed255a5 100644 --- a/tests/test_proxy/test_settings_store.py +++ b/tests/test_proxy/test_settings_store.py @@ -163,6 +163,72 @@ 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 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) diff --git a/tests/test_upstream_guard_allowlist_file.py b/tests/test_upstream_guard_allowlist_file.py new file mode 100644 index 0000000000..484ae8dcf3 --- /dev/null +++ b/tests/test_upstream_guard_allowlist_file.py @@ -0,0 +1,1914 @@ +"""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 errno +import logging +import os +import socket +import stat +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 +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) + + +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, st: os.stat_result): # noqa: ANN202 + reads.append(path) + return real_read(path, st) + + monkeypatch.setattr(upstream_guard, "_read_allowlist_file", counting_read) + 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" + 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 = _count_reads(monkeypatch) + + 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 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") + 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_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() + + +_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"] + + +@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 + + +@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_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_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`` 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 + 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) + 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 _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 == [] + + +@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_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_the_guard_expands_a_home_relative_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + upstream_guard._RESOLVED_ALLOWLIST_PATHS.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._RESOLVED_ALLOWLIST_PATHS.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 + + +_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). 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 + + +@_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 _on_its_path(shared, "writable by group or others (mode 0770)") in warnings[0] + assert ( + "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 +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] + # 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( + 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 + + +@_POSIX_USER +def test_a_symlink_planted_in_a_sticky_shared_directory_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """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() + 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 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 _on_its_path(shared, "writable by group or others (mode 1777)") in warning + + +@_POSIX_USER +def test_an_own_file_in_a_sticky_shared_directory_is_refused( + 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 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 ( + _on_its_path(tmp_path, f"owned by uid {real_uid}, not by the user running the proxy") + 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 + + +@_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 _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 + + +@_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 _on_its_path(shared, "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 _on_its_path(shared, "writable by group or others") in m]) == 1 + ) + 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_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") + + +@_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 _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 + + +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, 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 + + +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 + + +@_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_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_lstat(path, *args, **kwargs) # type: ignore[arg-type] + + 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 ( + f"the directory {upstream_guard._logged_path(directory)} on its path: Input/output error (EIO)" + in warning + ) + + 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", "remedy"), + [ + ( + { + "/": (_ROOT, 0o755), + "/home": (_ROOT, 0o755), + "/home/me": (_ME, 0o755), + "/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", + ), + ( + { + "/": (_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", + ), + # 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", + ], +) +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, 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)])) + + problem = upstream_guard._directory_problem(list(tree)) + + if refused_at is None: + assert problem is None + else: + directory, description = refused_at + logged = upstream_guard._logged_path(directory) + assert problem == f"the directory {logged} on its path is {description}. {remedy}" + + +@_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 + + +@_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_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 + + +@_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 + + +@_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 + + +@_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 _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 + + +@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_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: + 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 + + +@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) + + +@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 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( + "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_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, " ") + + 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"] + + +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) + upstream_guard._RESOLVED_ALLOWLIST_PATHS.clear() + + 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 + + @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 == "" + + @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._RESOLVED_ALLOWLIST_PATHS.clear() + 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") + + @_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"]) + + 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() + )