fix(conferences): rate limit блокировал вход всей конференции сразу
Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026. 1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx, поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей. Проверено на проде: в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту» действовал на весь инстанс разом, а не на клиента. 2. Считались все запросы подряд, включая успешные. Одиннадцатый человек, открывший ссылку на конференцию в течение минуты, получал 429 — и видел «Не удалось найти конференцию» для существующей и активной конференции. Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно. Что изменилось: - адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному клиентом, и лимит обходился бы одним заголовком; - жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ: конференция не найдена или пароль неверен. Именно так выглядит перебор номера, от которого лимит и защищает по ADR-001, п.4; - на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше правдоподобного числа участников одной конференции. Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30 подряд); перебор номера, несуществующий идентификатор и подбор пароля по-прежнему упираются; лимит одного клиента не задевает другого.
This commit is contained in:
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_current_user
|
||||
from core.db import get_session
|
||||
from core.rate_limit import enforce_rate_limit
|
||||
from core.rate_limit import (
|
||||
RATE_LIMIT_MISS_MAX_REQUESTS,
|
||||
RATE_LIMIT_SOFT_MAX_REQUESTS,
|
||||
client_ip,
|
||||
enforce_rate_limit,
|
||||
)
|
||||
from models.user import User
|
||||
from schemas.conferences import (
|
||||
ConferenceCreateIn,
|
||||
@@ -103,10 +108,18 @@ async def resolve_conference(
|
||||
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
|
||||
join/guest-join), а признак закрытости неактуален для мёртвой конференции.
|
||||
"""
|
||||
await enforce_rate_limit(f"resolve:{_client_ip(request)}")
|
||||
ip = client_ip(request)
|
||||
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
|
||||
# вся конференция открывает ссылку в одну минуту.
|
||||
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||||
service = ConferenceService(session)
|
||||
conference = await service.resolve(q)
|
||||
if conference is None:
|
||||
# Жёсткий счётчик — только на промахи: перебор номера конференции
|
||||
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
|
||||
await enforce_rate_limit(
|
||||
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
|
||||
if conference.status == "ended":
|
||||
return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
|
||||
@@ -154,11 +167,15 @@ async def guest_join_conference(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> JoinOut:
|
||||
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
|
||||
await enforce_rate_limit(f"guest_join:{_client_ip(request)}")
|
||||
ip = client_ip(request)
|
||||
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
|
||||
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
|
||||
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||||
service = ConferenceService(session)
|
||||
try:
|
||||
return await service.join_as_guest(conference_id, data=data)
|
||||
except ConferenceNotFoundError as exc:
|
||||
await _count_guest_join_miss(ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
|
||||
) from exc
|
||||
@@ -169,6 +186,9 @@ async def guest_join_conference(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
|
||||
) from exc
|
||||
except InvalidPasswordError as exc:
|
||||
# Подбор пароля закрытой конференции — тот же класс атаки, что и
|
||||
# перебор номера, поэтому считается жёстким счётчиком.
|
||||
await _count_guest_join_miss(ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
|
||||
) from exc
|
||||
@@ -284,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю)."""
|
||||
return request.client.host if request.client else "unknown"
|
||||
async def _count_guest_join_miss(ip: str) -> None:
|
||||
"""Учесть неудачную попытку гостевого входа в жёстком счётчике.
|
||||
|
||||
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
|
||||
(несуществующая конференция и неверный пароль) и обязано бросать 429
|
||||
ровно так же, как обычный `enforce_rate_limit`.
|
||||
"""
|
||||
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)
|
||||
|
||||
Reference in New Issue
Block a user