From 747a32542873fafcb7f942be75319cdff1c7733d Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 29 May 2026 18:30:53 +0300 Subject: [PATCH] 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 --- tradein-mvp/backend/app/core/config.py | 7 +++ tradein-mvp/backend/app/core/ratelimit.py | 20 ++++--- tradein-mvp/backend/tests/test_ratelimit.py | 58 +++++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_ratelimit.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 86e3296b..2a772e01 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -34,6 +34,13 @@ class Settings(BaseSettings): # Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL. 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 # USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server). # Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev). diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index 9ab432b4..d7655f24 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -16,9 +16,11 @@ from fastapi import Request from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware -# Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP. -RATE_WINDOW = 60.0 -RATE_LIMIT = 90 +from app.core.config import settings + +# Окно и лимит читаются из settings (env RATE_LIMIT / RATE_LIMIT_WINDOW_S): +# не более settings.rate_limit запросов за settings.rate_limit_window_s секунд +# с одного IP. class RateLimitMiddleware(BaseHTTPMiddleware): @@ -34,17 +36,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware): if not path.startswith("/api/"): 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) now = time.monotonic() bucket = self._hits[ip] # Выкидываем устаревшие отметки за пределами окна. - cutoff = now - RATE_WINDOW + cutoff = now - settings.rate_limit_window_s while bucket and bucket[0] < cutoff: bucket.popleft() - if len(bucket) >= RATE_LIMIT: - retry = int(RATE_WINDOW - (now - bucket[0])) + 1 + if len(bucket) >= settings.rate_limit: + retry = int(settings.rate_limit_window_s - (now - bucket[0])) + 1 return JSONResponse( status_code=429, content={"detail": "Слишком много запросов. Попробуйте позже."}, diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py new file mode 100644 index 00000000..bc891851 --- /dev/null +++ b/tradein-mvp/backend/tests/test_ratelimit.py @@ -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 -- 2.45.3