All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m43s
- shared-secret Caddy↔tradein-backend: X-Internal-Auth-Secret (secrets.compare_digest, constant-time), fail-open до провижининга TRADEIN_INTERNAL_AUTH_SECRET (WARNING на старте); при заданном секрете подделка X-Authenticated-User изнутри docker-сети даёт 401 - rate-limiter: per-user/per-IP ключ вместо освобождения всех authenticated; client IP из rightmost XFF (один доверенный прокси Caddy, который аппендит real IP); leftmost XFF клиент-контролируем и игнорируется - Caddyfile: убран глобальный log_credentials (писал обратимые base64 basic-auth пароли в логи; glitchtip-forwarder деградирует gracefully) - тесты: secret-gate (401/200/backward-compat/wrong/no-user) + per-user rate-limit + XFF-spoof нейтрализован Провижининг (human, порядок критичен): значение секрета СНАЧАЛА в окружение Caddy-стека (+recreate caddy), ПОТОМ в рантайм-окружение tradein-backend (+restart). Обратный порядок = fail-closed 401 на всё приложение. Значение секрета в репо не попадает. Refs #2213
96 lines
4.7 KiB
Python
96 lines
4.7 KiB
Python
"""Простой in-memory rate-limiter для публичного API.
|
||
|
||
Sliding window. Достаточно для одного backend-инстанса (MVP).
|
||
Защищает `/api/v1/*` от абуза — estimate/geocode/suggest публичны.
|
||
|
||
Ключ лимита (#2213):
|
||
- authenticated username (заголовок X-Authenticated-User от Caddy basic_auth) —
|
||
per-user лимит = rate_limit × rate_limit_authenticated_multiplier;
|
||
- для анонимов — client IP, лимит = rate_limit.
|
||
|
||
Раньше весь authenticated-трафик освобождался целиком (#655). Это было плацебо:
|
||
заголовок X-Authenticated-User клиент-контролируем (его ставит Caddy, но на общей
|
||
docker-сети запрос мог прийти и мимо Caddy). Теперь лимит применяется всегда, но
|
||
per-user порог щедрый — живой пилот его не достигнет.
|
||
|
||
Допущение по IP (#2213): перед backend ровно ОДИН доверенный прокси (Caddy).
|
||
Значит честный клиентский IP = ПОСЛЕДНИЙ (rightmost) элемент X-Forwarded-For,
|
||
добавленный самим Caddy. Leftmost-элементы клиент может подделать (X-Forwarded-For:
|
||
"1.2.3.4" в исходном запросе), поэтому брать leftmost — дыра (тривиальный обход
|
||
per-IP лимита сменой фейкового первого хопа). Если XFF пуст — remote_addr
|
||
соединения.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from collections import defaultdict, deque
|
||
|
||
from fastapi import Request
|
||
from fastapi.responses import JSONResponse
|
||
from starlette.middleware.base import BaseHTTPMiddleware
|
||
|
||
from app.core.config import settings
|
||
|
||
|
||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||
"""Sliding-window rate limit на /api/v1/*. Health и статика — без лимита."""
|
||
|
||
def __init__(self, app) -> None: # type: ignore[no-untyped-def]
|
||
super().__init__(app)
|
||
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||
|
||
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
||
path = request.url.path
|
||
# Лимитируем только API; health и прочее — пропускаем.
|
||
if not path.startswith("/api/"):
|
||
return await call_next(request)
|
||
|
||
# Ключ и порог: per-user для аутентифицированных, per-IP для анонимов.
|
||
username = request.headers.get("x-authenticated-user")
|
||
if username:
|
||
key = f"user:{username}"
|
||
limit = settings.rate_limit * settings.rate_limit_authenticated_multiplier
|
||
else:
|
||
key = f"ip:{_client_ip(request)}"
|
||
limit = settings.rate_limit
|
||
|
||
now = time.monotonic()
|
||
bucket = self._hits[key]
|
||
|
||
# Выкидываем устаревшие отметки за пределами окна.
|
||
cutoff = now - settings.rate_limit_window_s
|
||
while bucket and bucket[0] < cutoff:
|
||
bucket.popleft()
|
||
|
||
if len(bucket) >= limit:
|
||
retry = int(settings.rate_limit_window_s - (now - bucket[0])) + 1
|
||
return JSONResponse(
|
||
status_code=429,
|
||
content={"detail": "Слишком много запросов. Попробуйте позже."},
|
||
headers={"Retry-After": str(retry)},
|
||
)
|
||
|
||
bucket.append(now)
|
||
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка.
|
||
if len(self._hits) > 10000:
|
||
for k in [k for k, v in self._hits.items() if not v]:
|
||
del self._hits[k]
|
||
|
||
return await call_next(request)
|
||
|
||
|
||
def _client_ip(request: Request) -> str:
|
||
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.
|
||
|
||
Caddy добавляет свой хоп в конец X-Forwarded-For, поэтому берём ПОСЛЕДНИЙ
|
||
(rightmost) элемент — его нельзя подделать с клиента. Leftmost элементы
|
||
клиент-контролируемы и для ключа лимита НЕ используются. Пустой XFF (прямое
|
||
соединение) → remote_addr.
|
||
"""
|
||
xff = request.headers.get("x-forwarded-for")
|
||
if xff:
|
||
parts = [p.strip() for p in xff.split(",") if p.strip()]
|
||
if parts:
|
||
return parts[-1]
|
||
return request.client.host if request.client else "unknown"
|