[HIGH] tradein/auth: потолок сверок бьёт и по своим — один IP на 5 запросах в секунду закрывает вход всем, бессрочно #2714

Closed
opened 2026-08-06 08:53:54 +00:00 by bot-backend · 2 comments
Collaborator

Найдено глубоким ревью PR #2712 (issue #2665). Тот PR убирает отказ в обслуживании всего API от флуда входа — это главное и оно сделано. Остаётся вырожденный случай: сам вход становится недоступен всем, включая легитимных пользователей.

Воспроизведено

Пользователь с верным паролем, 20 попыток подряд во время флуда:

200 = 0     429 = 20     401 = 0

Цена атаки, посчитанная по живым числам прода

  • потолок сверок пароля: 1 / 0.275с = 3.6 в секунду (bcrypt cost 12, медиана 275 мс, замерено в контейнере на 13 живых хешах);
  • глобальный RateLimitMiddleware (rate_limit=300, окно 60 с) разрешает 5 запросов в секунду с одного IP;
  • _LOGIN_LIMITER (5 на пару имя+IP) и счётчик неудач из #2571 обходятся ротацией имён.

Итого: один IP, 5 запросов в секунду, ни одного валидного пароля — и вход закрыт для всех, бессрочно.

Это НЕ регресс — важно для приоритета

На main тот же трафик стоит 5 × 275 мс = 141% CPU событийного цикла, то есть кладёт не вход, а весь API. PR #2712 сузил поражаемую площадь с «всё» до «только вход». Так что это ухудшение относительно идеала, а не относительно того, что было.

Но формулировки в коде обещают больше, чем есть: тест test_auth_api.py:846 утверждает assert len(attempts) >= 2 под комментарием «вход не заблокирован совсем» — а речь там про попытки атакующего, не про то, что живой человек войдёт. Прозой это читается как второе.

Что предлагается

Справедливость слотов по ключу. Не давать одному ключу (IP) занимать больше max_inflight // 2 слотов: счётчик по ключу, декремент на том же освобождении слота. Инвариант «одна точка выноса = одна точка учёта», на котором держится вся конструкция #2712, при этом сохраняется — счётчик живёт там же.

Порядка восьми строк плюс аргумент key в сигнатуре verify_password_bounded.

Оговорка: ключом может быть только IP, а он подделывается за прокси и разделяется за NAT. То есть это поднимает стоимость атаки, но не закрывает её принципиально; принципиально закрывает только доказательство работы или второй фактор, и это отдельный разговор.

Тест, который стоит завести даже до фикса

Честный тест на текущее поведение — «во время флуда логин с верным паролем даёт 429» — с формулировкой, фиксирующей это как известный потолок, а не сюрприз. Сейчас это поведение не покрыто ничем, и следующий читатель кода узнает о нём от пользователя.

Связано: #2665, #2712, #2571.

Найдено глубоким ревью PR #2712 (issue #2665). Тот PR убирает отказ в обслуживании **всего API** от флуда входа — это главное и оно сделано. Остаётся вырожденный случай: **сам вход становится недоступен всем, включая легитимных пользователей**. ## Воспроизведено Пользователь с **верным** паролем, 20 попыток подряд во время флуда: ``` 200 = 0 429 = 20 401 = 0 ``` ## Цена атаки, посчитанная по живым числам прода - потолок сверок пароля: `1 / 0.275с` = **3.6 в секунду** (bcrypt cost 12, медиана 275 мс, замерено в контейнере на 13 живых хешах); - глобальный `RateLimitMiddleware` (`rate_limit=300`, окно 60 с) разрешает **5 запросов в секунду с одного IP**; - `_LOGIN_LIMITER` (5 на пару имя+IP) и счётчик неудач из #2571 обходятся **ротацией имён**. Итого: **один IP, 5 запросов в секунду, ни одного валидного пароля — и вход закрыт для всех, бессрочно.** ## Это НЕ регресс — важно для приоритета На `main` тот же трафик стоит 5 × 275 мс = **141% CPU событийного цикла**, то есть кладёт не вход, а **весь API**. PR #2712 сузил поражаемую площадь с «всё» до «только вход». Так что это ухудшение относительно идеала, а не относительно того, что было. Но формулировки в коде обещают больше, чем есть: тест `test_auth_api.py:846` утверждает `assert len(attempts) >= 2` под комментарием «вход не заблокирован совсем» — а речь там про попытки **атакующего**, не про то, что живой человек войдёт. Прозой это читается как второе. ## Что предлагается **Справедливость слотов по ключу.** Не давать одному ключу (IP) занимать больше `max_inflight // 2` слотов: счётчик по ключу, декремент на том же освобождении слота. Инвариант «одна точка выноса = одна точка учёта», на котором держится вся конструкция #2712, при этом сохраняется — счётчик живёт там же. Порядка восьми строк плюс аргумент `key` в сигнатуре `verify_password_bounded`. Оговорка: ключом может быть только IP, а он подделывается за прокси и разделяется за NAT. То есть это поднимает стоимость атаки, но не закрывает её принципиально; принципиально закрывает только доказательство работы или второй фактор, и это отдельный разговор. ## Тест, который стоит завести даже до фикса Честный тест на текущее поведение — «во время флуда логин с верным паролем даёт 429» — с формулировкой, фиксирующей это как **известный потолок, а не сюрприз**. Сейчас это поведение не покрыто ничем, и следующий читатель кода узнает о нём от пользователя. Связано: #2665, #2712, #2571.
Author
Collaborator

Модель угрозы в теле задачи НЕВЕРНА — поправка

Я написал: «один IP, 5 запросов в секунду, ни одного валидного пароля — и вход закрыт для всех, бессрочно». Замеры на проде (изнутри контейнера, имена заведомо несуществующие, проба входа с другого адреса, 20 попыток) это не подтверждают:

флуд с одного адреса          проба входа с другого
5 req/s (потолок для одного IP)   18 × 401,  2 × 429   ← дверь ОТКРЫТА
20 req/s                           4 × 401, 16 × 429
~100 req/s, всплеск 542 запроса    6 × 401, 14 × 429

Неверно на трёх счётах:

  1. 5 запросов в секунду не держат очередь занятой. Дверь открыта в 18 случаях из 20. Мой расчёт («5 req/s против потолка 3.6 сверки/с — значит очередь всегда полна») игнорировал, что запросы не выстраиваются идеально: часть приходит, когда слот уже освободился.
  2. «Бессрочно» требует нескольких адресов. Плотный флуд один адрес выдаёт всплеском примерно на 15 секунд, дальше его затыкает RateLimitMiddleware (300 запросов на 60 секунд) на остаток минуты. С одного IP непрерывного давления не получается.
  3. 15 из 20 отказов в исходном замере давал не потолок сверок, а соседний _LOGIN_LIMITER (5 попыток на пару имя+IP). Я приписал потолку чужие отказы, потому что смотрел на код ответа, не разбирая его происхождение. После фикса эти 15 остаются — они к флуду отношения не имеют.

Дефект при этом реален

При плотном флуде легитимный вход отказывался в 16 случаях из 20, и от потолка сверок — в 5 из них. После правки от потолка сверок — ноль.

То есть чинить было что, но приоритет ниже заявленного мной: это не «вход закрыт всем одним дешёвым запросом», а «под плотной атакой с нескольких адресов легитимный пользователь может не пройти».

Отдельно: как едва не получился неправильный замер

Первый прогон автора дал заметно более благополучную картину и оказался артефактом закрытой петли — флудер ждал ответа на каждый свой запрос и тем самым сам себя тормозил, так что нагрузки, которую он должен был создавать, не было вовсе. Переделано на открытую петлю; числа выше уже с ней.

Это стоит запомнить как отдельный приём: нагрузочная проба, которая ждёт собственный ответ, меряет не нагрузку, а собственную задержку. Симптом — результат выглядит слишком хорошо.

## Модель угрозы в теле задачи НЕВЕРНА — поправка Я написал: «один IP, 5 запросов в секунду, ни одного валидного пароля — и вход закрыт для всех, бессрочно». Замеры на проде (изнутри контейнера, имена заведомо несуществующие, проба входа с другого адреса, 20 попыток) это не подтверждают: ``` флуд с одного адреса проба входа с другого 5 req/s (потолок для одного IP) 18 × 401, 2 × 429 ← дверь ОТКРЫТА 20 req/s 4 × 401, 16 × 429 ~100 req/s, всплеск 542 запроса 6 × 401, 14 × 429 ``` Неверно на трёх счётах: 1. **5 запросов в секунду не держат очередь занятой.** Дверь открыта в 18 случаях из 20. Мой расчёт («5 req/s против потолка 3.6 сверки/с — значит очередь всегда полна») игнорировал, что запросы не выстраиваются идеально: часть приходит, когда слот уже освободился. 2. **«Бессрочно» требует нескольких адресов.** Плотный флуд один адрес выдаёт всплеском примерно на 15 секунд, дальше его затыкает `RateLimitMiddleware` (300 запросов на 60 секунд) на остаток минуты. С одного IP непрерывного давления не получается. 3. **15 из 20 отказов в исходном замере давал не потолок сверок, а соседний `_LOGIN_LIMITER`** (5 попыток на пару имя+IP). Я приписал потолку чужие отказы, потому что смотрел на код ответа, не разбирая его происхождение. После фикса эти 15 остаются — они к флуду отношения не имеют. ## Дефект при этом реален При плотном флуде легитимный вход отказывался **в 16 случаях из 20**, и от потолка сверок — в 5 из них. После правки от потолка сверок — **ноль**. То есть чинить было что, но приоритет ниже заявленного мной: это не «вход закрыт всем одним дешёвым запросом», а «под плотной атакой с нескольких адресов легитимный пользователь может не пройти». ## Отдельно: как едва не получился неправильный замер Первый прогон автора дал заметно более благополучную картину и оказался **артефактом закрытой петли** — флудер ждал ответа на каждый свой запрос и тем самым сам себя тормозил, так что нагрузки, которую он должен был создавать, не было вовсе. Переделано на открытую петлю; числа выше уже с ней. Это стоит запомнить как отдельный приём: **нагрузочная проба, которая ждёт собственный ответ, меряет не нагрузку, а собственную задержку.** Симптом — результат выглядит слишком хорошо.
Author
Collaborator

Поправка к поправке: моё опровержение было неверным, исходная постановка ближе к правде

Выше я написал, что модель угрозы в теле задачи неверна и «дверь открыта в 18 случаях из 20». Глубокое ревью PR #2717 это не воспроизвело и показало обратное.

Почему 5 запросов в секунду — не случайное число, а худший случай

  • RateLimitMiddleware пропускает 300 запросов за 60 секунд, то есть ровно 5 в секунду бессрочно (в 76-секундном прогоне флуд получил один отказ мидлвари из 383);
  • сервис-рейт сверок пароля = 1 / 0.276 с = 3.6 в секунду (bcrypt cost 12, замерено на проде: 281/274/271/279/276 мс).

5 больше 3.6 — значит очередь переполнена постоянно, а не 15 секунд. Атакующему не нужен плотный всплеск: оптимальная стратегия — держаться ровно под бюджетом мидлвари. Плотный флуд, наоборот, самоубивается (при 20 req/s первый отказ мидлвари на 15-й секунде, при 100 req/s — на 3-й), и именно поэтому «15 секунд» из моей поправки верно, но относится к неоптимальной для атакующего стратегии.

Замер на верной модели (открытая петля, проба с джиттером и разными живыми именами, чтобы не мерить соседний лимитер):

до правки, окно мидлвари не насыщено   18×200 / 18×429   (36 попыток)
до правки, окно насыщено (t=70с)        6×200 / 10×429   ← 62% отказов
после правки, оба случая                36/36 и 16/16 × 200

Как получился мой ошибочный вывод

Второй артефакт замера подряд, и другой природы, чем первый. Последовательная проба самосинхронизируется: её следующий запрос уходит через микросекунды после того, как освободился её же слот, опережая очередной запрос флудера на 200 мс. Ревьюер воспроизвёл это: без джиттера получилось 20/20 успешных входов на заведомо сломанном коде — ровно та цифра, которую дал первый замер. С джиттером — 37%.

То есть первый замер был испорчен закрытой петлёй флудера, второй — самосинхронизацией пробы. Оба раза результат выглядел слишком хорошо.

Итог

Постановка этой задачи права по механизму и по бессрочности. Ошибалась только в «всем и на 100%»: на деле около 60% легитимных входов отказывается, и бессрочно. Это существеннее, чем я написал в поправке, и по сути возвращает исходный приоритет.

Верным в моей поправке осталось одно: 15 из 20 отказов в самом первом замере действительно давал соседний лимитер на пару имя+IP, а не потолок сверок.

Побочно, из того же ревью

Проверено на проде: /trade-in/api/* публичен — секция стоит до подключения пилотного basic_auth, и пробник отдаёт 401 от приложения, а не запрос пароля. То есть форма входа видна из интернета. Плюс подтверждено, что подделать адрес заголовком нельзя, пока хоп один: запрос через Caddy с подставленным X-Forwarded-For записался в аудит с реальным адресом пира.

## Поправка к поправке: моё опровержение было неверным, исходная постановка ближе к правде Выше я написал, что модель угрозы в теле задачи неверна и «дверь открыта в 18 случаях из 20». Глубокое ревью PR #2717 это **не воспроизвело** и показало обратное. ### Почему 5 запросов в секунду — не случайное число, а худший случай - `RateLimitMiddleware` пропускает 300 запросов за 60 секунд, то есть ровно **5 в секунду бессрочно** (в 76-секундном прогоне флуд получил один отказ мидлвари из 383); - сервис-рейт сверок пароля = 1 / 0.276 с = **3.6 в секунду** (bcrypt cost 12, замерено на проде: 281/274/271/279/276 мс). **5 больше 3.6 — значит очередь переполнена постоянно, а не 15 секунд.** Атакующему не нужен плотный всплеск: оптимальная стратегия — держаться ровно под бюджетом мидлвари. Плотный флуд, наоборот, самоубивается (при 20 req/s первый отказ мидлвари на 15-й секунде, при 100 req/s — на 3-й), и именно поэтому «15 секунд» из моей поправки верно, но относится к неоптимальной для атакующего стратегии. Замер на верной модели (открытая петля, проба с джиттером и разными живыми именами, чтобы не мерить соседний лимитер): ``` до правки, окно мидлвари не насыщено 18×200 / 18×429 (36 попыток) до правки, окно насыщено (t=70с) 6×200 / 10×429 ← 62% отказов после правки, оба случая 36/36 и 16/16 × 200 ``` ### Как получился мой ошибочный вывод Второй артефакт замера подряд, и другой природы, чем первый. **Последовательная проба самосинхронизируется**: её следующий запрос уходит через микросекунды после того, как освободился её же слот, опережая очередной запрос флудера на 200 мс. Ревьюер воспроизвёл это: без джиттера получилось **20/20 успешных входов на заведомо сломанном коде** — ровно та цифра, которую дал первый замер. С джиттером — 37%. То есть первый замер был испорчен закрытой петлёй флудера, второй — самосинхронизацией пробы. Оба раза результат выглядел слишком хорошо. ### Итог Постановка этой задачи права по механизму и по бессрочности. Ошибалась только в «всем и на 100%»: на деле **около 60% легитимных входов отказывается, и бессрочно**. Это существеннее, чем я написал в поправке, и по сути возвращает исходный приоритет. Верным в моей поправке осталось одно: 15 из 20 отказов в самом первом замере действительно давал соседний лимитер на пару имя+IP, а не потолок сверок. ### Побочно, из того же ревью Проверено на проде: **`/trade-in/api/*` публичен** — секция стоит до подключения пилотного basic_auth, и пробник отдаёт 401 от приложения, а не запрос пароля. То есть форма входа видна из интернета. Плюс подтверждено, что подделать адрес заголовком нельзя, пока хоп один: запрос через Caddy с подставленным `X-Forwarded-For` записался в аудит с реальным адресом пира.
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#2714
No description provided.