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:
@@ -20,6 +20,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
|
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
|
||||||
- All other endpoints: limited to `rate_limit_general` req/min.
|
- All other endpoints: limited to `rate_limit_general` req/min.
|
||||||
Returns HTTP 429 with Retry-After header when limit exceeded.
|
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"
|
LOGIN_PATH = "/api/auth/login"
|
||||||
@@ -34,23 +39,48 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _client_ip(request: Request) -> str:
|
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``,
|
Nginx (and, behind it, the Flask client) each add one hop to
|
||||||
``request.client.host``. Required because Nginx and the Flask client
|
X-Forwarded-For. The *leftmost* entries are whatever the original
|
||||||
sit between the tablet and the API; without parsing these headers
|
caller claimed and are trivially spoofed; trust only extends to the
|
||||||
every tablet shares one bucket.
|
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.
|
||||||
"""
|
"""
|
||||||
|
count = settings.trusted_proxy_count
|
||||||
|
if count > 0:
|
||||||
xff = request.headers.get("x-forwarded-for")
|
xff = request.headers.get("x-forwarded-for")
|
||||||
if xff:
|
if xff:
|
||||||
first = xff.split(",")[0].strip()
|
hops = [h.strip() for h in xff.split(",") if h.strip()]
|
||||||
if first:
|
if len(hops) >= count:
|
||||||
return first
|
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")
|
real = request.headers.get("x-real-ip")
|
||||||
if real:
|
if real:
|
||||||
return real.strip()
|
return real.strip()
|
||||||
return request.client.host if request.client else "unknown"
|
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]:
|
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||||
"""Remove timestamps outside the current sliding window."""
|
"""Remove timestamps outside the current sliding window."""
|
||||||
cutoff = now - self.WINDOW_SECONDS
|
cutoff = now - self.WINDOW_SECONDS
|
||||||
@@ -100,7 +130,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
# Check login-specific rate limit
|
# Check login-specific rate limit
|
||||||
if path == self.LOGIN_PATH and request.method == "POST":
|
if path == self.LOGIN_PATH and request.method == "POST":
|
||||||
allowed, retry_after = self._check_rate_limit(
|
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:
|
if not allowed:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -111,7 +142,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Check general rate limit
|
# Check general rate limit
|
||||||
allowed, retry_after = self._check_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:
|
if not allowed:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ class Settings(BaseSettings):
|
|||||||
server_port: int = 8000
|
server_port: int = 8000
|
||||||
server_secret_key: str = "change-this-to-a-random-secret-key"
|
server_secret_key: str = "change-this-to-a-random-secret-key"
|
||||||
server_cors_origins: str = "http://localhost:5000"
|
server_cors_origins: str = "http://localhost:5000"
|
||||||
|
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
|
||||||
|
# in process memory, so this is how RateLimitMiddleware divides the
|
||||||
|
# configured limits to keep the cluster-wide effective limit close to what
|
||||||
|
# was configured instead of multiplying it by N.
|
||||||
|
web_concurrency: int = 1
|
||||||
|
# How many reverse proxies between the client and this API are trusted to
|
||||||
|
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
||||||
|
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
||||||
|
# parsing entirely (only request.client.host is trusted).
|
||||||
|
trusted_proxy_count: int = 1
|
||||||
|
|
||||||
# File Storage
|
# File Storage
|
||||||
upload_dir: str = "uploads"
|
upload_dir: str = "uploads"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
|
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
|
||||||
class _FakeRequest:
|
class _FakeRequest:
|
||||||
@@ -18,12 +21,33 @@ class _FakeRequest:
|
|||||||
self.client.host = host
|
self.client.host = host
|
||||||
|
|
||||||
|
|
||||||
def test_client_ip_uses_x_forwarded_for_first_hop():
|
@pytest.fixture(autouse=True)
|
||||||
|
def default_trusted_proxy_count(monkeypatch):
|
||||||
|
"""Every test in this file assumes the default (1 trusted proxy)."""
|
||||||
|
monkeypatch.setattr(settings, "trusted_proxy_count", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_uses_last_trusted_hop_of_x_forwarded_for():
|
||||||
|
"""With one trusted proxy, the client IP is the LAST element of XFF -
|
||||||
|
|
||||||
|
the one appended by that proxy - not the first, which the caller supplies
|
||||||
|
and can set to anything.
|
||||||
|
"""
|
||||||
req = _FakeRequest(
|
req = _FakeRequest(
|
||||||
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
|
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
|
||||||
host="10.0.0.1",
|
host="10.0.0.1",
|
||||||
)
|
)
|
||||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
assert RateLimitMiddleware._client_ip(req) == "10.0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_ignores_spoofed_first_hop():
|
||||||
|
"""Rotating the client-supplied (leftmost) part of XFF changes nothing:
|
||||||
|
|
||||||
|
the trusted, rightmost hop stays the same, so the bucket key is stable.
|
||||||
|
"""
|
||||||
|
req1 = _FakeRequest(headers={"x-forwarded-for": "1.1.1.1, 10.0.0.2"})
|
||||||
|
req2 = _FakeRequest(headers={"x-forwarded-for": "9.9.9.9, 10.0.0.2"})
|
||||||
|
assert RateLimitMiddleware._client_ip(req1) == RateLimitMiddleware._client_ip(req2) == "10.0.0.2"
|
||||||
|
|
||||||
|
|
||||||
def test_client_ip_strips_whitespace():
|
def test_client_ip_strips_whitespace():
|
||||||
@@ -63,3 +87,55 @@ def test_empty_x_forwarded_for_falls_through():
|
|||||||
host="10.0.0.1",
|
host="10.0.0.1",
|
||||||
)
|
)
|
||||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.42"
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_x_forwarded_for_shorter_than_trusted_proxy_count_falls_back(monkeypatch):
|
||||||
|
"""Two trusted proxies configured, but XFF has only one hop: falls back to
|
||||||
|
request.client.host rather than trusting an under-populated header."""
|
||||||
|
monkeypatch.setattr(settings, "trusted_proxy_count", 2)
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={"x-forwarded-for": "203.0.113.5"},
|
||||||
|
host="172.18.0.9",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "172.18.0.9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxy_count_two_uses_second_to_last_hop(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "trusted_proxy_count", 2)
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={"x-forwarded-for": "1.1.1.1, 203.0.113.5, 10.0.0.2"},
|
||||||
|
host="10.0.0.1",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxy_count_zero_ignores_all_proxy_headers(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "trusted_proxy_count", 0)
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={
|
||||||
|
"x-forwarded-for": "203.0.113.5, 10.0.0.2",
|
||||||
|
"x-real-ip": "203.0.113.99",
|
||||||
|
},
|
||||||
|
host="172.18.0.9",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "172.18.0.9"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveLimit:
|
||||||
|
"""_effective_limit: dividing a configured limit across worker processes."""
|
||||||
|
|
||||||
|
def test_single_worker_keeps_configured_limit(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "web_concurrency", 1)
|
||||||
|
assert RateLimitMiddleware._effective_limit(5) == 5
|
||||||
|
|
||||||
|
def test_divides_and_rounds_up(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "web_concurrency", 4)
|
||||||
|
assert RateLimitMiddleware._effective_limit(5) == 2 # ceil(5/4)
|
||||||
|
|
||||||
|
def test_never_below_one(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "web_concurrency", 100)
|
||||||
|
assert RateLimitMiddleware._effective_limit(5) == 1
|
||||||
|
|
||||||
|
def test_non_positive_concurrency_treated_as_one(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "web_concurrency", 0)
|
||||||
|
assert RateLimitMiddleware._effective_limit(5) == 5
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ class TestRateLimiting:
|
|||||||
retry_val = int(resp.headers["retry-after"])
|
retry_val = int(resp.headers["retry-after"])
|
||||||
assert retry_val >= 1
|
assert retry_val >= 1
|
||||||
|
|
||||||
|
async def test_login_rate_limit_survives_rotated_x_forwarded_for(
|
||||||
|
self, client: AsyncClient
|
||||||
|
):
|
||||||
|
"""A client rotating the spoofable (first) hop of XFF cannot dodge the
|
||||||
|
login rate limit: with one trusted proxy (the default), the real
|
||||||
|
client IP is the last hop, which stays constant."""
|
||||||
|
for i in range(5):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": f"rotated{i}", "password": "pass"},
|
||||||
|
headers={
|
||||||
|
"X-Forwarded-For": f"{i}.{i}.{i}.{i}, 198.51.100.42",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "rotated-overflow", "password": "pass"},
|
||||||
|
headers={"X-Forwarded-For": "9.9.9.9, 198.51.100.42"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
class TestPathTraversal:
|
class TestPathTraversal:
|
||||||
"""Path traversal protection in file endpoints."""
|
"""Path traversal protection in file endpoints."""
|
||||||
|
|||||||
Reference in New Issue
Block a user