gendesign/tradein-mvp/backend/app/core/ratelimit.py
bot-backend 7377fb61e5 feat(tradein/support): веб-чат поддержки поверх Telegram support-моста
Сайт закрыт Caddy basic_auth, тред привязывается к X-Authenticated-User (нет
анонимов). Новые web_support_threads/web_support_messages (миграция 187) —
отдельно от tg_support_* (186): у веб-клиента нет Telegram chat_id, смешение
identity-схем в одной таблице потребовало бы NULLABLE chat_id/username и XOR
CHECK-ограничений без реальной выгоды (обоснование в самой миграции).

API (app/api/v1/support.py, /api/v1/trade-in/support/*):
  POST /messages  — отправка (sendMessage-зеркало в топик, "[С САЙТА] user: ...")
  GET  /messages   — polling своего треда (?since=id)
  GET  /unread     — счётчик непрочитанного
  POST /read       — отметить прочитанным
Тред резолвится ИСКЛЮЧИТЕЛЬНО по username — нет параметра, которым можно
адресовать чужой тред (структурная защита от IDOR, не только access-check).

bridge.py: _handle_group_reply получил ветку резолва reply в web-тред (после
существующего tg-резолва, без изменения Telegram-пути) — оператор отвечает
одинаково, вне зависимости от канала клиента.

Rate-limit: новый SlidingWindowLimiter (ratelimit.py) — 12 msg/60s per user,
жёстче общего RateLimitMiddleware (общий бот-токен, флуд одного клиента иначе
бьёт по доставке всем).

Security: этот процесс (app/main.py) теперь тоже зовёт Telegram Bot API
напрямую (раньше — только изолированный tgbot_main.py) — реплицированы обе
защиты токена: httpx-INFO подавлен, include_local_variables=False +
redact_telegram_bot_token в sentry before_send.

Бот не сконфигурирован (пустой TELEGRAM_BOT_TOKEN/chat_id) → 503, не 500.
2026-07-26 22:58:54 +03:00

129 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Простой 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)
class SlidingWindowLimiter:
"""Reusable in-process sliding-window limiter — тот же алгоритм, что
`RateLimitMiddleware.dispatch` (deque per key, отбрасываем протухшие метки),
вынесенный для feature-специфичных лимитов, которые нужны ЖЁСТЧЕ общего
per-user порога `/api/*` (напр. отправка сообщений в веб-чат поддержки,
#tgsupport-web — общий лимит 300/60с не спасёт support-топик от заливки
одним флудящим клиентом, т.к. Telegram Bot API токен общий на всех).
Не заменяет `RateLimitMiddleware` (тот остаётся общим гейтом на `/api/*`),
а даёт отдельный, более узкий бюджет для конкретного эндпоинта/действия.
"""
def __init__(self, limit: int, window_s: float) -> None:
self._limit = limit
self._window_s = window_s
self._hits: dict[str, deque[float]] = defaultdict(deque)
def check(self, key: str) -> float | None:
"""Регистрирует попытку под *key*. Возвращает None, если она уложилась в
лимит (и учтена), иначе — сколько секунд ждать до следующей попытки."""
now = time.monotonic()
bucket = self._hits[key]
cutoff = now - self._window_s
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= self._limit:
return self._window_s - (now - bucket[0])
bucket.append(now)
return None
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"