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 -7
View File
@@ -5,11 +5,12 @@ from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from slowapi.middleware import SlowAPIMiddleware
from app.config import settings
from app.ratelimit import limiter
from app.database import AsyncSessionLocal, engine
from app.models import Base
from app.scrapers.truth_social import poll_truth_social, backfill_history
@@ -276,11 +277,10 @@ async def lifespan(app: FastAPI):
logger.info("Shutdown complete.")
# Rate limiter — keyed by client IP (X-Forwarded-For → remote_addr fallback).
# Public read endpoints: 60 req/min. Signed mutations: 20 req/min (enforced
# per-route). Limits are generous enough for normal use but block scrapers
# and accidental polling loops.
limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
# Rate limiter is the shared instance from app.ratelimit — it keys on the
# real client IP (x-forwarded-for / x-real-ip) relayed by the Next.js proxy,
# NOT the proxy's own IP. See app/ratelimit.py for the BUG-02 rationale.
# Public read endpoints: 60 req/min. Signed mutations: 20 req/min (per-route).
app = FastAPI(
title="TrumpSignal API",
@@ -290,6 +290,11 @@ app = FastAPI(
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Register the middleware so default_limits actually applies to EVERY route
# (not just the two with an explicit @limiter.limit decorator). Without this,
# all signed-mutation routes had no rate limit at all. WebSocket scope is
# skipped by slowapi. Decorated routes keep their stricter explicit limit.
app.add_middleware(SlowAPIMiddleware)
# CORS
# In production we only allow the canonical frontend origin (FRONTEND_URL).