The limiter keyed on request.client.host, which in staging and prod is the Next container — the backend has no published ports and nothing else can reach it. So every browser user on the site shared one 60/minute bucket per route. Measured against staging: 70 concurrent requests to /api/schools returned exactly 60 OK and 10 refused, from one machine. CF-Connecting-IP first. Cloudflare fronts both environments and overwrites any client-supplied value, which a parsed X-Forwarded-For chain does not guarantee. The XFF fallback is forgeable only from inside the Docker network. Named rather than hidden: the shared bucket was an accidental global throttle on a single-process backend, and correct per-user keying removes it. A real global ceiling belongs at Cloudflare, which is already in the path; slowapi cannot express one without a second Limiter and middleware this app does not install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
59 lines
2.2 KiB
Python
59 lines
2.2 KiB
Python
"""The rate-limit bucket must be the caller, not the proxy in front of them.
|
|
|
|
`get_remote_address` reads request.client.host. In staging and production the
|
|
backend has no published ports and its only caller is the Next proxy, so that
|
|
host is the Next container — one bucket for every browser user on the site.
|
|
Measured before this fix: 70 concurrent requests, 60 served and 10 refused.
|
|
"""
|
|
|
|
from starlette.datastructures import Headers
|
|
|
|
from backend.app import client_key
|
|
|
|
|
|
class _Req:
|
|
"""Enough of a Request for the key function: headers and a client host."""
|
|
|
|
def __init__(self, headers: dict, host: str = "10.0.0.9"):
|
|
self.headers = Headers(headers)
|
|
self.client = type("C", (), {"host": host})()
|
|
self.scope = {"type": "http", "client": (host, 0),
|
|
"headers": [(k.lower().encode(), v.encode())
|
|
for k, v in headers.items()]}
|
|
|
|
|
|
def test_cloudflare_header_wins():
|
|
# Cloudflare sets CF-Connecting-IP and overwrites any client-supplied
|
|
# value, so it is trustworthy in a way a parsed XFF chain is not.
|
|
assert client_key(_Req({"cf-connecting-ip": "203.0.113.7"})) == "203.0.113.7"
|
|
|
|
|
|
def test_forwarded_for_is_the_fallback_and_takes_the_first_entry():
|
|
# Left-most is the original client; everything after it is proxies.
|
|
assert client_key(
|
|
_Req({"x-forwarded-for": "203.0.113.7, 10.0.0.2"})) == "203.0.113.7"
|
|
|
|
|
|
def test_remote_address_is_the_last_resort():
|
|
assert client_key(_Req({}, host="10.0.0.9")) == "10.0.0.9"
|
|
|
|
|
|
def test_cloudflare_header_beats_forwarded_for():
|
|
key = client_key(_Req({"cf-connecting-ip": "203.0.113.7",
|
|
"x-forwarded-for": "198.51.100.1"}))
|
|
assert key == "203.0.113.7"
|
|
|
|
|
|
def test_two_callers_get_two_buckets():
|
|
# The whole point: one user exhausting their limit must not refuse another.
|
|
a = client_key(_Req({"cf-connecting-ip": "203.0.113.7"}))
|
|
b = client_key(_Req({"cf-connecting-ip": "203.0.113.8"}))
|
|
assert a != b
|
|
|
|
|
|
def test_whitespace_is_stripped():
|
|
# "a, b" split on comma leaves a leading space on every entry but the
|
|
# first; an unstripped key silently creates a second bucket per client.
|
|
assert client_key(_Req({"x-forwarded-for": " 203.0.113.7 ,10.0.0.2"})) \
|
|
== "203.0.113.7"
|