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:
k
2026-05-29 13:55:00 +08:00
parent d6c802ef26
commit 520bd7243d
6 changed files with 134 additions and 18 deletions
+47
View File
@@ -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"])