Repository navigation
fix(proxy): enforce HEADROOM_PROXY_TOKEN in the Rust front and listen on loopback by default - #4840
chopratejas wants to merge 4 commits into
Conversation
… on loopback by default The Rust front relays every request to the Python proxy from 127.0.0.1, where the Python proxy treats the caller as local: no token, and access to /admin, /debug and /settings. The front had no inbound check of its own and listened on 0.0.0.0:8787 by default. The front now applies the Python proxy's token rules itself: a non-loopback peer must present HEADROOM_PROXY_TOKEN as X-Headroom-Proxy-Token or Authorization: Bearer (constant-time compare) or gets 401 before anything is forwarded; the token is removed before forwarding; GET health probes stay open and /api/hello is answered locally. A non-loopback --listen with no token refuses to start unless HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1, and the default listen address is now 127.0.0.1:8787. Assay-Kind: bugfix Assay-Ask: Any peer that can reach the Rust front proxy is relayed to the Python proxy as a trusted loopback caller, because the front has no inbound authentication and listens on all interfaces by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR governanceThis PR does not yet satisfy the required template fields:
Please update the PR body, or move the PR back to draft while it is still in progress. |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
JerrettDavis
left a comment
There was a problem hiding this comment.
The unresolved security findings need resolution before approval. With HEADROOM_PROXY_TOKEN set and the default Host rewrite, (1) a loopback request with Host: attacker.example and no token passes the Rust gate, and (2) a remote request with a valid token can reach /admin/upstream. Both are relayed to Python as a loopback peer with a loopback Host, so Python's local-only guard admits requests it would reject directly. Please preserve the original peer/Host trust boundary for local-only routes and add regression coverage with the real Python upstream, including foreign-Host loopback and authenticated remote admin requests. Expected: these requests cannot obtain local-only data; ordinary authenticated provider traffic still works. These are the existing unresolved discussion findings, not new regressions introduced by this patch. Please also qualify the statement that monitoring aggregates are available to anyone.
Validation: staged Rust CI reports success, but I could not independently run the integration tests because cargo is unavailable. The submitted real behavior proof uses a recorder and explicitly excludes the Python chain. Inherited workflow provenance holds are tracked separately and are not contributor defects.
The Rust front reaches the Python proxy from 127.0.0.1 and, by default, rewrites Host to the upstream's loopback authority, so every relayed request passed both checks Python's local-only routes make (loopback peer and loopback Host). That held for a remote caller holding the proxy token and for a loopback peer that sent a non-loopback Host. The inbound gate now always runs and marks a caller as non-local unless it is a loopback peer with a loopback Host (same rules as loopback_guard.is_loopback_host_header). forward_http sends a non-loopback Host to a loopback upstream for such callers (their own, or headroom-remote.invalid), so /admin, /debug, /settings and the other local-only routes stay closed to them. RUST_DEV.md now says the token also applies to /stats and /metrics for remote callers. Assay-Kind: bugfix Assay-Ask: Requests relayed by the Rust front still look local to the Python proxy's local-only routes when the caller holds the token or reaches the front through a non-loopback hostname. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JerrettDavis
left a comment
There was a problem hiding this comment.
The previous ordinary Host cases and monitoring docs are addressed, but the local-only boundary still has a scoped-IPv6 bypass. With a loopback Python upstream, a remote token holder can request GET /admin/upstream using Host: [::1%lo]:8787. Rust rejects the scoped literal as IpAddr and preserves it as supposedly non-loopback; Python accepts that Host as loopback, so its admin guard passes. I reproduced the actual Python guard: evil.example and headroom-remote.invalid return 404, while [::1%lo]:8787 is admitted, on both head and recorded main. The full Rust-to-Python chain was not executed. Please use the reserved non-loopback Host for every NonLocalCaller (or make classification conservatively cover Python forms), and add a scoped-IPv6 regression exercising Python denial. Expected: non-local callers cannot obtain local-only data even with a valid token. Rust tests could not run here because cargo is absent; head Rust CI was still running in the snapshot. Inherited workflow provenance holds are tracked separately.
Picks up the dependency updates from #4835 so the audit checks pass. Assay-Kind: deps Assay-Ask: The dependency audit checks failed on this PR because the branch predates the dependency updates on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he upstream address The override for callers that are not local only ran when the upstream URL named a loopback address. With --no-rewrite-host and an upstream name that resolves to loopback, a caller's Host: localhost was passed through, so the upstream saw a loopback peer with a loopback Host. The front now replaces the Host whenever the one it would send (the explicit outgoing Host, else the upstream authority) is a loopback name, whatever --rewrite-host is and whatever the upstream name resolves to. Assay-Kind: bugfix Assay-Ask: With host rewriting off and an upstream hostname that resolves to loopback, a relayed caller can still present a loopback Host to the upstream's local-only routes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JerrettDavis
left a comment
There was a problem hiding this comment.
The alias fix addresses the latest Devin finding, but the previously requested scoped-IPv6 repair remains unresolved. With a loopback Python upstream, a remote token holder can send GET /admin/upstream with Host: [::1%lo]:8787. The current helper still preserves that Host because Rust rejects the scoped literal; Python accepts it as loopback. The new effective-Host check also leaves it intact with --no-rewrite-host. Please conservatively prevent every non-local caller from being forwarded with a Python-loopback Host and add scoped-IPv6 regression coverage against the Python guard. Expected: a valid proxy token never grants local-only administration. Earlier review reproduced the Python guard admission; no full Rust/Python chain is claimed. Hosted Rust checks pass at this head; local cargo is unavailable. Inherited workflow provenance issues are tracked separately.
Description
The Rust front (
crates/headroom-proxy) relays every request to the Python proxy from127.0.0.1. The Python proxy treats a loopback caller as local: it skips theHEADROOM_PROXY_TOKENcheck and serves/admin,/debugand/settingsto it. The front had no inbound check of its own, listened on0.0.0.0:8787by default, and droppedx-headroom-proxy-token(as anx-headroom-*header), so a token set on the Python side neither protected the stack nor could be presented through the front.This PR makes the Rust front apply the same token model as the Python proxy (
read_proxy_token/scrub_proxy_token_headersinheadroom/proxy/server.py,headroom/proxy/bind_policy.py) before anything is forwarded, and makes its default listen address loopback.Fix adapted from a proposal by HetCreep.
Type of Change
Changes Made
crates/headroom-proxy/src/inbound_auth.rs, installed as a router-wide layer (all routes, the catch-all and WebSocket upgrades) whenHEADROOM_PROXY_TOKEN/--proxy-tokenis set:X-Headroom-Proxy-TokenorAuthorization: Bearer; the explicit header wins, as in Python. Constant-time compare. Otherwise401 {"error":"unauthorized"}and no upstream contact; aproxy_auth_rejectedwarning is logged (no credential values).GET /healthz,/healthz/upstream,/health,/livez,/readyzstay open;GET/HEAD /api/hello(also under/p/<project>) is answered locally with an empty 200, as Python does, instead of being relayed.x-headroom-proxy-tokenalways,Authorizationonly when it carries the token (a provider credential is left alone).Debugoutput ofConfig.main.rs: startup bind policy, same rule and variable as Python: a non-loopback--listenwith no token exits with code 2 and a message naming both remedies, unlessHEADROOM_ALLOW_UNAUTHENTICATED_BIND=1(then a warning is logged).config.rs:--listen/HEADROOM_PROXY_LISTENdefault changes from0.0.0.0:8787to127.0.0.1:8787; new--proxy-token(envHEADROOM_PROXY_TOKEN, value hidden from--help).RUST_DEV.md: runbook exports a token for the0.0.0.0cutover, flag table updated, new "Inbound authentication" section.Behaviour change for operators: a Rust front started with
--listen 0.0.0.0:...(or relying on the old default to be reachable from other hosts) now needsHEADROOM_PROXY_TOKENset, and clients on other hosts must send it. Setups that front the port with something else that restricts access can setHEADROOM_ALLOW_UNAUTHENTICATED_BIND=1. Loopback clients (Claude Code, Codex, SDKs on the same host) are unaffected. The Docker image's entrypoint is the Python proxy, so it is unaffected.Testing
cargo test -p headroom-proxy(46 test binaries, 608 tests pass)cargo clippy -p headroom-proxy --all-targets -- -D warningsandcargo fmt --all -- --checkcleanpytest/ruff/mypy: N/A, no Python changesNew tests:
tests/integration_inbound_auth.rs(11 tests,oneshotwith an injectedConnectInfoso a non-loopback peer can be simulated,wiremockupstream): missing/wrong token is 401 and the upstream sees nothing; correct token via header or Bearer is forwarded without the token; providerAuthorizationkept when the header carries the token; loopback and IPv4-mapped loopback peers pass; missing peer address fails closed; health probes open butPOST /readyzis 401;/api/helloanswered locally; no token configured keeps passthrough.tests/integration_bind_policy.rs(3 tests, real binary):0.0.0.0with no token refuses to start; loopback starts; default--listenis loopback.inbound_auth.rsfor token parsing, constant-time compare, bind policy and exemptions.Test Output
Before (the bind-policy test file copied onto a clean
origin/mainworktree;integration_inbound_auth.rsdoes not compile there since main has no token setting at all):After:
Real Behavior Proof
headroom-proxyfromorigin/main(c21b081) and from this branch. Upstream is a small Python HTTP recorder on127.0.0.1:18788that prints each request it receives. Requests are sent with curl to the machine's LAN address (non-loopback peer) and to127.0.0.1.HEADROOM_PROXY_TOKEN=s3cret HEADROOM_PROXY_STATS=false headroom-proxy --listen 0.0.0.0:18787 --upstream http://127.0.0.1:18788, thencurl http://<lan-ip>:18787/admin/upstreamwith no token, a wrong token and the right token inx-headroom-proxy-token,GET /readyzwith no token from the LAN address, andGET /v1/modelsfrom127.0.0.1with no token.Runtime Rollout Safety
HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1restores starting on a non-loopback address without a token;--listen 0.0.0.0:8787restores the old address--listen 127.0.0.1:<port>)Review Readiness
Checklist
CHANGELOG.md— it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)Additional Notes
A companion PR makes the Rust front's native Bedrock and Vertex routes opt-in; it relies on this token gate for callers on those routes.
🤖 Generated with Claude Code