Skip to content

Commit aeac6e5

Browse files
fix: honor X-Forwarded-For for rate limits behind trusted proxies (#221)
Parse client IPs from X-Forwarded-For only when the immediate peer is listed in TRUSTED_PROXY_IPS, and enable ProxyHeadersMiddleware in that case.
1 parent 9b54d86 commit aeac6e5

4 files changed

Lines changed: 115 additions & 14 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ DB_NAME=
2626
# Domain for production (used by Caddy for TLS)
2727
DOMAIN=
2828

29+
# Comma-separated reverse-proxy peer IPs for X-Forwarded-For (e.g. 127.0.0.1,::1)
30+
# TRUSTED_PROXY_IPS=
31+
2932
# Set to 0 to disable CSRF checks (not recommended in production)
3033
# CSRF_ENABLED=1
3134

main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require_unauthenticated_client,
2222
)
2323
from utils.core.auth import refresh_token_is_persistent, set_auth_cookies
24+
from utils.core.rate_limit import get_trusted_proxy_hosts
2425
from utils.core.csrf import (
2526
CSRF_COOKIE_NAME,
2627
UNSAFE_HTTP_METHODS,
@@ -63,6 +64,12 @@ async def lifespan(app: FastAPI):
6364
# Initialize the FastAPI app
6465
app: FastAPI = FastAPI(lifespan=lifespan)
6566

67+
trusted_proxy_hosts = get_trusted_proxy_hosts()
68+
if trusted_proxy_hosts:
69+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
70+
71+
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=list(trusted_proxy_hosts))
72+
6673
# Mount static files (e.g., CSS, JS) and initialize Jinja2 templates
6774
app.mount("/static", StaticFiles(directory="static"), name="static")
6875
templates = Jinja2Templates(directory="templates")

tests/utils/test_rate_limit.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import importlib
22
import time
3-
from unittest.mock import patch
3+
from unittest.mock import MagicMock, patch
44

55
import utils.core.rate_limit as rate_limit_module
6-
from utils.core.rate_limit import RateLimitWindow
6+
from utils.core.rate_limit import RateLimitWindow, get_client_ip
77

88

99
# ---------------------------------------------------------------------------
@@ -162,3 +162,53 @@ def test_module_limiters_honor_env_configuration(monkeypatch):
162162
assert rate_limit_module.forgot_password_email_limiter.window_seconds == 91
163163

164164
importlib.reload(rate_limit_module)
165+
166+
167+
# ---------------------------------------------------------------------------
168+
# Client IP behind trusted proxies
169+
# ---------------------------------------------------------------------------
170+
171+
172+
def _make_request(
173+
peer_host: str | None,
174+
headers: dict[str, str] | None = None,
175+
) -> MagicMock:
176+
request = MagicMock()
177+
if peer_host is None:
178+
request.client = None
179+
else:
180+
request.client = MagicMock(host=peer_host)
181+
request.headers = headers or {}
182+
return request
183+
184+
185+
def test_get_client_ip_ignores_x_forwarded_for_without_trusted_proxy(monkeypatch):
186+
monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False)
187+
request = _make_request(
188+
"203.0.113.10",
189+
headers={"x-forwarded-for": "198.51.100.7"},
190+
)
191+
assert get_client_ip(request) == "203.0.113.10"
192+
193+
194+
def test_get_client_ip_uses_x_forwarded_for_from_trusted_proxy(monkeypatch):
195+
monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1")
196+
request = _make_request(
197+
"127.0.0.1",
198+
headers={"x-forwarded-for": "198.51.100.7, 127.0.0.1"},
199+
)
200+
assert get_client_ip(request) == "198.51.100.7"
201+
202+
203+
def test_get_client_ip_falls_back_to_peer_when_x_forwarded_for_missing(
204+
monkeypatch,
205+
):
206+
monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1")
207+
request = _make_request("127.0.0.1")
208+
assert get_client_ip(request) == "127.0.0.1"
209+
210+
211+
def test_get_client_ip_unknown_when_peer_missing(monkeypatch):
212+
monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False)
213+
request = _make_request(None)
214+
assert get_client_ip(request) == "unknown"

utils/core/rate_limit.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import time
33
import threading
44
import math
5+
import ipaddress
56
from logging import getLogger
67
from typing import Tuple
78

@@ -13,6 +14,58 @@
1314
load_dotenv()
1415

1516

17+
def get_trusted_proxy_hosts() -> tuple[str, ...]:
18+
"""
19+
Return trusted reverse-proxy peer addresses from TRUSTED_PROXY_IPS.
20+
21+
Comma-separated list, e.g. ``127.0.0.1,::1,172.18.0.2``.
22+
"""
23+
raw = os.environ.get("TRUSTED_PROXY_IPS", "")
24+
return tuple(part.strip() for part in raw.split(",") if part.strip())
25+
26+
27+
def _peer_host(request: Request) -> str | None:
28+
if request.client is None:
29+
return None
30+
return request.client.host
31+
32+
33+
def _parse_forwarded_client_ip(forwarded_for: str) -> str | None:
34+
for candidate in forwarded_for.split(","):
35+
value = candidate.strip()
36+
if not value:
37+
continue
38+
try:
39+
return str(ipaddress.ip_address(value))
40+
except ValueError:
41+
continue
42+
return None
43+
44+
45+
def get_client_ip(request: Request) -> str:
46+
"""
47+
Extract the client IP for rate limiting.
48+
49+
When the immediate peer is listed in TRUSTED_PROXY_IPS, the left-most
50+
valid address in X-Forwarded-For is treated as the client. Otherwise only
51+
request.client.host is used so clients cannot spoof their IP.
52+
"""
53+
peer = _peer_host(request)
54+
if peer is None:
55+
return "unknown"
56+
57+
trusted_hosts = get_trusted_proxy_hosts()
58+
if peer not in trusted_hosts:
59+
return peer
60+
61+
forwarded_for = request.headers.get("x-forwarded-for")
62+
if not forwarded_for:
63+
return peer
64+
65+
client_ip = _parse_forwarded_client_ip(forwarded_for)
66+
return client_ip or peer
67+
68+
1669
class RateLimitWindow:
1770
"""
1871
Thread-safe sliding window rate limiter with bounded in-memory state.
@@ -172,18 +225,6 @@ def clear_all_rate_limiters() -> None:
172225
# --- Dependency helpers ---
173226

174227

175-
def get_client_ip(request: Request) -> str:
176-
"""
177-
Extract client IP from the request.
178-
179-
Uses request.client.host only. Does NOT trust X-Forwarded-For
180-
because this app has no trusted-proxy middleware.
181-
"""
182-
if request.client:
183-
return request.client.host
184-
return "unknown"
185-
186-
187228
def _enforce_rate_limit(limiter: RateLimitWindow, key: str, scope: str) -> int:
188229
"""
189230
Check the limiter for the given key. If limited, raise RateLimitError.

0 commit comments

Comments
 (0)