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:
@@ -3,15 +3,59 @@
|
||||
Используется резолвом конференций и гостевым входом (`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, status
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user