Files
plm-lottery/app/api/client_ip.py
T
davideandClaude Opus 5 907e32e9e0 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>
2026-08-03 22:14:03 +02:00

32 lines
1.6 KiB
Python

from fastapi import Request
def client_ip(request: Request) -> str:
"""The caller's real IP, from Caddy's X-Forwarded-For (see Caddyfile) —
request.client.host would otherwise be the reverse proxy's own address, not
the caller's. Falls back to request.client.host only if the header is
somehow missing (e.g. the app container hit directly, bypassing Caddy).
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:
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"