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,47 @@
|
||||
"""Shared rate-limiter instance + client-IP key function.
|
||||
|
||||
WHY THIS MODULE EXISTS (BUG-02):
|
||||
slowapi's default `get_remote_address` keys on `request.client.host` — the
|
||||
immediate TCP peer. In production the backend sits behind the Next.js proxy
|
||||
(app/api/proxy/[...path]/route.ts), and uvicorn is launched WITHOUT
|
||||
`--proxy-headers` (see entrypoint.sh), so `request.client.host` is the proxy
|
||||
server's IP for EVERY request. That collapses all users into a single
|
||||
rate-limit bucket — the proxy gets throttled, real per-user limiting never
|
||||
happens.
|
||||
|
||||
The frontend proxy relays the real client IP in `x-forwarded-for` (falling
|
||||
back to `x-real-ip`). This key function reads those headers explicitly so
|
||||
the limiter buckets per end-user. All routers MUST import THIS `limiter`
|
||||
(not construct their own) so every decorated route shares one instance and
|
||||
one storage backend, and `app.state.limiter` matches the decorators.
|
||||
|
||||
SECURITY NOTE:
|
||||
A client that can reach the backend directly (bypassing the proxy) could
|
||||
spoof `x-forwarded-for` to mint a fresh bucket per request. That is inherent
|
||||
to any XFF-based limiting and is acceptable here because the backend is only
|
||||
meant to be reachable via the proxy. If the backend is ever exposed publicly,
|
||||
restrict trust to the known proxy IP (uvicorn `--forwarded-allow-ips`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from slowapi import Limiter
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def client_ip_key(request: Request) -> str:
|
||||
"""Best-effort real client IP: x-forwarded-for[0] → x-real-ip → peer."""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
# Left-most entry is the original client; subsequent hops are proxies.
|
||||
first = xff.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
xri = request.headers.get("x-real-ip")
|
||||
if xri and xri.strip():
|
||||
return xri.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# Single shared limiter. default_limits applies to any route without an
|
||||
# explicit @limiter.limit(...) decorator.
|
||||
limiter = Limiter(key_func=client_ip_key, default_limits=["60/minute"])
|
||||
Reference in New Issue
Block a user