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.
44 lines
1.5 KiB
Python
44 lines
1.5 KiB
Python
"""app.api.client_ip is shared by the login/registration throttles (B-33) and
|
|
the SSE per-IP subscriber cap (B-38) — both depend on it correctly preferring
|
|
X-Forwarded-For (Caddy reverse-proxies every request, see Caddyfile) over
|
|
request.client.host, which would otherwise be the proxy's own address."""
|
|
|
|
from starlette.requests import Request
|
|
|
|
from app.api.client_ip import client_ip
|
|
|
|
|
|
def _request(*, forwarded: str | None = None, client_host: str | None = "127.0.0.1") -> Request:
|
|
headers = [(b"x-forwarded-for", forwarded.encode())] if forwarded else []
|
|
scope = {
|
|
"type": "http",
|
|
"headers": headers,
|
|
"client": (client_host, 12345) if client_host else None,
|
|
}
|
|
return Request(scope)
|
|
|
|
|
|
def test_client_ip_prefers_x_forwarded_for():
|
|
request = _request(forwarded="5.6.7.8", client_host="10.0.0.1")
|
|
assert client_ip(request) == "5.6.7.8"
|
|
|
|
|
|
def test_client_ip_takes_the_first_hop_of_a_forwarded_chain():
|
|
request = _request(forwarded="5.6.7.8, 10.0.0.1, 172.17.0.1")
|
|
assert client_ip(request) == "5.6.7.8"
|
|
|
|
|
|
def test_client_ip_strips_whitespace():
|
|
request = _request(forwarded=" 5.6.7.8 , 10.0.0.1")
|
|
assert client_ip(request) == "5.6.7.8"
|
|
|
|
|
|
def test_client_ip_falls_back_to_request_client_without_the_header():
|
|
request = _request(forwarded=None, client_host="10.0.0.1")
|
|
assert client_ip(request) == "10.0.0.1"
|
|
|
|
|
|
def test_client_ip_falls_back_to_unknown_with_neither():
|
|
request = _request(forwarded=None, client_host=None)
|
|
assert client_ip(request) == "unknown"
|