mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
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
46 lines
1.5 KiB
Python
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)
|