fix(rate-limit): non fidarsi del primo hop di X-Forwarded-For (#7)

_client_ip usava il primo elemento di XFF, impostabile dal client. Ora
usa l'elemento a -trusted_proxy_count (nuova impostazione, default 1,
env TRUSTED_PROXY_COUNT), quello aggiunto dal proxy fidato più esterno.
Con trusted_proxy_count=0 XFF e X-Real-IP sono ignorati.

Aggiunta anche web_concurrency (env WEB_CONCURRENCY, default 1): i
bucket del rate limit sono per processo, con --workers N il limite
configurato viene diviso per N (arrotondato per eccesso, minimo 1) in
_effective_limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:34:02 +02:00
parent 1fee29358f
commit a330f2b340
4 changed files with 158 additions and 17 deletions
+47 -15
View File
@@ -20,6 +20,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
- All other endpoints: limited to `rate_limit_general` req/min.
Returns HTTP 429 with Retry-After header when limit exceeded.
Both configured limits are divided across worker processes before being
applied (see `_effective_limit`): each bucket lives in this process's
memory, so with `--workers N` a single configured limit is enforced N
times over across the cluster unless it is divided down first.
"""
LOGIN_PATH = "/api/auth/login"
@@ -34,23 +39,48 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
@staticmethod
def _client_ip(request: Request) -> str:
"""Resolve the originating client IP, honoring proxy headers.
"""Resolve the originating client IP behind `trusted_proxy_count` proxies.
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
``request.client.host``. Required because Nginx and the Flask client
sit between the tablet and the API; without parsing these headers
every tablet shares one bucket.
Nginx (and, behind it, the Flask client) each add one hop to
X-Forwarded-For. The *leftmost* entries are whatever the original
caller claimed and are trivially spoofed; trust only extends to the
hops actually appended by our own trusted proxies, counted from the
*right*. With `trusted_proxy_count = N`, the client IP is the Nth
element from the end of XFF (the one added by the outermost trusted
proxy). If XFF has fewer elements than that, or is absent, fall back
to `request.client.host` - except that `X-Real-IP` is tried first when
XFF is simply missing and at least one proxy is trusted.
With `trusted_proxy_count = 0`, no proxy header is honored at all
(neither XFF nor X-Real-IP) and only `request.client.host` is used.
"""
xff = request.headers.get("x-forwarded-for")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
real = request.headers.get("x-real-ip")
if real:
return real.strip()
count = settings.trusted_proxy_count
if count > 0:
xff = request.headers.get("x-forwarded-for")
if xff:
hops = [h.strip() for h in xff.split(",") if h.strip()]
if len(hops) >= count:
return hops[-count]
# Fewer hops than trusted proxies: the chain is short of what
# was configured, so fall back rather than trust an
# under-populated header.
else:
real = request.headers.get("x-real-ip")
if real:
return real.strip()
return request.client.host if request.client else "unknown"
@staticmethod
def _effective_limit(configured: int) -> int:
"""Divide a configured per-minute limit across worker processes.
Rounds up and never returns less than 1, so a low configured limit
with high concurrency still allows at least one request per worker
per window rather than locking everyone out.
"""
concurrency = max(settings.web_concurrency, 1)
return max(1, -(-configured // concurrency))
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
"""Remove timestamps outside the current sliding window."""
cutoff = now - self.WINDOW_SECONDS
@@ -100,7 +130,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Check login-specific rate limit
if path == self.LOGIN_PATH and request.method == "POST":
allowed, retry_after = self._check_rate_limit(
self._login_requests, client_ip, settings.rate_limit_login, now
self._login_requests, client_ip,
self._effective_limit(settings.rate_limit_login), now,
)
if not allowed:
return JSONResponse(
@@ -111,7 +142,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Check general rate limit
allowed, retry_after = self._check_rate_limit(
self._general_requests, client_ip, settings.rate_limit_general, now
self._general_requests, client_ip,
self._effective_limit(settings.rate_limit_general), now,
)
if not allowed:
return JSONResponse(