"""Rate limit на основе Redis `INCR`+`EXPIRE` для публичных (без auth) эндпоинтов. Используется резолвом конференций и гостевым входом (`api/conferences.py`) — эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции (см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP). ## Два счётчика вместо одного (0.0.18) Прежняя схема считала ВСЕ запросы подряд с лимитом 10/мин. На нагрузочном тесте 31.07.2026 это остановило вход целой конференции: люди открывали ссылку одновременно, одиннадцатый получал 429, а фронтенд показывал «Не удалось найти конференцию» — при том что конференция существовала и была активна. Смысл лимита по ADR-001 — защита от ПЕРЕБОРА номера конференции. Перебор — это поток промахов; легитимный участник открывает существующую ссылку и получает успех. Поэтому: - `RATE_LIMIT_MISS_MAX_REQUESTS` — жёсткий счётчик промахов (конференция не найдена, неверный пароль). Именно он защищает от перебора, и он остался прежним — 10/мин; - `RATE_LIMIT_SOFT_MAX_REQUESTS` — мягкий потолок на общее число обращений с одного адреса. Нужен только против тупого флуда; рассчитан так, чтобы сотня человек из офиса за общим NAT спокойно зашла в одну конференцию. """ from fastapi import HTTPException, Request, status from core.redis import redis_client RATE_LIMIT_MAX_REQUESTS = 10 RATE_LIMIT_WINDOW_SECONDS = 60 # Промахи: перебор номера/ссылки или подбор пароля конференции. RATE_LIMIT_MISS_MAX_REQUESTS = 10 # Общий поток с одного IP. Офис за общим NAT — это ОДИН адрес, поэтому потолок # заведомо выше правдоподобного числа участников одной конференции. RATE_LIMIT_SOFT_MAX_REQUESTS = 300 def client_ip(request: Request) -> str: """IP клиента для rate limit — с учётом того, что backend стоит за nginx. `request.client.host` — это TCP-peer, то есть контейнер nginx, один и тот же для всех пользователей. С ним лимит превращался в общий на весь инстанс: на проде в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`, и десяти запросов в минуту хватало, чтобы заблокировать вход всем сразу. Берём `X-Real-IP`, а НЕ первый элемент `X-Forwarded-For`: nginx заполняет его через `$proxy_add_x_forwarded_for`, то есть ДОПИСЫВАЕТ к присланному клиентом. Первый элемент там подделывается одним заголовком, и лимит обходился бы тривиально. `X-Real-IP` nginx всегда перезаписывает своим `$remote_addr` (см. deploy/nginx/nginx.conf.template). """ real_ip = request.headers.get("x-real-ip") if real_ip: return real_ip.strip() return request.client.host if request.client else "unknown" async def enforce_rate_limit( key: str, *, max_requests: int = RATE_LIMIT_MAX_REQUESTS, window_seconds: int = RATE_LIMIT_WINDOW_SECONDS, ) -> None: """Увеличить счётчик запросов по ключу; бросить 429, если лимит превышен. `INCR` атомарно создаёт ключ со значением 1, если его ещё не было; TTL выставляется только при первом инкременте в окне (когда счётчик стал равен 1) — иначе окно продлевалось бы при каждом запросе и лимит никогда бы не истекал. """ redis_key = f"rate_limit:{key}" current = await redis_client.incr(redis_key) if current == 1: await redis_client.expire(redis_key, window_seconds) if current > max_requests: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate_limit_exceeded" )