bc3901da68
app/rate_limit.py: an in-process sliding-window limiter (single-worker backend, so no Redis needed -- flagged for if that changes). /register and /login are capped at 10 attempts / minute / client IP (X-Forwarded-For aware), returning 429 + Retry-After past that. Blunts credential brute-force, which the backend had no protection against. - conftest.py resets the limiter between tests (every TestClient request looks like one IP). - tests/test_rate_limit.py: threshold, shared bucket, per-key window. 77 passed. CORS reviewed while here: already an explicit origin allowlist (settings.CORS_ORIGINS), not "*" -- and the Godot client isn't a browser so it doesn't apply there anyway. Left as-is. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
52 lines
1.6 KiB
Python
52 lines
1.6 KiB
Python
"""The auth endpoints are rate-limited per client IP."""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import rate_limit
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_limiter():
|
|
# This module tests the limiter itself, so start each test from empty
|
|
# and don't let conftest's between-test reset interfere mid-test.
|
|
rate_limit.reset_all()
|
|
yield
|
|
rate_limit.reset_all()
|
|
|
|
|
|
def test_login_is_limited_after_the_threshold():
|
|
client = TestClient(app)
|
|
codes = []
|
|
for i in range(15):
|
|
r = client.post("/api/auth/login", data={"username": f"nobody{i}", "password": "x"})
|
|
codes.append(r.status_code)
|
|
# First 10 get through to the (failing) auth check; the rest are 429.
|
|
assert codes[:10] == [401] * 10
|
|
assert 429 in codes[10:]
|
|
assert codes[-1] == 429
|
|
|
|
|
|
def test_register_shares_the_same_bucket():
|
|
client = TestClient(app)
|
|
# Burn the budget on login...
|
|
for i in range(10):
|
|
client.post("/api/auth/login", data={"username": f"x{i}", "password": "y"})
|
|
# ...register from the same IP is now blocked too.
|
|
r = client.post("/api/auth/register", json={
|
|
"username": "rl_user", "email": "rl@e.com",
|
|
"password": "secret123", "date_of_birth": "1990-01-01",
|
|
})
|
|
assert r.status_code == 429
|
|
assert "Retry-After" in r.headers
|
|
|
|
|
|
def test_limiter_window_is_per_key():
|
|
limiter = rate_limit.SlidingWindowLimiter(max_hits=2, window_seconds=60)
|
|
limiter.check("a")
|
|
limiter.check("a")
|
|
with pytest.raises(Exception):
|
|
limiter.check("a")
|
|
limiter.check("b") # different key, unaffected
|