fix(tradein): bypass rate-limit for authenticated pilots + configurable limit (#655)
Results page hits ~8-10 /api/* endpoints per estimate, tripping the per-IP rate-limit (was 90/60s) and 429'ing trusted pilots behind Caddy basic_auth. Requests carrying X-Authenticated-User (injected by Caddy) now bypass the limiter; anonymous traffic stays throttled. Limit/window are env-configurable (RATE_LIMIT default 90->300, RATE_LIMIT_WINDOW_S=60). Closes #655
This commit is contained in:
parent
6508023c29
commit
747a325428
3 changed files with 79 additions and 6 deletions
|
|
@ -34,6 +34,13 @@ class Settings(BaseSettings):
|
||||||
# Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
|
# Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
|
||||||
redis_url: str = "redis://localhost:6379/0"
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# Rate-limit публичного /api/* (per-IP sliding window). ENV: RATE_LIMIT,
|
||||||
|
# RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за rate_limit_window_s
|
||||||
|
# секунд с одного IP. Аутентифицированный трафик (X-Authenticated-User от
|
||||||
|
# Caddy basic_auth) не лимитируется — см. ratelimit.py (#655).
|
||||||
|
rate_limit: int = 300
|
||||||
|
rate_limit_window_s: float = 60.0
|
||||||
|
|
||||||
# Password for tradein_fdw_reader role — used by backend startup to create/refresh
|
# Password for tradein_fdw_reader role — used by backend startup to create/refresh
|
||||||
# USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
|
# USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
|
||||||
# Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
|
# Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@ from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
# Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP.
|
from app.core.config import settings
|
||||||
RATE_WINDOW = 60.0
|
|
||||||
RATE_LIMIT = 90
|
# Окно и лимит читаются из settings (env RATE_LIMIT / RATE_LIMIT_WINDOW_S):
|
||||||
|
# не более settings.rate_limit запросов за settings.rate_limit_window_s секунд
|
||||||
|
# с одного IP.
|
||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
@ -34,17 +36,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
if not path.startswith("/api/"):
|
if not path.startswith("/api/"):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Аутентифицированный трафик не лимитируем: Caddy basic_auth ставит
|
||||||
|
# X-Authenticated-User на каждый запрос пилота. Лимит — только для
|
||||||
|
# анонимного трафика (заголовок отсутствует/пуст). #655.
|
||||||
|
if request.headers.get("x-authenticated-user"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
bucket = self._hits[ip]
|
bucket = self._hits[ip]
|
||||||
|
|
||||||
# Выкидываем устаревшие отметки за пределами окна.
|
# Выкидываем устаревшие отметки за пределами окна.
|
||||||
cutoff = now - RATE_WINDOW
|
cutoff = now - settings.rate_limit_window_s
|
||||||
while bucket and bucket[0] < cutoff:
|
while bucket and bucket[0] < cutoff:
|
||||||
bucket.popleft()
|
bucket.popleft()
|
||||||
|
|
||||||
if len(bucket) >= RATE_LIMIT:
|
if len(bucket) >= settings.rate_limit:
|
||||||
retry = int(RATE_WINDOW - (now - bucket[0])) + 1
|
retry = int(settings.rate_limit_window_s - (now - bucket[0])) + 1
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
content={"detail": "Слишком много запросов. Попробуйте позже."},
|
content={"detail": "Слишком много запросов. Попробуйте позже."},
|
||||||
|
|
|
||||||
58
tradein-mvp/backend/tests/test_ratelimit.py
Normal file
58
tradein-mvp/backend/tests/test_ratelimit.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Tests for RateLimitMiddleware — per-IP sliding window + auth bypass (#655).
|
||||||
|
|
||||||
|
Тестируем middleware в изоляции на минимальном FastAPI-приложении, чтобы не
|
||||||
|
тянуть тяжёлый app.main (DB / scheduler / sentry). Лимит/окно читаются из
|
||||||
|
settings на каждый dispatch → monkeypatch'им их в маленькие значения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core import config
|
||||||
|
from app.core.ratelimit import RateLimitMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
"""Минимальное приложение с лимитом 3 запроса / 60 с (для быстрого 429)."""
|
||||||
|
monkeypatch.setattr(config.settings, "rate_limit", 3)
|
||||||
|
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RateLimitMiddleware)
|
||||||
|
|
||||||
|
@app.get("/api/v1/ping")
|
||||||
|
def ping() -> dict[str, bool]:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_throttled_past_limit(client):
|
||||||
|
# Первые rate_limit запросов проходят, следующий — 429.
|
||||||
|
for _ in range(3):
|
||||||
|
assert client.get("/api/v1/ping").status_code == 200
|
||||||
|
resp = client.get("/api/v1/ping")
|
||||||
|
assert resp.status_code == 429
|
||||||
|
assert resp.json() == {"detail": "Слишком много запросов. Попробуйте позже."}
|
||||||
|
assert "Retry-After" in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_user_bypasses_limit(client):
|
||||||
|
# X-Authenticated-User (Caddy basic_auth) → лимит не применяется.
|
||||||
|
headers = {"X-Authenticated-User": "pilot@example.com"}
|
||||||
|
for _ in range(10): # сильно больше rate_limit=3
|
||||||
|
assert client.get("/api/v1/ping", headers=headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_auth_header_does_not_bypass(client):
|
||||||
|
# Пустой заголовок не должен no-op'ить лимит (header present но falsy).
|
||||||
|
headers = {"X-Authenticated-User": ""}
|
||||||
|
for _ in range(3):
|
||||||
|
assert client.get("/api/v1/ping", headers=headers).status_code == 200
|
||||||
|
assert client.get("/api/v1/ping", headers=headers).status_code == 429
|
||||||
Loading…
Add table
Reference in a new issue