[HIGH] tradein/auth: bcrypt блокирует единственный event loop — поток логинов кладёт весь API, и он же сейчас единственный настоящий ограничитель темпа #2665

Closed
opened 2026-08-05 17:47:40 +00:00 by bot-backend · 1 comment
Collaborator

Найдено при реализации #2571 (PR #2663). Не блокировало ту работу, но это отдельная и более серьёзная проблема, чем сама задача про перебор.

Что происходит

verify_password вызывается синхронно внутри async def. bcrypt с cost 12 считается порядка 250 мс и это чистый CPU — на время проверки пароля единственный event loop backend'а заблокирован целиком.

Два следствия, и они тянут в противоположные стороны.

Плохое. Поток запросов на вход кладёт не только вход, а весь API: пока крутится bcrypt, ни один другой запрос не обслуживается. То есть неаутентифицированный запрос к публичному эндпоинту (а форма входа публично доступна с момента cutover'а, см. #2571) позволяет любому желающему деградировать весь трейд-ин без единого валидного крéда. Это дешевле и эффективнее, чем перебор паролей.

Неожиданно полезное. Ровно эта блокировка сегодня и есть единственный настоящий потолок темпа логинов — примерно 4 попытки в секунду, потому что они физически сериализуются на CPU. Замедление, добавленное в #2571, таким потолком не является: await asyncio.sleep отпускает событийный цикл, поэтому атакующий с сотнями одновременных соединений отспит их параллельно. Это латентность одного ответа, а не ограничение темпа.

Почему это надо чинить одним заходом

Очевидная починка — унести bcrypt в пул потоков — снимет единственный работающий throttle и сделает перебор быстрее, чем он есть сейчас. То есть по отдельности каждая половина делает хуже:

  • унести bcrypt, не добавив настоящий потолок → перебор ускоряется;
  • добавить потолок, не унося bcrypt → отказ в обслуживании всего API остаётся.

Поэтому: сначала (или одновременно) настоящий потолок темпа, потом вынос bcrypt из событийного цикла.

Варианты настоящего потолка

Ни один не бесплатен, выбор за тобой:

  1. Ограничение на периметре (Caddy) — лимит запросов к POST /trade-in/api/v1/auth/login по адресу и глобально. Дёшево, не трогает код, но глобальный лимит бьёт и по легитимным пользователям в час пик.
  2. Ограничение одновременных проверок пароля (семафор на N параллельных bcrypt) — сохраняет сериализацию сознательно, вместо того чтобы получать её случайно. Очередь сверх N отклоняется с 429.
  3. Отказ вместо задержки при превышении глобального порога на имя — даёт настоящий потолок, но возвращает ровно тот вектор отказа в обслуживании против владельца имени, от которого #2571 сознательно уходила.

Оговорка про доказательность

Числа (cost 12, ~250 мс, ~4 попытки в секунду) взяты из чтения кода и типичных характеристик bcrypt, на этом проде не замерены. Прежде чем выбирать вариант, стоит замерить фактическое время verify_password в контейнере и фактический потолок темпа — от этого зависит, насколько срочно.

Связано: #2571, #2549, #2558.

Найдено при реализации #2571 (PR #2663). Не блокировало ту работу, но это отдельная и более серьёзная проблема, чем сама задача про перебор. ## Что происходит `verify_password` вызывается **синхронно внутри `async def`**. bcrypt с cost 12 считается порядка 250 мс и это чистый CPU — на время проверки пароля единственный event loop backend'а **заблокирован целиком**. Два следствия, и они тянут в противоположные стороны. **Плохое.** Поток запросов на вход кладёт не только вход, а **весь API**: пока крутится bcrypt, ни один другой запрос не обслуживается. То есть неаутентифицированный запрос к публичному эндпоинту (а форма входа публично доступна с момента cutover'а, см. #2571) позволяет любому желающему деградировать весь трейд-ин без единого валидного крéда. Это дешевле и эффективнее, чем перебор паролей. **Неожиданно полезное.** Ровно эта блокировка сегодня и есть **единственный настоящий потолок темпа логинов** — примерно 4 попытки в секунду, потому что они физически сериализуются на CPU. Замедление, добавленное в #2571, таким потолком не является: `await asyncio.sleep` отпускает событийный цикл, поэтому атакующий с сотнями одновременных соединений отспит их параллельно. Это латентность одного ответа, а не ограничение темпа. ## Почему это надо чинить одним заходом Очевидная починка — унести bcrypt в пул потоков — **снимет единственный работающий throttle** и сделает перебор быстрее, чем он есть сейчас. То есть по отдельности каждая половина делает хуже: - унести bcrypt, не добавив настоящий потолок → перебор ускоряется; - добавить потолок, не унося bcrypt → отказ в обслуживании всего API остаётся. Поэтому: сначала (или одновременно) настоящий потолок темпа, потом вынос bcrypt из событийного цикла. ## Варианты настоящего потолка Ни один не бесплатен, выбор за тобой: 1. **Ограничение на периметре** (Caddy) — лимит запросов к `POST /trade-in/api/v1/auth/login` по адресу и глобально. Дёшево, не трогает код, но глобальный лимит бьёт и по легитимным пользователям в час пик. 2. **Ограничение одновременных проверок пароля** (семафор на N параллельных bcrypt) — сохраняет сериализацию сознательно, вместо того чтобы получать её случайно. Очередь сверх N отклоняется с 429. 3. **Отказ вместо задержки** при превышении глобального порога на имя — даёт настоящий потолок, но возвращает ровно тот вектор отказа в обслуживании против владельца имени, от которого #2571 сознательно уходила. ## Оговорка про доказательность Числа (cost 12, ~250 мс, ~4 попытки в секунду) взяты из чтения кода и типичных характеристик bcrypt, **на этом проде не замерены**. Прежде чем выбирать вариант, стоит замерить фактическое время `verify_password` в контейнере и фактический потолок темпа — от этого зависит, насколько срочно. Связано: #2571, #2549, #2558.
Author
Collaborator

Working on this in PR #2712.

Working on this in PR #2712.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2665
No description provided.