Skip to content

fix(proxy): enforce HEADROOM_PROXY_TOKEN in the Rust front and listen on loopback by default - #4840

Open
chopratejas wants to merge 4 commits into
mainfrom
fix/rust-front-inbound-auth
Open

chopratejas wants to merge 4 commits into
mainfrom
fix/rust-front-inbound-auth

Conversation

@chopratejas

@chopratejas chopratejas commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Description

The Rust front (crates/headroom-proxy) relays every request to the Python proxy from 127.0.0.1. The Python proxy treats a loopback caller as local: it skips the HEADROOM_PROXY_TOKEN check and serves /admin, /debug and /settings to it. The front had no inbound check of its own, listened on 0.0.0.0:8787 by default, and dropped x-headroom-proxy-token (as an x-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_headers in headroom/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

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Code refactoring (no functional changes)

Changes Made

  • New crates/headroom-proxy/src/inbound_auth.rs, installed as a router-wide layer (all routes, the catch-all and WebSocket upgrades) when HEADROOM_PROXY_TOKEN / --proxy-token is set:
    • a non-loopback peer (or one with no peer address) must send the token as X-Headroom-Proxy-Token or Authorization: Bearer; the explicit header wins, as in Python. Constant-time compare. Otherwise 401 {"error":"unauthorized"} and no upstream contact; a proxy_auth_rejected warning is logged (no credential values).
    • loopback peers are exempt (same as Python); GET /healthz, /healthz/upstream, /health, /livez, /readyz stay open; GET/HEAD /api/hello (also under /p/<project>) is answered locally with an empty 200, as Python does, instead of being relayed.
    • the token is removed before forwarding: x-headroom-proxy-token always, Authorization only when it carries the token (a provider credential is left alone).
    • the token is redacted from Debug output of Config.
  • main.rs: startup bind policy, same rule and variable as Python: a non-loopback --listen with no token exits with code 2 and a message naming both remedies, unless HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1 (then a warning is logged).
  • config.rs: --listen / HEADROOM_PROXY_LISTEN default changes from 0.0.0.0:8787 to 127.0.0.1:8787; new --proxy-token (env HEADROOM_PROXY_TOKEN, value hidden from --help).
  • RUST_DEV.md: runbook exports a token for the 0.0.0.0 cutover, 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 needs HEADROOM_PROXY_TOKEN set, and clients on other hosts must send it. Setups that front the port with something else that restricts access can set HEADROOM_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 warnings and cargo fmt --all -- --check clean
  • New tests added for new functionality
  • Manual testing performed
  • pytest / ruff / mypy: N/A, no Python changes

New tests:

  • tests/integration_inbound_auth.rs (11 tests, oneshot with an injected ConnectInfo so a non-loopback peer can be simulated, wiremock upstream): missing/wrong token is 401 and the upstream sees nothing; correct token via header or Bearer is forwarded without the token; provider Authorization kept when the header carries the token; loopback and IPv4-mapped loopback peers pass; missing peer address fails closed; health probes open but POST /readyz is 401; /api/hello answered locally; no token configured keeps passthrough.
  • tests/integration_bind_policy.rs (3 tests, real binary): 0.0.0.0 with no token refuses to start; loopback starts; default --listen is loopback.
  • unit tests in inbound_auth.rs for token parsing, constant-time compare, bind policy and exemptions.

Test Output

Before (the bind-policy test file copied onto a clean origin/main worktree; integration_inbound_auth.rs does not compile there since main has no token setting at all):

test default_listen_address_is_loopback ... FAILED
test loopback_listen_without_token_still_starts ... ok
test non_loopback_listen_without_token_refuses_to_start ... FAILED
test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 20.01s

After:

$ cargo test -p headroom-proxy --test integration_inbound_auth --test integration_bind_policy
test default_listen_address_is_loopback ... ok
test non_loopback_listen_without_token_refuses_to_start ... ok
test loopback_listen_without_token_still_starts ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.01s
test missing_peer_address_fails_closed ... ok
test remote_peer_with_bearer_token_is_forwarded_without_it ... ok
test remote_peer_without_token_gets_401_and_upstream_is_not_contacted ... ok
test token_header_wins_over_a_provider_bearer ... ok
test no_token_configured_keeps_todays_passthrough ... ok
test loopback_peer_without_token_is_still_forwarded ... ok
test ipv4_mapped_loopback_peer_counts_as_loopback ... ok
test remote_peer_with_token_header_is_forwarded_without_it ... ok
test hello_probe_is_answered_locally_without_token ... ok
test remote_peer_with_wrong_token_gets_401 ... ok
test health_probes_stay_reachable_without_token ... ok
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.74s

Real Behavior Proof

  • Environment: macOS (Darwin 27.2), debug builds of headroom-proxy from origin/main (c21b081) and from this branch. Upstream is a small Python HTTP recorder on 127.0.0.1:18788 that prints each request it receives. Requests are sent with curl to the machine's LAN address (non-loopback peer) and to 127.0.0.1.
  • Exact command / steps: HEADROOM_PROXY_TOKEN=s3cret HEADROOM_PROXY_STATS=false headroom-proxy --listen 0.0.0.0:18787 --upstream http://127.0.0.1:18788, then curl http://<lan-ip>:18787/admin/upstream with no token, a wrong token and the right token in x-headroom-proxy-token, GET /readyz with no token from the LAN address, and GET /v1/models from 127.0.0.1 with no token.
  • Observed result:
== origin/main
GET /admin/upstream (no token)                     -> 200
GET /admin/upstream -H x-headroom-proxy-token:wrong -> 200
GET /admin/upstream -H x-headroom-proxy-token:s3cret -> 200
GET /readyz (no token)                             -> 200
GET /v1/models from 127.0.0.1 (no token)           -> 200
UPSTREAM SAW {"path": "/admin/upstream", "peer": "127.0.0.1", "token_hdr": null, "authorization": null}   (x3)
UPSTREAM SAW {"path": "/readyz", ...}
UPSTREAM SAW {"path": "/v1/models", ...}

== this branch
GET /admin/upstream (no token)                     -> 401
GET /admin/upstream -H x-headroom-proxy-token:wrong -> 401
GET /admin/upstream -H x-headroom-proxy-token:s3cret -> 200
GET /readyz (no token)                             -> 200
GET /v1/models from 127.0.0.1 (no token)           -> 200
UPSTREAM SAW {"path": "/admin/upstream", "peer": "127.0.0.1", "token_hdr": null, "authorization": null}   (only the authenticated one)
UPSTREAM SAW {"path": "/readyz", ...}
UPSTREAM SAW {"path": "/v1/models", ...}

$ headroom-proxy --listen 0.0.0.0:18787 --upstream http://127.0.0.1:18788   # no token
headroom-proxy: Refusing to listen on 0.0.0.0:18787: with no HEADROOM_PROXY_TOKEN this proxy would relay every network peer to the upstream Headroom proxy from 127.0.0.1, where it is treated as a trusted local caller. Either set HEADROOM_PROXY_TOKEN ... or ... set HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1 to acknowledge the open bind.
(exit 2)
  • Not tested: a full chain with the real Python proxy behind the Rust front (the recorder stands in for it); WebSocket upgrades through the gate are covered only by the layer wrapping the catch-all, not by a dedicated test.

Runtime Rollout Safety

  • Rollout-managed feature(s): none
  • Minimum rollout channel: N/A
  • Stable/default behavior changed: yes, default listen address is now loopback, and a non-loopback listen needs a token or the acknowledgement variable
  • Kill switch / disable path: HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1 restores starting on a non-loopback address without a token; --listen 0.0.0.0:8787 restores the old address
  • Unsafe override required: no
  • Qualification impact: none (qualification scripts already pass --listen 127.0.0.1:<port>)
  • Rollback path: revert this PR

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I did not edit 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

… 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>
@chopratejas chopratejas added the bug Something isn't working label Nov 13, 2026
@chopratejas chopratejas added rust Pull requests that update rust code size: medium 101–500 added/deleted lines. risk: medium Scope estimate: bounded implementation or tooling change; verification still required. area: proxy Touches proxy files; triage routing, not a CODEOWNERS assignment. labels Nov 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR governance

This PR does not yet satisfy the required template fields:

  • Fill in Real Behavior Proof → Observed result.

Please update the PR body, or move the PR back to draft while it is still in progress.

@github-actions github-actions Bot added status: needs author action Pull request body or readiness checklist still needs author updates status: ci failing Required or reported CI checks are failing labels Nov 13, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@github-actions github-actions Bot removed the status: ci failing Required or reported CI checks are failing label Nov 13, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
devin-ai-integration[bot]

This comment was marked as resolved.

…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 JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch was successfully deployed

1 active deployment
Preview — 80629bec Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: proxy Touches proxy files; triage routing, not a CODEOWNERS assignment. bug Something isn't working risk: medium Scope estimate: bounded implementation or tooling change; verification still required. rust Pull requests that update rust code size: medium 101–500 added/deleted lines. status: needs author action Pull request body or readiness checklist still needs author updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants