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
+78 -2
View File
@@ -1,5 +1,8 @@
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
import pytest
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from src.backend.config import settings
class _FakeRequest:
@@ -18,12 +21,33 @@ class _FakeRequest:
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(
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
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():
@@ -63,3 +87,55 @@ def test_empty_x_forwarded_for_falls_through():
host="10.0.0.1",
)
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
+23
View File
@@ -101,6 +101,29 @@ class TestRateLimiting:
retry_val = int(resp.headers["retry-after"])
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:
"""Path traversal protection in file endpoints."""