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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user