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
+12 -5
View File
@@ -426,11 +426,18 @@ this for you" to "you opened it, bot manages your discipline".
computes remaining seconds, and reschedules the task. Elapsed windows (backend
was down longer than the time-stop period) fire immediately with `delay_seconds=0`.
- **Rate limit bypass via proxy x-forwarded-for deletion** (BUG-02, HIGH):
`app/api/proxy/[...path]/route.ts` (frontend) strips `x-forwarded-for`
before forwarding. Backend `slowapi` sees the Next.js server IP and applies
one rate-limit bucket to ALL users. Fix: relay the real client IP from
`req.headers.get('x-forwarded-for')` or `x-real-ip` in the proxy.
- ~~**Rate limit bypass via proxy x-forwarded-for** (BUG-02, HIGH, FIXED 2026-05-29):~~
Two-part fix. (1) Frontend proxy `app/api/proxy/[...path]/route.ts` now
relays the real client IP via `x-forwarded-for` / `x-real-ip`. (2) Backend
had the matching gap: `slowapi`'s default `get_remote_address` reads
`request.client.host` (the proxy IP, since uvicorn runs without
`--proxy-headers`), so the relayed header was ignored and all users still
shared one bucket. Now a shared `app/ratelimit.py` exposes `client_ip_key`
(reads `x-forwarded-for[0]` → `x-real-ip` → peer) used by ONE shared
`limiter` across `main.py`, `posts.py`, `prices.py`. Also registered
`SlowAPIMiddleware` so `default_limits` (60/min) actually applies to every
route — previously only the 2 decorated read endpoints were limited and all
signed-mutation routes had no limit at all. Covered by `tests/test_ratelimit.py`.
- ~~**`close_and_finalize` double-failure leaves DB/HL state inconsistent**
(BUG-03, MITIGATED 2026-05-29):~~ Full two-phase-commit is out of scope.