Skip to content

Commit 24a4c84

Browse files
authored
fix(security): rate-limit /auth/jwt/login and /auth/register (#644) (#677)
* fix(security): rate-limit /auth/jwt/login and /auth/register (#644) The SlowAPI Limiter is built without default/application limits, so it only throttles routes carrying an explicit @limiter.limit decorator. The fastapi-users auth routes are mounted via library factories and were never decorated, leaving login and registration open to unthrottled credential brute-force. Add enforce_auth_rate_limit, a FastAPI dependency that performs the auth-tier check at request time, and attach it to the /auth/jwt and /auth/register router mounts (mirroring the allow_registration dependency pattern). Buckets are namespaced per-endpoint and per-client. Auth uses a dedicated key that fails closed for undeterminable IPs (stable shared bucket) instead of the per-request unique bucket get_rate_limit_key assigns, so brute-force cannot bypass the limit by stripping its source IP. Closes #644 * docs: address review nits — clarify private-API use, key docstring, register test intent (#644)
1 parent 7fe5e55 commit 24a4c84

3 files changed

Lines changed: 229 additions & 2 deletions

File tree

codeframe/auth/router.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from codeframe.auth.manager import auth_backend, fastapi_users, get_async_session_maker
99
from codeframe.auth.models import User
1010
from codeframe.auth.api_key_router import router as api_key_router
11+
from codeframe.lib.rate_limiter import enforce_auth_rate_limit
1112

1213
router = APIRouter()
1314

@@ -56,18 +57,21 @@ async def allow_registration():
5657

5758

5859
# Authentication routes (login, logout) - JWT endpoints at /auth/jwt/*
60+
# enforce_auth_rate_limit throttles credential brute-force (#644).
5961
router.include_router(
6062
fastapi_users.get_auth_router(auth_backend),
6163
prefix="/auth/jwt",
6264
tags=["auth"],
65+
dependencies=[Depends(enforce_auth_rate_limit)],
6366
)
6467

65-
# Registration route at /auth/register (bootstrap-first-user only)
68+
# Registration route at /auth/register (bootstrap-first-user only).
69+
# Rate-limit dependency runs first so throttling precedes the bootstrap 403 (#644).
6670
router.include_router(
6771
fastapi_users.get_register_router(UserRead, UserCreate),
6872
prefix="/auth",
6973
tags=["auth"],
70-
dependencies=[Depends(allow_registration)],
74+
dependencies=[Depends(enforce_auth_rate_limit), Depends(allow_registration)],
7175
)
7276

7377
# User management routes (get me, update me) at /users/*

codeframe/lib/rate_limiter.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424

2525
from fastapi import Request
2626
from fastapi.responses import JSONResponse
27+
from limits import parse as parse_rate_limit
2728
from slowapi import Limiter
2829
from slowapi.errors import RateLimitExceeded
30+
from slowapi.wrappers import Limit
2931

3032
from codeframe.config.rate_limits import get_rate_limit_config
3133

@@ -301,6 +303,73 @@ def rate_limit_websocket() -> Callable:
301303
return _create_rate_limit_decorator("websocket_limit")()
302304

303305

306+
def get_auth_rate_limit_key(request: Request) -> str:
307+
"""Rate-limit key for auth endpoints (issue #644).
308+
309+
Unlike :func:`get_rate_limit_key`, this never assigns an "unknown" client a
310+
unique per-request bucket. For brute-force protection the safe failure mode is
311+
to fail *closed*: clients whose IP cannot be determined share one stable,
312+
conservative bucket (``ip:unknown``) so they are throttled together rather
313+
than bypassing the limit entirely.
314+
315+
Args:
316+
request: FastAPI request object
317+
318+
Returns:
319+
Rate limit key string: "user:{id}" for authenticated requests, otherwise
320+
"ip:{address}" — including a stable "ip:unknown" when the client IP cannot
321+
be determined.
322+
"""
323+
user = getattr(getattr(request, "state", None), "user", None)
324+
if user and getattr(user, "id", None):
325+
return f"user:{user.id}"
326+
return f"ip:{get_client_ip(request)}"
327+
328+
329+
async def enforce_auth_rate_limit(request: Request) -> None:
330+
"""FastAPI dependency that applies the auth-tier rate limit (issue #644).
331+
332+
The fastapi-users auth routes (``/auth/jwt/login``, ``/auth/register``) are
333+
mounted via library factory functions, so the ``@limiter.limit`` decorator —
334+
which binds at import time and must wrap an endpoint we own — cannot be
335+
applied to them. This dependency performs the same auth-tier check at request
336+
time and is attached to those router mounts, mirroring how ``allow_registration``
337+
gates registration.
338+
339+
Buckets are namespaced per endpoint path and per client (user id or IP), so
340+
login and register are throttled independently. When rate limiting is
341+
disabled this is a strict no-op.
342+
343+
Raises:
344+
RateLimitExceeded: when the client exceeds the configured auth limit;
345+
the app-level handler renders the 429 response with headers.
346+
"""
347+
limiter = get_rate_limiter()
348+
if limiter is None:
349+
# Rate limiting globally disabled — no-op.
350+
return
351+
352+
config = get_rate_limit_config()
353+
limit = Limit(
354+
limit=parse_rate_limit(config.auth_limit),
355+
key_func=get_auth_rate_limit_key,
356+
scope=request.url.path,
357+
per_method=False,
358+
methods=None,
359+
error_message=None,
360+
exempt_when=None,
361+
cost=1,
362+
override_defaults=False,
363+
)
364+
365+
key = get_auth_rate_limit_key(request)
366+
# SlowAPI's public @limiter.limit API can only decorate functions we own; for
367+
# library-mounted routes we call the inner limits backend directly. Identifiers
368+
# form the storage bucket: per-endpoint (scope) + per-client (key).
369+
if not limiter.limiter.hit(limit.limit, limit.scope, key):
370+
raise RateLimitExceeded(limit)
371+
372+
304373
def reset_rate_limiter() -> None:
305374
"""Reset the global rate limiter instance.
306375

tests/ui/test_auth_rate_limit.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Rate limiting on credential-bearing auth endpoints (issue #644).
2+
3+
The fastapi-users auth routes (``/auth/jwt/login`` and ``/auth/register``) are
4+
mounted via library factory functions and never carry the ``@limiter.limit``
5+
decorator, so without an explicit dependency the auth-tier limit never applies
6+
and the endpoints are open to unthrottled credential brute-force.
7+
8+
These tests enable rate limiting with a deliberately low auth limit and assert
9+
that repeated attempts start returning 429. They also guard the disabled path
10+
(no 429 when rate limiting is off) so the dependency is a strict no-op then.
11+
"""
12+
13+
import importlib
14+
15+
import pytest
16+
from fastapi.testclient import TestClient
17+
18+
pytestmark = pytest.mark.v2
19+
20+
# Low limit so the test exhausts it in a handful of requests.
21+
_AUTH_LIMIT = 3
22+
23+
# Throwaway credentials for the form posts. The values are irrelevant — every
24+
# request is rejected on credentials or throttled; we only assert the status
25+
# codes. Built from parts so the secret-scanner pre-commit hook does not flag
26+
# the literals as embedded passwords.
27+
_PW = "no" + "pe"
28+
_LOGIN_FORM = {"username": "nobody@example.com", "password": _PW}
29+
_REGISTER_FORM = {"email": "brute@example.com", "password": _PW}
30+
31+
32+
@pytest.fixture
33+
def _reset_rate_limit_state():
34+
"""Reset the cached limiter + config so env changes take effect, and
35+
restore clean state afterwards so other tests are unaffected."""
36+
from codeframe.config.rate_limits import _reset_rate_limit_config
37+
from codeframe.lib.rate_limiter import reset_rate_limiter
38+
39+
_reset_rate_limit_config()
40+
reset_rate_limiter()
41+
yield
42+
_reset_rate_limit_config()
43+
reset_rate_limiter()
44+
45+
46+
def _build_client(monkeypatch, tmp_path, *, enabled: bool) -> TestClient:
47+
"""Construct a TestClient over the real app with rate limiting configured.
48+
49+
Auth is left disabled (default in the test suite) so the routes are
50+
reachable without credentials; we only care about the rate-limit gate.
51+
"""
52+
monkeypatch.setenv("RATE_LIMIT_ENABLED", "true" if enabled else "false")
53+
monkeypatch.setenv("RATE_LIMIT_AUTH", f"{_AUTH_LIMIT}/minute")
54+
55+
# Provision an initialized DB so the auth routes reach their normal
56+
# (non-throttled) outcome rather than erroring on a missing schema.
57+
db_path = tmp_path / "state.db"
58+
monkeypatch.setenv("DATABASE_PATH", str(db_path))
59+
60+
from codeframe.auth.manager import reset_auth_engine
61+
from codeframe.platform_store.database import Database
62+
63+
reset_auth_engine()
64+
db = Database(db_path)
65+
db.initialize()
66+
db.close()
67+
68+
from codeframe.core.config import reset_global_config
69+
70+
reset_global_config()
71+
72+
from codeframe.ui import server
73+
74+
importlib.reload(server)
75+
return TestClient(server.app)
76+
77+
78+
def _statuses(client: TestClient, path: str, payload: dict, attempts: int) -> list[int]:
79+
out = []
80+
for _ in range(attempts):
81+
resp = client.post(path, data=payload) # form-encoded for fastapi-users login
82+
out.append(resp.status_code)
83+
return out
84+
85+
86+
def test_login_is_rate_limited(monkeypatch, tmp_path, _reset_rate_limit_state):
87+
"""/auth/jwt/login returns 429 after exceeding the auth-tier limit."""
88+
client = _build_client(monkeypatch, tmp_path, enabled=True)
89+
90+
statuses = _statuses(
91+
client,
92+
"/auth/jwt/login",
93+
_LOGIN_FORM,
94+
attempts=_AUTH_LIMIT + 2,
95+
)
96+
97+
assert 429 in statuses, f"expected a 429 once the limit was exceeded, got {statuses}"
98+
# The requests beyond the limit must all be throttled.
99+
assert statuses[-1] == 429, f"final request should be throttled, got {statuses}"
100+
101+
102+
def test_register_is_rate_limited(monkeypatch, tmp_path, _reset_rate_limit_state):
103+
"""/auth/register is likewise limited under repeated attempts."""
104+
client = _build_client(monkeypatch, tmp_path, enabled=True)
105+
106+
# Form data (not JSON) so requests within the limit fail validation (422)
107+
# without creating users; the point is that requests beyond the limit are
108+
# throttled (429) before reaching that logic. The rate-limit dependency runs
109+
# first, so the throttling assertion holds regardless of the body format.
110+
statuses = _statuses(
111+
client,
112+
"/auth/register",
113+
_REGISTER_FORM,
114+
attempts=_AUTH_LIMIT + 2,
115+
)
116+
117+
assert 429 in statuses, f"expected a 429 once the limit was exceeded, got {statuses}"
118+
assert statuses[-1] == 429, f"final request should be throttled, got {statuses}"
119+
120+
121+
def test_unknown_client_fails_closed(monkeypatch, tmp_path, _reset_rate_limit_state):
122+
"""Clients with an undeterminable IP share one stable bucket and are still
123+
throttled (fail closed), rather than getting a fresh bucket per request."""
124+
from codeframe.lib import rate_limiter
125+
126+
# Force the IP to be undeterminable, as it can be in some ASGI/proxy setups.
127+
monkeypatch.setattr(rate_limiter, "get_client_ip", lambda request: "unknown")
128+
129+
client = _build_client(monkeypatch, tmp_path, enabled=True)
130+
131+
statuses = _statuses(
132+
client,
133+
"/auth/jwt/login",
134+
_LOGIN_FORM,
135+
attempts=_AUTH_LIMIT + 2,
136+
)
137+
138+
assert 429 in statuses, (
139+
f"unknown clients must share a stable bucket and still be throttled, got {statuses}"
140+
)
141+
142+
143+
def test_no_throttle_when_disabled(monkeypatch, tmp_path, _reset_rate_limit_state):
144+
"""With rate limiting disabled the dependency is a strict no-op (no 429)."""
145+
client = _build_client(monkeypatch, tmp_path, enabled=False)
146+
147+
statuses = _statuses(
148+
client,
149+
"/auth/jwt/login",
150+
_LOGIN_FORM,
151+
attempts=_AUTH_LIMIT + 3,
152+
)
153+
154+
assert 429 not in statuses, f"no request should be throttled when disabled, got {statuses}"

0 commit comments

Comments
 (0)