fix(tradein/auth): отказ по насыщению — до выборки из БД и с агрегированным следом (#2715) #2734

Merged
bot-backend merged 3 commits from fix/2715-auth-hardening-tail into main 2026-08-06 14:27:27 +00:00
Collaborator

Summary

Пункты 1 и 2 хвоста #2715 (после #2712/#2717). Пункт 3 — отдельным PR #2735 (тест-сторож, файлы не пересекаются, порядок мержа любой). Пункт 4 закрыт проверкой без правки — см. ниже.

1. Атака не оставляла следа в аудите, а единственный след выселял прочие логи.
Отказ при насыщении намеренно не пишет login_failed и не тратит бюджет неудач по имени (иначе насыщением блокируют чужую учётку) — значит инцидент был виден только строкой logger.warning НА КАЖДЫЙ отказ, в общем и ограниченном логе бэкенда (json-file max-size 20m × max-file 3, перепроверено docker inspect сегодня). Теперь на окно в 1с — одна запись в лог И одно событие login_verify_saturated в user_events, обе с числом отказов с прошлой записи. Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна). Имя в событии пустое намеренно: отказ случился до того, как мы на имя посмотрели, а запись присланного дала бы атакующему строки аудита с любым именем на выбор.

Запись идёт на ERROR, а не WARNING: бэкенд поднят с LoggingIntegration(level=INFO, event_level=ERROR) (app/main.py), то есть событием GlitchTip запись становится ровно с ERROR, а WARNING остаётся строкой в docker-логе — тем самым следом, на бесполезность которого жалуется issue. Прецедент цены известен (#2674): монитор писал WARNING про протухшие куки, событий было ноль. Уровень закреплён тестом.

2. Гейт насыщения стоял ПОСЛЕ выборки из БД.
Теперь verify_slots_saturated(key) — тот же предикат, что решает отказ, но без взятия слота — вызывается ДО get_user_by_username. Авторитетная проверка осталась внутри verify_password_bounded, и она зовёт ЭТУ ЖЕ функцию: двум условиям разъехаться нечем, инвариант «одна точка выноса = одна точка учёта» цел. Предчек учитывает и общий потолок, и долю на ключ (#2714) — при флуде с одного адреса первой упирается именно доля, без неё предчек не покрывал бы главный случай.

Замеры

Флуд 100 соединений × 1с, у каждого запроса своё имя и свой адрес, джиттер 1-10мс. Проба прогнана и на origin/main — там обязана показать поломку, показала:

origin/main этот PR
отказов (429) 1634 1821
записей в лог об отказе 1634 (1 на отказ) 1
выборок из реестра на отклонённом пути 1634 0

Мутационная проверка (каждая ветка ломалась по одной, тест обязан краснеть — краснел):

  • предчек выключен → под насыщением всё-таки сходили в реестр: ['alice', 'ghost']
  • окно агрегации убрано → 20 отказов дали 20 строк в логе — агрегации нет
  • счётчик за окно потерян → assert {'rejected': 1} == {'rejected': 20}
  • уровень понижен до WARNING → assert 30 == 40
  • доля на ключ выкинута из общего предиката → краснеет test_one_key_cannot_take_more_than_its_share (#2714)

Что этот PR НЕ делает

  • Уведомление никому не уйдёт: у GlitchTip-проекта нет ни правил, ни получателей (#2673). Событие будет видно в интерфейсе; поведенческую проверку доставки сделать не на чем, и это сказано в docstring, а не подразумевается.
  • login_verify_saturated виден запросом к user_events (индекс по event_type есть, проверено на проде), но НЕ виден в админ-UI: drilldown аудита ходит по username, а имени у этого события намеренно нет.
  • Миграция не нужна: user_events.event_type — свободный text без CHECK (проверено на проде \d user_events).
  • Порог _SATURATION_REPORT_WINDOW_S охраняется ЛИТЕРАЛОМ в тесте, а не арифметикой от самой настройки.

Пункт 4 (утечка слота при закрытом цикле) — закрыт без правки

Autouse-фикстура из #2717 (tests/conftest.py::_no_leaked_password_verify_slots) этот путь покрывает. Проверено пробником: тест, который занимает слот и отдаёт освобождение в ЗАКРЫТЫЙ цикл (_schedule_verify_slot_release(dead_loop, key)), падает в teardown с тест оставил 1 занятых слотов проверки пароля (по ключам: {'203.0.113.77': 1}), а следующий тест видит чистое состояние — сброс до assert работает, каскада нет. Пробник удалён, в PR его нет.

Test plan

  • pytest tests/ (весь tradein-backend) — 3757 passed, 9 skipped
  • pytest tests/test_auth_api.py tests/test_password.py tests/test_user_events.py tests/test_audit_api.py — 92 passed, 2 skipped
  • ruff 0.7.4 (версия из pre-commit) check + format — чисто
  • мутационные прогоны выше
  • после мержа на проде: docker exec tradein-backend curl на заведомо несуществующее имя (ответ прежний 401), SELECT count(*) FROM user_events WHERE event_type='login_verify_saturated'

Refs #2715

## Summary Пункты 1 и 2 хвоста #2715 (после #2712/#2717). Пункт 3 — отдельным PR #2735 (тест-сторож, файлы не пересекаются, порядок мержа любой). Пункт 4 закрыт проверкой без правки — см. ниже. **1. Атака не оставляла следа в аудите, а единственный след выселял прочие логи.** Отказ при насыщении намеренно не пишет `login_failed` и не тратит бюджет неудач по имени (иначе насыщением блокируют чужую учётку) — значит инцидент был виден только строкой `logger.warning` НА КАЖДЫЙ отказ, в общем и ограниченном логе бэкенда (`json-file max-size 20m × max-file 3`, перепроверено `docker inspect` сегодня). Теперь на окно в 1с — одна запись в лог И одно событие `login_verify_saturated` в `user_events`, обе с числом отказов с прошлой записи. Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна). Имя в событии пустое намеренно: отказ случился до того, как мы на имя посмотрели, а запись присланного дала бы атакующему строки аудита с любым именем на выбор. Запись идёт на **ERROR**, а не WARNING: бэкенд поднят с `LoggingIntegration(level=INFO, event_level=ERROR)` (`app/main.py`), то есть событием GlitchTip запись становится ровно с ERROR, а WARNING остаётся строкой в docker-логе — тем самым следом, на бесполезность которого жалуется issue. Прецедент цены известен (#2674): монитор писал WARNING про протухшие куки, событий было ноль. Уровень закреплён тестом. **2. Гейт насыщения стоял ПОСЛЕ выборки из БД.** Теперь `verify_slots_saturated(key)` — тот же предикат, что решает отказ, но без взятия слота — вызывается ДО `get_user_by_username`. Авторитетная проверка осталась внутри `verify_password_bounded`, и она зовёт ЭТУ ЖЕ функцию: двум условиям разъехаться нечем, инвариант «одна точка выноса = одна точка учёта» цел. Предчек учитывает и общий потолок, и долю на ключ (#2714) — при флуде с одного адреса первой упирается именно доля, без неё предчек не покрывал бы главный случай. ## Замеры Флуд 100 соединений × 1с, у каждого запроса своё имя и свой адрес, джиттер 1-10мс. Проба прогнана и на `origin/main` — там обязана показать поломку, показала: | | origin/main | этот PR | |---|---|---| | отказов (429) | 1634 | 1821 | | записей в лог об отказе | 1634 (1 на отказ) | 1 | | выборок из реестра на отклонённом пути | 1634 | 0 | Мутационная проверка (каждая ветка ломалась по одной, тест обязан краснеть — краснел): - предчек выключен → `под насыщением всё-таки сходили в реестр: ['alice', 'ghost']` - окно агрегации убрано → `20 отказов дали 20 строк в логе — агрегации нет` - счётчик за окно потерян → `assert {'rejected': 1} == {'rejected': 20}` - уровень понижен до WARNING → `assert 30 == 40` - доля на ключ выкинута из общего предиката → краснеет `test_one_key_cannot_take_more_than_its_share` (#2714) ## Что этот PR НЕ делает - Уведомление никому не уйдёт: у GlitchTip-проекта нет ни правил, ни получателей (#2673). Событие будет видно в интерфейсе; поведенческую проверку доставки сделать не на чем, и это сказано в docstring, а не подразумевается. - `login_verify_saturated` виден запросом к `user_events` (индекс по `event_type` есть, проверено на проде), но НЕ виден в админ-UI: drilldown аудита ходит по `username`, а имени у этого события намеренно нет. - Миграция не нужна: `user_events.event_type` — свободный `text` без CHECK (проверено на проде `\d user_events`). - Порог `_SATURATION_REPORT_WINDOW_S` охраняется ЛИТЕРАЛОМ в тесте, а не арифметикой от самой настройки. ## Пункт 4 (утечка слота при закрытом цикле) — закрыт без правки Autouse-фикстура из #2717 (`tests/conftest.py::_no_leaked_password_verify_slots`) этот путь покрывает. Проверено пробником: тест, который занимает слот и отдаёт освобождение в ЗАКРЫТЫЙ цикл (`_schedule_verify_slot_release(dead_loop, key)`), падает в teardown с `тест оставил 1 занятых слотов проверки пароля (по ключам: {'203.0.113.77': 1})`, а следующий тест видит чистое состояние — сброс до assert работает, каскада нет. Пробник удалён, в PR его нет. ## Test plan - [x] `pytest tests/` (весь tradein-backend) — 3757 passed, 9 skipped - [x] `pytest tests/test_auth_api.py tests/test_password.py tests/test_user_events.py tests/test_audit_api.py` — 92 passed, 2 skipped - [x] ruff 0.7.4 (версия из pre-commit) check + format — чисто - [x] мутационные прогоны выше - [ ] после мержа на проде: `docker exec tradein-backend curl` на заведомо несуществующее имя (ответ прежний 401), `SELECT count(*) FROM user_events WHERE event_type='login_verify_saturated'` Refs #2715
bot-backend added 1 commit 2026-08-06 12:38:43 +00:00
fix(tradein/auth): отказ по насыщению — до выборки из БД и с агрегированным следом (#2715)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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 3m18s
1610c01c6d
Два пункта хвоста #2712/#2717.

1. След инцидента. Отказ при насыщении сознательно не пишет login_failed и не
   тратит бюджет неудач по имени (иначе насыщением блокируют чужую учётку) —
   значит инцидент был виден только строкой logger.warning НА КАЖДЫЙ отказ.
   Лог у бэкенда общий и ограниченный (docker json-file, 20m × 3): при флуде в
   сотни запросов в секунду 60 МБ прокручиваются за минуты и выселяют все
   прочие логи ровно во время атаки. Теперь на окно (1с) — одна строка и одно
   событие login_verify_saturated в user_events, оба с числом отказов,
   накопленных с прошлой записи. Событие важнее строки: аудит переживает и
   ротацию логов, и редеплой. Имя в событии пустое намеренно — отказ случился
   до того, как мы на имя посмотрели, а запись присланного дала бы атакующему
   строки аудита с любым именем на выбор.

2. Гейт насыщения переехал ПЕРЕД выборкой пользователя. Раньше заведомо
   отклоняемый запрос всё равно брал соединение из пула и делал SELECT по имени
   — и это была единственная работа на пути отказа, чьё время зависит от
   существования учётки (bcrypt, который эту разницу ровняет, до отказанного
   запроса не доходит). Добавлен verify_slots_saturated(key) — тот же предикат,
   что решает отказ, но без взятия слота; verify_password_bounded зовёт его же,
   так что двум условиям разъехаться нечем и инвариант «одна точка выноса =
   одна точка учёта» цел. Предчек учитывает и общий потолок, и долю на ключ
   (#2714): при флуде с одного адреса первой упирается именно доля.

Замер (20 заведомо отклоняемых запросов): до — 20 выборок из БД, 20 строк лога;
после — 0 выборок, 1 строка. Все три ветки проверены мутацией кода: тесты
краснеют.

Refs #2715
Light1YT added 1 commit 2026-08-06 12:46:55 +00:00
fix(tradein/auth): агрегированный отказ пишется на ERROR — иначе канала нет (#2715)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 3m8s
8958a657cb
Бэкенд поднят с LoggingIntegration(level=INFO, event_level=ERROR) (app/main.py):
событием GlitchTip запись становится ровно с ERROR, а WARNING остаётся строкой в
docker-логе, которая умирает с ротацией и редеплоем — то есть ровно тем следом,
на бесполезность которого жалуется пункт 1 issue. Прецедент цены известен
(#2674): монитор писал WARNING про протухшие куки, событий было ноль.

Спама не будет: запись не чаще раза в окно (1с), все группируются в один issue.
Получателей у проекта по-прежнему нет (#2673) — событие будет видно в интерфейсе
и никому не уйдёт; это сказано в docstring, а не подразумевается.

Уровень закреплён тестом: понижение до WARNING выключило бы канал молча.

Refs #2715
Light1YT added 1 commit 2026-08-06 14:22:47 +00:00
fix(tradein/auth): хвост счётчика датируется честно, безымянное событие — не аккаунт (#2715)
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
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 Trade-In / backend-tests (pull_request) Successful in 3m8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
18bfa7d3cb
Три правки по ревью PR #2734.

1. `since_prev_s` в payload события. Хвост отказов копится, пока не придёт
   следующий: атака кончилась в 03:00, 900 отказов не отчитаны — и во вторник
   одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником и
   подписанную АДРЕСОМ СОСЕДА. По `created_at` это читалось бы как 901 отказ в
   моменте. Теперь видно, за какой промежуток они накоплены (None — первая
   запись за жизнь процесса, сравнивать не с чем); считается ДО сдвига отметки,
   иначе всегда 0 — проверено мутацией.

2. Безымянная строка больше не притворяется аккаунтом. `WHERE username <> ''`
   в обеих выборках `GROUP BY username` (audit.py): без фильтра строка встала бы
   ПЕРВОЙ в списке аккаунтов (её last_seen_at — момент атаки), а её кнопка в UI
   раскрывалась бы в /audit/accounts/{username} с min_length=1, то есть в
   ошибку. Плюс `count(DISTINCT NULLIF(username, ''))` в трёх счётчиках
   уникальных: строка остаётся в таблице навсегда, значит и +1 к числу
   пользователей был бы навсегда. Из total_events события при этом не исчезают —
   они события, просто не люди. Все четыре изменённые выборки прогнаны на проде
   read-only: синтаксис живой.

3. Дыра в покрытии: ветку «доля на ключ» в предчеке не исполнял ни один тест —
   `or` коротил на общем счётчике, и вторую половину предиката можно было
   выкинуть незамеченной. Хотя отвечает она за главный реальный случай (флуд с
   одного адреса). Тест-близнец занимает долю ключа, а не общий котёл, и
   проверяет заодно, что с ДРУГОГО адреса запрос идёт дальше.

Масштаб назван в docstring рядом с прочими потолками: час непрерывной атаки =
3600 строк в user_events (за всю жизнь таблицы ~3.4 тысячи), сутки — под 86
тысяч, retention нет; то же давление уходит на квоту GlitchTip.

Refs #2715
bot-backend merged commit 90e328df66 into main 2026-08-06 14:27:27 +00:00
bot-backend deleted branch fix/2715-auth-hardening-tail 2026-08-06 14:27:27 +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#2734
No description provided.