Make X-Forwarded-For trustworthy instead of attacker-controlled (B-54)
client_ip() read the first element of X-Forwarded-For, which is correct only if
the proxy replaces the header. Caddy appends the peer address to whatever the
client sent, so element 0 was whatever the caller claimed: rotating a fake value
per request minted a fresh identity every time and walked straight through the
login and registration throttles (B-33) and the SSE per-IP subscriber cap
(B-38). Only the per-username login bucket, which doesn't key on the IP, still
bit.
Both halves of the audit's fix, since they hold independently:
- the Caddyfile overwrites the header with `header_up X-Forwarded-For
{remote_host}`, so what reaches the app is the actual peer and nothing else.
This is the one that makes the app's assumption true at the source.
- client_ip() reads the *last* hop rather than the first — the element written
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
sits in front of the app (`app` is only `expose`d on the compose network,
never published to the host), so that element is the real peer.
An empty or comma-only header now falls back to request.client.host instead of
returning "", which was its own shared-bucket evasion.
Regression tests both sides: two requests spoofing different prefixes must key
to the same IP, and the Caddyfile must keep the header_up directive (checked by
`caddy validate`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+15
-1
@@ -10,8 +10,22 @@ def client_ip(request: Request) -> str:
|
||||
Shared by the login/registration throttles (B-33) and the SSE per-IP
|
||||
subscriber cap (B-38) so the two can't drift into different notions of
|
||||
"the client's IP".
|
||||
|
||||
B-54: the *last* element, not the first. A proxy appends the address it saw
|
||||
to any X-Forwarded-For the client already sent, so the first element is
|
||||
attacker-controlled — with the header read from the front, rotating a fake
|
||||
value per request gave every request a fresh identity and turned all three
|
||||
IP-keyed controls above into decoration. The last element is the one written
|
||||
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
|
||||
sits in front of this app (Caddy, see docker-compose.yml, where `app` is
|
||||
only `expose`d on the compose network and never published to the host), so
|
||||
the last element is the real peer. The Caddyfile now also overwrites the
|
||||
header with `header_up X-Forwarded-For {remote_host}`, which collapses it to
|
||||
a single value — belt and braces: either fix alone closes B-54.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
|
||||
if hops:
|
||||
return hops[-1]
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
Reference in New Issue
Block a user