fix(tradein/auth): отказ по насыщению — до выборки из БД и с агрегированным следом (#2715) #2734
Merged
bot-backend
merged 3 commits from 2026-08-06 14:27:27 +00:00
fix/2715-auth-hardening-tail into main
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 |
|||
| 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 |
|||
| 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 |