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>
64 lines
2.3 KiB
Python
64 lines
2.3 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from starlette.requests import Request
|
|
|
|
from porchlight.rate_limit import _client_identifier
|
|
from tests.conftest import get_csrf_token
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_password_login_rate_limited(client: AsyncClient) -> None:
|
|
"""After 5 failed attempts, the 6th should be rate-limited."""
|
|
token = await get_csrf_token(client)
|
|
|
|
for _ in range(5):
|
|
await client.post(
|
|
"/login/password",
|
|
data={"username": "nobody", "password": "wrong"},
|
|
headers={"X-CSRF-Token": token},
|
|
)
|
|
|
|
response = await client.post(
|
|
"/login/password",
|
|
data={"username": "nobody", "password": "wrong"},
|
|
headers={"X-CSRF-Token": token},
|
|
)
|
|
|
|
assert response.status_code == 429
|
|
|
|
|
|
def _make_request(headers: dict[str, str], client_host: str, trusted_proxy_count: int) -> Request:
|
|
scope = {
|
|
"type": "http",
|
|
"headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
|
|
"client": (client_host, 12345),
|
|
"app": SimpleNamespace(
|
|
state=SimpleNamespace(settings=SimpleNamespace(trusted_proxy_count=trusted_proxy_count))
|
|
),
|
|
}
|
|
return Request(scope)
|
|
|
|
|
|
def test_client_identifier_ignores_forwarded_when_no_trusted_proxy() -> None:
|
|
req = _make_request({"x-forwarded-for": "203.0.113.9"}, "127.0.0.1", trusted_proxy_count=0)
|
|
# Spoofable header must be ignored when no proxy is trusted.
|
|
assert _client_identifier(req) == "127.0.0.1"
|
|
|
|
|
|
def test_client_identifier_uses_forwarded_with_one_trusted_proxy() -> None:
|
|
req = _make_request({"x-forwarded-for": "203.0.113.9"}, "10.0.0.1", trusted_proxy_count=1)
|
|
assert _client_identifier(req) == "203.0.113.9"
|
|
|
|
|
|
def test_client_identifier_picks_correct_hop_with_two_trusted_proxies() -> None:
|
|
req = _make_request({"x-forwarded-for": "client, proxyA, proxyB"}, "10.0.0.1", trusted_proxy_count=2)
|
|
# Two trusted proxies appended their peers; the client as seen by the
|
|
# outermost trusted proxy is the 2nd-from-right entry.
|
|
assert _client_identifier(req) == "proxyA"
|
|
|
|
|
|
def test_client_identifier_falls_back_when_forwarded_missing() -> None:
|
|
req = _make_request({}, "10.0.0.1", trusted_proxy_count=1)
|
|
assert _client_identifier(req) == "10.0.0.1"
|