Conversation
…only its last hop
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused security fix. The premise is present on current main: hermes_cli/dashboard_auth/routes.py:108-112 trusts the first raw X-Forwarded-For hop, and routes.py:667-668 uses that result for the password-login throttle bucket. The same first-hop behavior exists in token_auth.py:83-87 and middleware.py:89-93.
Problems
tests/hermes_cli/test_dashboard_auth_xff_trusted_peer.py:33covers onlyroutes._client_ipandtoken_auth._client_ip, although this PR also changesmiddleware._client_ip. That leaves the gate-audit implementation outside the new regression contract.
Suggested changes
- Add
middleware._client_ipto_HELPERSattests/hermes_cli/test_dashboard_auth_xff_trusted_peer.py:33so every changed copy is tested for loopback gating, last-hop selection, and empty-hop handling.
Automated hermes-sweeper review.
|
|
||
| # Both call sites carry an identical helper; every case below must hold for | ||
| # each of them, so they are parametrized rather than duplicated. | ||
| _HELPERS = (_client_ip, token_auth._client_ip) |
There was a problem hiding this comment.
This tuple omits middleware._client_ip, although the PR changes that third copy too. Please add it here so the trusted-peer contract covers every modified implementation.
_HELPERS listed routes and token_auth but not middleware, which this PR also changes. That left the one copy feeding the audit log's ip= field on every authenticated request -- SESSION_VERIFY_FAILURE, REFRESH_FAILURE, LOGIN_START -- patched with nothing exercising it. Verified the added coverage is real: reintroducing the first-hop read in middleware._client_ip alone now fails 4 cases under the _client_ip2 parametrization, where before it failed none.
|
Fixed in 440bfc8.
_HELPERS = (_client_ip, token_auth._client_ip, middleware._client_ip)The omission mattered more than a missing third parametrization usually would — Checked that the new coverage actually bites rather than just importing the symbol: reintroducing the first-hop read in |
|
suggesting changes The patch correctly limits X-Forwarded-For use to loopback peers and takes the final nonempty hop, but the trust boundary can still be bypassed when Uvicorn rewrites
Security evidence:
Not checked:
Signed: GPT-5.6-luna-max in Codex |
Severity: high — defeats the only brute-force control in front of the
password login, and forges the source IP on every auth audit event.
Line references below are against
cd6585abf.Problem.
_client_iptakes the first element ofX-Forwarded-For, whichis entirely client-supplied. Three byte-identical copies:
hermes_cli/dashboard_auth/routes.py:108-112,hermes_cli/dashboard_auth/token_auth.py:83-87, andhermes_cli/dashboard_auth/middleware.py:89-93.That return value is:
routes.py:667feeds it to_password_rate_limited(routes.py:615-634,10 attempts / 60s,
routes.py:609-610). Sending a different first hop onevery request allocates a fresh bucket every time, so the limiter never
fires and online guessing runs unmetered against the provider's scrypt
verify (
_SCRYPT_N = 2**14, plugins/dashboard_auth/basic/init.py:95).ip=field on every auth audit record —routes.py:673, 688, 701, 714, 724(login failure/success) andtoken_auth.py:178, 189(
TOKEN_AUTH_FAILURE). Any caller can write an arbitrary source IP into theaudit trail, so post-incident attribution is unusable.
routes.py:347passes it asnative_flow.register_pending(client_ip=...), andnative_flow.py:195-197compares it for equality (
v.client_ip == client_ip) against_MAX_PENDING_PER_IP. So the value is not merely logged: it is a livecomparison key, and rotating the header lifts that cap too.
A prepended hop costs the attacker one header. No proxy configuration
mitigates it, because the header is read raw: uvicorn's
proxy_headers(
hermes_cli/web_server.py:17242, enabled when the gate is active) rewritesrequest.clientfrom the forwarded headers, but_client_ipnever consultsrequest.clientwhen the header is present.Change. Replace the first-hop read with a trusted-peer gate, in both
copies:
with the loopback test shared from
dashboard_auth/base.py(which neither copyimported from before, and which has no intra-package imports, so there is no
cycle):
Three details that a literal
peer not in ("127.0.0.1", "::1")test gets wrong,each of which fails closed on availability rather than open on security — i.e.
they lock legitimate users out rather than letting attackers in, which is why
they are easy to miss in review:
--host ::) the IPv4loopback proxy peer arrives as
::ffff:127.0.0.1. A literal tuple rejects it,X-Forwarded-For is discarded, and every client collapses into one throttle
bucket — 429 on correct credentials, plus 503 once
native_flow._MAX_PENDING_PER_IP(native_flow.py:92) is hit.127.0.0.0/8.is_loopbackcovers it; a two-element tupledoes not.
"10.0.0.7,"yields""fromsplit(",")[-1].strip(), which lands every such caller in the shared_unknown_bucket. Filtering empty hops avoids it.Two rules, both minimal:
direct connection has no legitimate reason to carry the header, so on that
path it is ignored outright and the real peer is used.
appends the address it received the connection from. In a single-proxy
deployment (the shipped shape — a loopback-bound dashboard fronted by one
reverse proxy, cf. dashboard: reverse-proxy Host allowlist + auth-gate scoping (extra_hosts) #75907) the last element is exactly the address that
proxy observed, and the one element a client cannot influence. Everything
to its left is whatever the client sent.
Signature, name and return contract are unchanged (
str,""when there is nodiscernible peer — which still lands in the throttle's shared
_unknown_bucket,
routes.py:626). The stale clause in the throttle's block comment(
routes.py:604-607) is corrected to match the new granularity.Deliberately not generalized into a configurable trusted-proxy list: with one
proxy, "last hop" is unconditionally correct and needs no configuration. A
multi-proxy deploy is the case that would need
trusted_hops = N, and addingthat knob before anyone runs that shape invites mis-setting it — a too-large
value reintroduces exactly this bug.
Evidence/Repro. 50 password-login attempts from a loopback peer, rotating
only the forgeable first hop while the proxy-appended last hop stays constant:
10/50 is the intended budget (
_PW_RATE_MAX_ATTEMPTS = 10). Before the changethe limiter is a no-op against any attacker who rotates one header.
Tests. New file
tests/hermes_cli/test_dashboard_auth_xff_trusted_peer.py— 27 tests. Thehelper contract is parametrized across both copies of
_client_ip(
routesandtoken_auth) so they cannot drift apart again: spoofed multi-hopchain from a loopback peer resolves to the last hop;
::1and::ffff:127.0.0.1are trusted like127.0.0.1; a trailing separator does notdegrade to the empty bucket key; a non-loopback peer (public and RFC1918)
ignores the header entirely; missing/empty header and absent
request.clientkeep the old fallbacks. Three end-to-end throttle tests drive the real route
through
TestClient(..., client=<peer>): rotating the first hop exhausts onebucket (
404 × 10then429), two distinct last hops get distinct buckets,and a non-loopback peer cannot reset its bucket with any header value.
Reverting only the two
_client_ipbodies against this test file fails 18 ofthe 27, which is the intended regression surface.
Alternatives considered.
proxy_headers. Doesn't apply — it normalizesrequest.client, and the buggy code path never readsrequest.clientoncethe header exists. Fixing the reader is the actual fix. The two do compose
cleanly afterwards: with
proxy_headerson, uvicorn has already resolvedrequest.client.hostto the last untrusted hop, sois_loopback_peerisfalse and that value is returned unchanged; with it off, the peer is the
loopback proxy and the header's last hop is read directly. Same answer either
way.
username-enumeration oracle that
routes.py:660-704deliberately avoids —unknown provider and bad password both return generic responses, and the
basic provider spends a fixed
_DUMMY_HASHscrypt round on unknownusernames for constant time (
plugins/dashboard_auth/basic/__init__.py:255).A per-username bucket makes "this username exists" observable through
429-vs-401 timing and counts.
needs; see above.
Residual, unchanged by this patch. Setting
FORWARDED_ALLOW_IPS=*in theenvironment makes uvicorn itself rewrite
request.clientfrom the firstX-Forwarded-For hop, which would put an attacker-chosen value back into
peer.The repo never sets it, and that is uvicorn's documented behaviour for
*rather than something this helper can defend against — noted so the trust
boundary is explicit.
All three copies are fixed here, not just the two with a rate-limit
consequence. The third lives at
hermes_cli/dashboard_auth/middleware.py:89-93and feeds
ip=on the gate's own audit events (middleware.py:239, 434, 498, 505, 570, 582—SESSION_VERIFY_FAILURE,REFRESH_FAILURE,LOGIN_START).It has no throttle consequence, so it was tempting to defer; but leaving it
would mean shipping a security fix whose own shared-helper docstring is
falsifiable by a single
grep -rn "def _client_ip", and would leave a third ofthe audit trail forgeable. It is a three-line change against the same helper.
After this patch
grep -rn 'split(",")\[0\]' hermes_cli/dashboard_auth/returnsnothing.