fix(security): localhost auth bypass refuses requests with reverse-proxy forwarding headers - #801
scottwofford wants to merge 1 commit into
Conversation
…oxy forwarding headers The localhost auth bypass decided purely from the TCP source IP (request.client.host). A reverse proxy on the same host (Caddy, nginx, Traefik) forwards every external request from 127.0.0.1, so the entire admin/history/debug surface was served unauthenticated to the public internet under the default LOCALHOST_AUTH_BYPASS=true, even with ADMIN_API_KEY set. The bypass now additionally requires the absence of reverse-proxy forwarding headers (Forwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Real-IP). Proxied requests fall through to normal auth (admin key / session). Direct loopback requests without those headers still bypass, so dockerless dev and the CLI are unaffected. Also adds a startup warning whenever the bypass is enabled. Trello: https://trello.com/c/ZLYI4skA Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewClean, well-scoped security fix. The threat model in the PR description is spot-on and the fail-safe design (presence-based header check that can only narrow the bypass) is the right primitive. Below are review notes — none are blocking; two are worth considering before merge. Code quality & correctness
Potential gaps
Test coverage
Performance & security
Nits
VerdictReady to merge as-is. Recommended follow-ups (separate PRs / cards):
|
|
Claude-generated merge-queue triage of all open Luthien PRs, requested by Scott (Jul 7, 2026). Advisory only; Scott has not yet acted on these recommendations. Recommendation: merge, after a human security read (as the PR itself requests). The diff matches the description: the localhost bypass now requires both a loopback TCP source and the absence of |
SECURITY: needs Scott's explicit review before merge.
Fixes the high-severity localhost auth bypass hole tracked in Trello: security: localhost auth bypass trusts TCP source IP — same-host reverse proxy exposes admin surface. Corroborated by finding F5 in the 2026-07-07 security/telemetry audit (luthien-org
dev/2026-07-07_security-telemetry-data-audit.md).Threat model
is_localhost_request()decided the auth bypass purely fromrequest.client.host(TCP source IP). It never looked at forwarding headers.LOCALHOST_AUTH_BYPASS=true(the default), the entire admin surface (/api/admin/*, history, request logs, debug, admin UI) was served unauthenticated to the public internet, regardless of whetherADMIN_API_KEYwas set. That surface is exactly where stored conversation content is readable.Chosen behavior
The bypass now requires all three:
LOCALHOST_AUTH_BYPASSenabled (default unchanged:true— see below),127.0.0.1,::1,::ffff:127.0.0.1), andForwarded(RFC 7239),X-Forwarded-For,X-Forwarded-Host,X-Forwarded-Proto,X-Real-IP.Why header presence rather than parsing the forwarded client IP: presence-detection fails safe. Caddy and Traefik always attach
X-Forwarded-For; standard nginx configs attachX-Forwarded-Forand/orX-Real-IP. A client can also set these headers themselves, but that only disables the bypass for that client and drops them into normal admin-key auth — an attacker cannot gain access by adding headers, and cannot strip the headers the proxy adds on the hop the gateway sees. A trusted-proxy CIDR config (option 1 on the card) would be strictly more machinery for no additional safety on this route, and gets the trust direction wrong if misconfigured.Also added: a startup warning whenever the bypass is enabled, stating that any local process can read stored conversations and that reverse-proxy deployments should set
LOCALHOST_AUTH_BYPASS=false(the gateway binds 0.0.0.0 unconditionally inmain.py, so a bind-address startup guard — option 4 on the card — would fire on every deployment; the warning covers the same ground without a new config knob).Residual risk (documented in
auth.py): a same-host reverse proxy explicitly configured to strip/omit all forwarding headers still looks like a direct loopback client. The guidance to setLOCALHOST_AUTH_BYPASS=falsebehind any reverse proxy stays in the module docstring, the config-field description, and the startup warning.Default NOT changed:
LOCALHOST_AUTH_BYPASSstill defaults totrue. Flipping it tofalse(opt-in bypass) is the stronger long-term posture but is a breaking change to the local dev / dockerless browse-the-dashboard-without-logging-in workflow, and deserves an explicit product decision. Input for that decision: theluthienCLI does not depend on the bypass (onboard generates anADMIN_API_KEYandgateway_client.pysends it as a Bearer token), so the flip would mainly cost browser UX — a one-time/loginwith the key from.env. Left as a follow-up decision on the Trello card rather than bundled here (one PR = one concern).What still works (unchanged behavior)
curl http://localhost:8000/api/admin/...from the same box, no proxy: bypass applies as before./v1/messagesproxy auth (verify_tokeningateway_routes.py): never consulted this module; unaffected.RCA
auth.py:15-20docstring literally described this attack — but the mitigation was left to the operator reading source comments and flipping a non-obvious env var. No test modeled the proxied topology; every bypass test used a bare loopback request. A security posture that lives only in a docstring is not a control.verify_admin_token→ 403,check_auth_or_redirect→ login redirect) across each forwarding header, plus bare-loopback and valid-key-through-proxy cases, so a future refactor of_should_bypass_auththat drops the header check fails CI.Tests
tests/luthien_proxy/unit_tests/test_auth.py:TestHasForwardingHeaders,TestLocalhostBypassRefusesProxiedRequests,TestLocalhostBypassRefusesProxiedAdminApi— bypass works for bare localhost; refuses per forwarding header; admin API returns 403 in the proxied case; valid admin key still authenticates when proxied../scripts/dev_checks.shfully green: ruff format + lint, pyright (0 errors, 0 warnings), full unit + integration pytest run.🤖 Generated with Claude Code