fix(BUG-02): rate limiter now keys on real client IP + enforces default_limits
The frontend proxy fix alone was incomplete. Backend slowapi used the default
get_remote_address (request.client.host), which is the proxy's IP because
uvicorn runs without --proxy-headers — so the relayed x-forwarded-for was
ignored and all users still shared one rate-limit bucket.
- Add app/ratelimit.py: shared `limiter` + `client_ip_key` that reads
x-forwarded-for[0] → x-real-ip → peer. Replaces the three independent
Limiter(get_remote_address) instances in main.py / posts.py / prices.py
(which also had separate, non-shared storage).
- Register SlowAPIMiddleware so default_limits ("60/minute") applies to EVERY
route. Previously only the 2 decorated read endpoints were limited; all
signed-mutation routes had no rate limit at all (the "20/min per-route"
comment was aspirational — no such decorator existed).
- Add tests/test_ratelimit.py (7 tests): XFF precedence, fallbacks, two users
behind one proxy get distinct keys, middleware-registered guard.
72 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""Regression tests for BUG-02: rate limiter must key on the real client IP
|
||||
relayed by the proxy (x-forwarded-for / x-real-ip), not the proxy's own IP,
|
||||
and default_limits must actually be enforced via SlowAPIMiddleware."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
from app.ratelimit import client_ip_key, limiter
|
||||
|
||||
|
||||
def _req(headers: dict, peer: str | None = "10.0.0.1"):
|
||||
"""Minimal stand-in for starlette Request: only .headers and .client are
|
||||
read by client_ip_key. Headers must be case-insensitive get()."""
|
||||
lower = {k.lower(): v for k, v in headers.items()}
|
||||
client = SimpleNamespace(host=peer) if peer is not None else None
|
||||
return SimpleNamespace(
|
||||
headers=SimpleNamespace(get=lambda k, d=None: lower.get(k.lower(), d)),
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
def test_key_prefers_first_x_forwarded_for_hop():
|
||||
# Left-most XFF entry is the original client; proxies append to the right.
|
||||
r = _req({"x-forwarded-for": "203.0.113.9, 10.0.0.1, 172.16.0.1"})
|
||||
assert client_ip_key(r) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_key_falls_back_to_x_real_ip():
|
||||
r = _req({"x-real-ip": "198.51.100.7"})
|
||||
assert client_ip_key(r) == "198.51.100.7"
|
||||
|
||||
|
||||
def test_key_falls_back_to_peer_when_no_headers():
|
||||
r = _req({}, peer="192.0.2.50")
|
||||
assert client_ip_key(r) == "192.0.2.50"
|
||||
|
||||
|
||||
def test_key_handles_missing_client():
|
||||
r = _req({}, peer=None)
|
||||
assert client_ip_key(r) == "unknown"
|
||||
|
||||
|
||||
def test_two_users_behind_same_proxy_get_distinct_keys():
|
||||
# The whole point of BUG-02: same TCP peer (proxy), different end users.
|
||||
a = _req({"x-forwarded-for": "203.0.113.1"}, peer="10.0.0.1")
|
||||
b = _req({"x-forwarded-for": "203.0.113.2"}, peer="10.0.0.1")
|
||||
assert client_ip_key(a) != client_ip_key(b)
|
||||
|
||||
|
||||
def test_limiter_uses_custom_key_func():
|
||||
assert limiter._key_func is client_ip_key
|
||||
|
||||
|
||||
def test_slowapi_middleware_registered_on_app():
|
||||
# Without the middleware, default_limits never applies to undecorated
|
||||
# (mutation) routes. Guard against silent regression.
|
||||
import app.main as main
|
||||
classes = [m.cls for m in main.app.user_middleware]
|
||||
assert SlowAPIMiddleware in classes
|
||||
Reference in New Issue
Block a user