From f5fbc65e1d919678d90b45ef008bc036ea92790f Mon Sep 17 00:00:00 2001 From: Minglong Pan <56749246+minglong51@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:11:12 -0700 Subject: [PATCH] fix(security): stop hardcoding a personal tailnet FQDN in the host allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _LOCAL_HOSTS carried "minglongs-mac-mini.tailab5be0.ts.net" as a literal, and it ships in the published 0.2.5 wheel on PyPI (verified by unzipping the artifact from the index). Two problems, and the second is the reason this is worth fixing before a security-framed launch: 1. It publishes the maintainer's machine name and tailnet ID to everyone who installs the package. 2. It puts a hardcoded exception inside the DNS-rebinding guard. The guard's whole claim is "requests whose Host is a non-local DNS name are refused" — and the shipped source shows one specific non-local DNS name that is not. Anyone reading app.py while evaluating the kill-switch story finds it. For every other user the entry was also dead weight: an allowlisted hostname they do not control and cannot use. Replaced with AUM_TRUSTED_HOSTS, a comma-separated opt-in read at import: _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"} | _configured_trusted_hosts() Empty by default, so out of the box only loopback names are accepted. This also turns a maintainer-specific hack into the general feature the README already described — anyone fronting the app with a reverse proxy or tailnet serve needs exactly this, and previously had no way to get it. Verified: - 74 tests pass - default: _LOCAL_HOSTS == {127.0.0.1, ::1, localhost}; the tailnet FQDN is refused; localhost still allowed - with AUM_TRUSTED_HOSTS set: that FQDN allowed, an unrelated host still refused - rebuilt wheel greps clean for tailab5be0 / minglongs / oclaw / founder-agent-os; the only remaining "ts.net" is the generic README placeholder Rides the unreleased 0.2.6, so one release fixes both this and the shipped config leak from #5. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015swHyENHGowhH1cwMXxiE2 --- README.md | 12 ++++++++++++ agent_usage_manager/app.py | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 65b070f..406629a 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,18 @@ This is the important part — a web page that can kill processes needs guardrai actions. That is deliberate (owner-only tailnet + token-gated actions), but do not read "loopback-only" as "not network-reachable" — a proxy in front is not a trust boundary. +- **Fronting it with a proxy needs `AUM_TRUSTED_HOSTS`.** The DNS-rebinding guard + refuses any request whose `Host` is a non-local DNS name — which is exactly what + a proxy forwards. Opt that one name in explicitly: + + ```bash + AUM_TRUSTED_HOSTS=box.your-tailnet.ts.net agent-usage-manager + ``` + + Comma-separated for several. Only add a name nobody else can mint a cert for on + this node; a name you don't control reopens the hole the guard exists to close. + Empty by default — out of the box only `localhost`, `127.0.0.1` and `::1` are + accepted. ## Limits & known issues diff --git a/agent_usage_manager/app.py b/agent_usage_manager/app.py index 8078812..261e56e 100644 --- a/agent_usage_manager/app.py +++ b/agent_usage_manager/app.py @@ -691,11 +691,18 @@ async def _lifespan(_: FastAPI): app = FastAPI(title="agent-usage-manager", lifespan=_lifespan) -# Loopback names are always local. The ts.net FQDN is the tailnet front door: -# `tailscale serve --https=8448` proxies to this app and preserves the Host -# header. Only tailscaled can mint that name/cert for this node, so allowing -# it does not reopen the DNS-rebinding hole _host_allowed exists to close. -_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "minglongs-mac-mini.tailab5be0.ts.net"} +# Loopback names are always local. Anything else must be opted in per install via +# AUM_TRUSTED_HOSTS (comma-separated hostnames) — typically the FQDN of a reverse +# proxy or tailnet front door that preserves the Host header, e.g. +# `tailscale serve --https=8448` fronting this app. Only add a name that nobody +# else can mint a cert for on this node; any other name reopens the DNS-rebinding +# hole _host_allowed exists to close. +def _configured_trusted_hosts() -> set[str]: + raw = os.environ.get("AUM_TRUSTED_HOSTS", "") + return {h.strip().lower() for h in raw.split(",") if h.strip()} + + +_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"} | _configured_trusted_hosts() def _hostname_of(hostport: str) -> str: