Files
plm-lottery/app/api/client_ip.py
davide 702b37b319 Cap SSE subscribers per client IP instead of only globally (B-38)
GET /rounds/stream capped concurrent subscribers with one global
counter (MAX_SUBSCRIBERS=500): anyone opening 500 connections degraded
every other user to polling. The comment called it a defensive cap;
it was actually the vector, since nothing stopped a single source from
exhausting it alone.

RoundEventBroadcaster now also tracks subscribers per client IP,
capped much lower (MAX_SUBSCRIBERS_PER_IP=5). Past that cap, opening
one more stream evicts that same IP's own oldest connection (woken via
a new EVICTED sentinel so the SSE generator closes it promptly) rather
than refusing the new one or letting one abusive IP crowd out unrelated
clients under the old global-only cap. The global cap stays as a
backstop against overall resource exhaustion regardless of source.

Extracted client_ip() (X-Forwarded-For, since Caddy reverse-proxies
every request) out of auth/routes.py into app/api/client_ip.py so the
login/registration throttles (B-33) and this new per-IP cap share one
definition instead of two that could drift apart.

Not implemented: enforcing the connection cap at the Caddy layer
itself, which the proposed fix also suggested - the standard Caddy
image this project uses has no such directive without a third-party
module, and building a custom image felt like a bigger, separate change
than this fix warranted.

Suite grows from 201 to 211 tests. BUGS.md moves B-38 to Previously
fixed.
2026-07-27 14:44:15 +02:00

18 lines
742 B
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".
"""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"