fix(tradein/auth): доля слотов сверки пароля на адрес — потолок перестаёт бить по своим (#2714) #2717

Merged
bot-backend merged 2 commits from fix/2714-verify-slot-fairness into main 2026-08-06 10:42:39 +00:00
Collaborator

Что чинится

Потолок темпа из #2665 держал слоты сверки пароля общим котлом: флуд занимал все четыре, и легитимный вход с верным паролем получал 429.

Замер ДО и ПОСЛЕ (проба с чужого адреса, открытая петля, джиттер 0.2–0.8с, разные живые имена — чтобы мерить потолок сверок, а не _LOGIN_LIMITER):

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

Потолок сверок больше не отказывает легитимному входу ни разу. Перебор при этом режется как и раньше (флуд получает 429 на всё сверх своей доли).

Как

Ключ (у единственного вызывающего — IP клиента) не берёт больше max_inflight // 2 слотов. Счётчик по ключу живёт в самой verify_password_bounded и отдаётся тем же _release_verify_slot, что и общий, — инвариант «одна точка выноса = одна точка учёта», на котором держится #2712, не делится надвое. Запись словаря удаляется на нуле, поэтому его размер ограничен числом слотов, а не числом виденных адресов.

Новой настройки нет сознательно: это доля, а не величина. Подкручивать её нечем — 100% возвращает ровно то поведение, ради отказа от которого правка написана.

Модель угрозы: постановка #2714 верна

RateLimitMiddleware пропускает 300 запросов за 60с — то есть ровно 5 в секунду бессрочно (в 76-секундном прогоне флуд получил один отказ мидлвари из 383). Сервис-рейт сверок = 1/0.276 = 3.6/с. Пять больше трёх с половиной → очередь переполнена постоянно, а не всплеском. Атакующему всплеск и не нужен: оптимально держаться ровно под бюджетом мидлвари, а плотный флуд самоубивается (20 req/s — первый отказ мидлвари на 15.0с, 100 req/s — на 3.0с).

Дефект = ~60% отказов легитимному входу, зато бессрочно, с одного адреса, без единого валидного пароля.

Мой первый замер давал обратное — две ошибки методики, обе в сторону «мягче»
  1. Закрытая петля флудера: флудер ждал свой ответ и потому сам себя тормозил — очередь не переполнялась.
  2. Самосинхронизация пробы: последовательная проба без пауз уходит через микросекунды после освобождения СВОЕГО же слота и опережает флудера на 200 мс. На заведомо сломанном коде это даёт 20/20 успешных — ровно та цифра, которую я и принёс. С джиттером — 37%.

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

Граница применимости — честно

Ключом может быть только IP, а IP:

  • подделывается, если между нами и клиентом окажется ещё один прокси (сейчас доверенный хоп ровно один — Caddy, _client_ip берёт правый элемент XFF; появится второй — ключ станет клиентским вводом);
  • разделяется: за NAT вся организация приходит с одного адреса;
  • ротируется: ботнет даёт столько ключей, сколько нужно.

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

Цена: соседям по NAT стало хуже

Проба с ТОГО ЖЕ адреса, что и флуд, 36 попыток:

флуд до после
2 req/s 36/36 (100%) 34/36 (94%)
3 req/s 34/36 (94%) 25/36 (69%)
5 req/s 15/36 (42%) 11/36 (31%)

Детерминированно: три одновременных входа с одного офисного адреса в окне ~282 мс — третий получает 429 при наполовину пустом пуле (до правки требовалось пять). Порог, за которым сосед перестаёт входить, падает с ~14 до ~7 req/s. Размен положительный (с чужих адресов 37% → 100%) и записан в docstring verify_password_bounded — не только здесь.

Тесты

  • test_flood_from_one_ip_leaves_login_open_for_another_ip — API-уровень, мерит заявленное: во время флуда с одного адреса вход с другого проходит. На коде до правки красный.
  • test_one_key_cannot_take_more_than_its_share — доля слотов на ключ + явно закреплённая цена по NAT (третий с того же адреса получает 429 при _verify_inflight == 2 из 4).
  • test_bounded_frees_slot_when_pool_refuses_worksubmit бросил, слот отдан синхронно (единственная строка без покрытия; мутант «старый код» краснит тест и сторож).
  • test_cancelling_queued_work_returns_the_key_slot — отмена ЕЩЁ НЕ НАЧАТОЙ работы (другая ветка future, чем отмена начатой).
  • test_bounded_frees_slot_when_verify_raises — исключение внутри сверки.
  • test_per_key_cap_never_rounds_down_to_zero — пол max(1, …): при очереди в 1 доля не округляется в ноль.
  • _no_leaked_password_verify_slots (autouse в tests/conftest.py) — «слотов занято 0» после каждого теста репозитория: счётчик глобальный, а pytest-asyncio даёт цикл на тест, так что известная ловушка except RuntimeError копилась бы молча и роняла не тот тест.
  • Сторож дефолтов несёт литералы (max_inflight == 4, _per_key_slot_cap() == 2), а не арифметику от настройки.
  • Поправлена формулировка в тесте темпа: «вход не заблокирован совсем» — это про попытки атакующего, не про живого пользователя.

Test plan

  • pytest tests/test_password.py tests/test_auth_api.py — 65 passed
  • полный прогон tradein-mvp/backend — 3645 passed, 9 skipped
  • ruff 0.7.4 (пин pre-commit) check + format
  • прод-верификация после деплоя

Refs #2714, #2665, #2712

## Что чинится Потолок темпа из #2665 держал слоты сверки пароля общим котлом: флуд занимал все четыре, и легитимный вход с **верным** паролем получал 429. **Замер ДО и ПОСЛЕ** (проба с чужого адреса, открытая петля, джиттер 0.2–0.8с, разные живые имена — чтобы мерить потолок сверок, а не `_LOGIN_LIMITER`): ``` до правки, окно мидлвари не насыщено 18×200 / 18×429 до правки, окно насыщено (t=70с) 6×200 / 10×429 ← 62% отказов после правки 36/36 и 16/16 × 200 ``` Потолок сверок больше не отказывает легитимному входу ни разу. Перебор при этом режется как и раньше (флуд получает 429 на всё сверх своей доли). ## Как Ключ (у единственного вызывающего — IP клиента) не берёт больше `max_inflight // 2` слотов. Счётчик по ключу живёт в самой `verify_password_bounded` и отдаётся тем же `_release_verify_slot`, что и общий, — инвариант «одна точка выноса = одна точка учёта», на котором держится #2712, не делится надвое. Запись словаря удаляется на нуле, поэтому его размер ограничен числом слотов, а не числом виденных адресов. Новой настройки нет сознательно: это **доля**, а не величина. Подкручивать её нечем — 100% возвращает ровно то поведение, ради отказа от которого правка написана. ## Модель угрозы: постановка #2714 верна `RateLimitMiddleware` пропускает 300 запросов за 60с — то есть **ровно 5 в секунду бессрочно** (в 76-секундном прогоне флуд получил один отказ мидлвари из 383). Сервис-рейт сверок = 1/0.276 = **3.6/с**. Пять больше трёх с половиной → очередь переполнена **постоянно**, а не всплеском. Атакующему всплеск и не нужен: оптимально держаться ровно под бюджетом мидлвари, а плотный флуд самоубивается (20 req/s — первый отказ мидлвари на 15.0с, 100 req/s — на 3.0с). Дефект = ~60% отказов легитимному входу, зато **бессрочно**, с одного адреса, без единого валидного пароля. <details> <summary>Мой первый замер давал обратное — две ошибки методики, обе в сторону «мягче»</summary> 1. **Закрытая петля флудера**: флудер ждал свой ответ и потому сам себя тормозил — очередь не переполнялась. 2. **Самосинхронизация пробы**: последовательная проба без пауз уходит через микросекунды после освобождения СВОЕГО же слота и опережает флудера на 200 мс. На заведомо сломанном коде это даёт **20/20 успешных** — ровно та цифра, которую я и принёс. С джиттером — 37%. Вывод для протокола: пробу нельзя гнать вплотную, иначе меришь её собственный ритм, а не доступность. </details> ## Граница применимости — честно Ключом может быть только IP, а IP: - **подделывается**, если между нами и клиентом окажется ещё один прокси (сейчас доверенный хоп ровно один — Caddy, `_client_ip` берёт правый элемент XFF; появится второй — ключ станет клиентским вводом); - **разделяется**: за NAT вся организация приходит с одного адреса; - **ротируется**: ботнет даёт столько ключей, сколько нужно. Правка **поднимает стоимость атаки**, но **не закрывает её**. Принципиально закрывают только доказательство работы на входе или второй фактор — отдельный разговор и отдельная цена. ### Цена: соседям по NAT стало хуже Проба с ТОГО ЖЕ адреса, что и флуд, 36 попыток: | флуд | до | после | |---|---|---| | 2 req/s | 36/36 (100%) | 34/36 (94%) | | 3 req/s | 34/36 (94%) | 25/36 (69%) | | 5 req/s | 15/36 (42%) | 11/36 (31%) | Детерминированно: три одновременных входа с одного офисного адреса в окне ~282 мс — третий получает 429 при наполовину пустом пуле (до правки требовалось пять). Порог, за которым сосед перестаёт входить, падает с ~14 до ~7 req/s. Размен положительный (с чужих адресов 37% → 100%) и записан в docstring `verify_password_bounded` — не только здесь. ## Тесты - `test_flood_from_one_ip_leaves_login_open_for_another_ip` — API-уровень, мерит **заявленное**: во время флуда с одного адреса вход с другого проходит. На коде до правки красный. - `test_one_key_cannot_take_more_than_its_share` — доля слотов на ключ + явно закреплённая цена по NAT (третий с того же адреса получает 429 при `_verify_inflight == 2` из 4). - `test_bounded_frees_slot_when_pool_refuses_work` — `submit` бросил, слот отдан синхронно (единственная строка без покрытия; мутант «старый код» краснит тест и сторож). - `test_cancelling_queued_work_returns_the_key_slot` — отмена ЕЩЁ НЕ НАЧАТОЙ работы (другая ветка future, чем отмена начатой). - `test_bounded_frees_slot_when_verify_raises` — исключение внутри сверки. - `test_per_key_cap_never_rounds_down_to_zero` — пол `max(1, …)`: при очереди в 1 доля не округляется в ноль. - `_no_leaked_password_verify_slots` (autouse в `tests/conftest.py`) — «слотов занято 0» после **каждого** теста репозитория: счётчик глобальный, а `pytest-asyncio` даёт цикл на тест, так что известная ловушка `except RuntimeError` копилась бы молча и роняла не тот тест. - Сторож дефолтов несёт **литералы** (`max_inflight == 4`, `_per_key_slot_cap() == 2`), а не арифметику от настройки. - Поправлена формулировка в тесте темпа: «вход не заблокирован совсем» — это про попытки **атакующего**, не про живого пользователя. ## Test plan - [x] `pytest tests/test_password.py tests/test_auth_api.py` — 65 passed - [x] полный прогон `tradein-mvp/backend` — 3645 passed, 9 skipped - [x] ruff 0.7.4 (пин pre-commit) check + format - [ ] прод-верификация после деплоя Refs #2714, #2665, #2712
bot-backend added 1 commit 2026-08-06 09:39:47 +00:00
fix(tradein/auth): доля слотов сверки пароля на адрес — потолок перестаёт бить по своим (#2714)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m10s
3d32a0ffc7
Потолок темпа из #2665 держал слоты общим котлом: флуд занимал все четыре, и
легитимный вход с ВЕРНЫМ паролем получал 429 столько раз, сколько пытался
(замер на стенде с настоящим bcrypt: 20 попыток → 0×200, 20×429).

Теперь ключ (у единственного вызывающего — IP клиента) не берёт больше половины
ёмкости: сколько бы один источник ни слал, вторая половина остаётся тем, кто
приходит впервые. Учёт по ключу живёт в самой `verify_password_bounded` и
отдаётся тем же `_release_verify_slot` — инвариант «одна точка выноса = одна
точка учёта» не делится надвое.

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

Замер на стенде после правки: 20 попыток → 5×200, 15×429, и НИ ОДНОГО 429 от
потолка сверок. Оставшиеся 15 — `_LOGIN_LIMITER` (5 попыток на пару имя+IP за
300с), отдельная и намеренная защита, срабатывающая независимо от флуда.

Тесты: вход с другого ключа во время флуда (API-уровень, мерит заявленное — не
латентность и не число попыток атакующего), доля слотов на ключ, возврат слота
при исключении в сверке, autouse-сторож «слотов занято 0» после каждого теста.
Формулировка «вход не заблокирован совсем» в тесте темпа поправлена: она про
попытки атакующего и ничего не говорит про живого пользователя.

Refs #2714, #2665, #2712
Light1YT added 1 commit 2026-08-06 10:38:23 +00:00
test(tradein/auth): покрыть отказ пула и отмену в очереди + записать цену размена по NAT (#2714)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m5s
57d3fec137
Правки по глубокому ревью.

Тесты:
- отказ пула на `submit` (единственная изменённая строка без покрытия): слот
  отдаётся синхронно, оба счётчика в нуле. Мутант «старый код, ключевой
  счётчик не отдан» краснит и тест, и autouse-сторож.
- отмена ЕЩЁ НЕ НАЧАТОЙ работы возвращает слот ключа — ветка future другая,
  чем у отмены начатой, соседний тест её не покрывал.
- пол `max(1, …)`: при очереди в 1 слот доля не округляется в ноль (иначе
  молчаливый отказ всем).
- цена размена по NAT закреплена явно: третий одновременный вход с того же
  адреса получает 429 при двух занятых слотах из четырёх.

Соседям по NAT стало ХУЖЕ, и теперь это записано в docstring с числами: при
флуде 3 запроса/с свои входят 69% попыток против 94% до правки, порог отказа
падает с ~14 до ~7 запросов/с. Взамен вход с чужих адресов идёт 100% против
37%. Размен сознательный, а не побочный эффект.

Refs #2714
bot-backend merged commit 6cf9172d96 into main 2026-08-06 10:42:39 +00:00
bot-backend deleted branch fix/2714-verify-slot-fairness 2026-08-06 10:42:40 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#2717
No description provided.