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:
@@ -573,17 +573,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
|
||||
assert response.json()["detail"] == "not_found"
|
||||
|
||||
|
||||
async def test_resolve_is_rate_limited_after_10_requests_per_minute(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
def _ip_headers() -> dict[str, str]:
|
||||
"""Уникальный `X-Real-IP` на каждый тест.
|
||||
|
||||
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
|
||||
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
|
||||
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
|
||||
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
|
||||
"""
|
||||
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
|
||||
|
||||
|
||||
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
|
||||
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
|
||||
headers = _ip_headers()
|
||||
for _ in range(10):
|
||||
response = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"})
|
||||
response = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
limited = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"})
|
||||
limited = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
|
||||
)
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
async def test_successful_resolves_are_not_limited_by_miss_counter(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
|
||||
|
||||
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
|
||||
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
|
||||
для существующей и активной конференции.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
headers = _ip_headers()
|
||||
|
||||
for _ in range(50):
|
||||
response = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
|
||||
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
|
||||
|
||||
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
|
||||
работать. До исправления ключ был общим на весь инстанс.
|
||||
"""
|
||||
first, second = _ip_headers(), _ip_headers()
|
||||
for _ in range(11):
|
||||
await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
|
||||
)
|
||||
|
||||
exhausted = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
|
||||
)
|
||||
assert exhausted.status_code == 429
|
||||
|
||||
other = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
|
||||
)
|
||||
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
|
||||
|
||||
|
||||
# --- Вход зарегистрированным пользователем ---------------------------------------
|
||||
|
||||
|
||||
@@ -835,21 +893,69 @@ async def test_guest_join_ended_conference_returns_410(
|
||||
assert response.json()["detail"] == "conference_ended"
|
||||
|
||||
|
||||
async def test_guest_join_is_rate_limited_after_10_requests_per_minute(
|
||||
async def test_guest_join_allows_a_whole_conference_to_enter(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Успешные гостевые входы не упираются в лимит промахов.
|
||||
|
||||
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
|
||||
пускало внутрь именно это ограничение — счётчик не различал легитимный
|
||||
массовый вход и перебор.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
headers = _ip_headers()
|
||||
|
||||
for i in range(30):
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||
json={"display_name": f"Guest {i}"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
|
||||
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
|
||||
headers = _ip_headers()
|
||||
missing_id = uuid.uuid4()
|
||||
|
||||
for _ in range(10):
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{missing_id}/guest-join",
|
||||
json={"display_name": "Bruteforce"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
limited = await client.post(
|
||||
f"/api/v1/conferences/{missing_id}/guest-join",
|
||||
json={"display_name": "Bruteforce"},
|
||||
headers=headers,
|
||||
)
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
async def test_guest_join_wrong_password_is_rate_limited(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
|
||||
conference = await _make_conference(db_session, is_closed=True, password="right-password")
|
||||
await db_session.commit()
|
||||
headers = _ip_headers()
|
||||
|
||||
for _ in range(10):
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||
json={"display_name": "Repeat Guest"},
|
||||
json={"display_name": "Guesser", "password": "wrong"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 403
|
||||
|
||||
limited = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Repeat Guest"}
|
||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||
json={"display_name": "Guesser", "password": "wrong"},
|
||||
headers=headers,
|
||||
)
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
Reference in New Issue
Block a user