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

3 commits

Author SHA1 Message Date
18bfa7d3cb 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
Три правки по ревью 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
2026-08-06 19:22:41 +05:00
8958a657cb 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
Бэкенд поднят с LoggingIntegration(level=INFO, event_level=ERROR) (app/main.py):
событием GlitchTip запись становится ровно с ERROR, а WARNING остаётся строкой в
docker-логе, которая умирает с ротацией и редеплоем — то есть ровно тем следом,
на бесполезность которого жалуется пункт 1 issue. Прецедент цены известен
(#2674): монитор писал WARNING про протухшие куки, событий было ноль.

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

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

Refs #2715
2026-08-06 17:46:43 +05:00
1610c01c6d 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
Два пункта хвоста #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
2026-08-06 17:36:22 +05:00