2026-07-27 14:44:15 +02:00
|
|
|
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".
|
2026-08-03 22:14:03 +02:00
|
|
|
|
|
|
|
|
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.
|
2026-07-27 14:44:15 +02:00
|
|
|
"""
|
|
|
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
|
|
|
if forwarded:
|
2026-08-03 22:14:03 +02:00
|
|
|
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
|
|
|
|
|
if hops:
|
|
|
|
|
return hops[-1]
|
2026-07-27 14:44:15 +02:00
|
|
|
return request.client.host if request.client else "unknown"
|