fix(security): make rate-limit client IP proxy-aware

The limiter keyed solely on the direct peer address, so behind a reverse
proxy every request shares the proxy's IP (collapsing all users into one
bucket). Blindly trusting X-Forwarded-For would instead let clients spoof it.

Add a trusted_proxy_count setting (default 0). When > 0, the client IP is read
from X-Forwarded-For counting that many hops from the right (ProxyFix-style);
when 0, the header is ignored and the peer address is used.

Refs: porchlight-8qj

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-05 13:35:29 +02:00
parent aedb451128
commit efb265a68b
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
3 changed files with 65 additions and 1 deletions

View file

@ -54,6 +54,10 @@ class Settings(BaseSettings):
# Rate limiting (disable for e2e/load tests that authenticate repeatedly)
rate_limit_enabled: bool = True
# Number of trusted reverse proxies in front of the app. When > 0, the
# client IP for rate limiting is taken from X-Forwarded-For, counting this
# many hops from the right. Keep 0 unless deployed behind a known proxy.
trusted_proxy_count: int = 0
# Signing keys
signing_key_path: str = "data/keys"

View file

@ -1,4 +1,25 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.requests import Request
limiter = Limiter(key_func=get_remote_address)
def _client_identifier(request: Request) -> str:
"""Rate-limit key: the client IP, proxy-aware.
By default the direct peer address is used. When ``trusted_proxy_count`` is
configured (the number of trusted reverse proxies in front of the app), the
client address is taken from the ``X-Forwarded-For`` chain instead, counting
that many hops back from the right. The header is ignored unless proxies are
trusted, so it cannot be spoofed in a direct-connection deployment.
"""
settings = getattr(getattr(request.app, "state", None), "settings", None)
hops: int = getattr(settings, "trusted_proxy_count", 0) or 0
if hops > 0:
forwarded = request.headers.get("x-forwarded-for", "")
parts = [p.strip() for p in forwarded.split(",") if p.strip()]
if len(parts) >= hops:
return parts[-hops]
return get_remote_address(request)
limiter = Limiter(key_func=_client_identifier)