Files
rengine/api/app/core/throttle.py
T
Yogesh OjhaandClaude Opus 5 5784a75ff7 fix(api): the global rate limit could be lifted with one forged header
GlobalRateLimitMiddleware keyed its per-client bucket on the first
entry of X-Forwarded-For whenever the header was present. Nothing in
the shipped stack writes that header — the Vite proxy does not set
xfwd — so in practice the value was always supplied by the caller, and
rotating it per request gave every request a fresh bucket. Measured:
40 requests with a rotating header all returned 200 while 20 from the
same client without one were refused.

Reading the leftmost entry is also wrong behind a real reverse proxy,
which appends the peer address rather than replacing the header, so a
caller who prepends a value of their own is keyed on that value even in
a correct deployment.

client_id() now walks the chain right to left and returns the first hop
that is not itself a trusted proxy, and only consults the header at all
when the immediate peer is trusted. TRUSTED_PROXIES (empty by default)
names those hops as IPs or CIDRs; with no value set the peer address is
the only identity considered. The 429 now carries Retry-After.

Login is keyed by username and was never affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6LcH8uUddjJbKrahf34ys
2026-09-07 06:47:41 +05:30

46 lines
1.5 KiB
Python

import logging
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.config import settings
from app.core.client_ip import client_id
from app.core.ratelimit import _client
logger = logging.getLogger(__name__)
_EXEMPT_PREFIXES = ("/api/v1/events", "/api/v1/media")
class GlobalRateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
limit = settings.GLOBAL_RATE_LIMIT_PER_MINUTE
path = request.url.path
if (
limit <= 0
or request.method == "OPTIONS"
or any(path.startswith(p) for p in _EXEMPT_PREFIXES)
):
return await call_next(request)
bucket = int(time.time() // 60)
key = f"throttle:{client_id(request)}:{bucket}"
try:
pipe = _client().pipeline(transaction=True)
pipe.incr(key)
pipe.expire(key, 60, nx=True)
count, _ = await pipe.execute()
except Exception as exc:
logger.warning("global rate limiter unavailable: %s", exc)
return await call_next(request)
if count > limit:
return JSONResponse(
status_code=429,
content={"detail": "Rate limit exceeded. Try again in a minute."},
headers={"Retry-After": str(60 - int(time.time() % 60))},
)
return await call_next(request)