feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571) #2663
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2663
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/2571-login-throttle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что это даёт и чего НЕ даёт
Сразу, чтобы не читалось сильнее, чем есть: это аудит-сигнал плюс трение, а не потолок темпа. Счётчик инкрементируется до сна, попытки по одному имени ничем не сериализуются, а
await asyncio.sleepотпускает событийный цикл — значит атакующий, которому безразлична латентность, держит сотни соединений и отсыпает свои задержки параллельно. Фактический потолок равен числу его соединений, делённому на задержку, то есть выбирается им, а не нами. Настоящий потолок обсуждается отдельно в #2665 (там же — про то, что сегодня единственный реальный ограничитель темпа побочный: синхронный bcrypt блокирует событийный цикл на ~3-4 попытки в секунду ценой остановки всего приложения).Что PR даёт по-настоящему: распределённый перебор становится видимым в
user_events(раньше выглядел как россыпь одиночных неудач с разных адресов) и дороже для атакующего, которому важна латентность.Модель угрозы
Секция
/trade-in/*в боевом Caddy вынесена выше импорта basic_auth (так и задумано в #2558 — у трейд-ина своя форма входа поверх RBAC), поэтомуPOST /trade-in/api/v1/auth/loginуже сейчас доступен из интернета без единого крeда. Единственный лимит на нём ключевался парой (username, IP): 5 попыток / 300с. Против человека, долбящего с одного адреса, это работает; против credential stuffing — нет вообще. Злоумышленник с ботнета или пула резидентных прокси получает свежие 5 попыток с КАЖДОГО нового адреса. Список имён при этом угадывается тривиально (admin,manager,user1…user10— реальные аккаунты из seed-миграции эпика #2549).Второй, менее очевидный вектор — перечисление учёток. Логин уже защищён от него на двух уровнях: все ветки отказа отдают один и тот же generic 401, а
verify_passwordвызывается безусловно (для несуществующего имени — против статичного dummy-хеша), чтобы bcrypt не выдал существование аккаунта разницей во времени ответа. Любая новая логика на пути отказа обязана этот инвариант сохранить, иначе она сама становится оракулом — что и определило форму этого PR.Что выбрано и почему
Глобальный счётчик неудач на ИМЯ, без IP в ключе (
_USERNAME_FAIL_LIMITER, дефолт 20 неудач / час) — поверх существующего per-IP лимита, не вместо. Оба — один и тот же примитивSlidingWindowLimiter; у него теперьrecord()возвращает число попыток в окне (раньшеNone, существующие вызывающие возврат игнорируют).Замедление, а не блокировка. Жёсткая блокировка учётки после N неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он гарантированно выключает вход конкретному человеку — отказ в обслуживании дешевле и надёжнее того, от чего блокировка защищает. Задержка не отнимает доступ ни у кого: владелец пароля входит с первой попытки, медленнее приходит ответ только на очередную НЕУДАЧУ. Рост — удвоением от 1с, с обязательным потолком (дефолт 8с). Порог, окно и потолок — в
Settings(LOGIN_USERNAME_FAIL_THRESHOLD,LOGIN_USERNAME_FAIL_WINDOW_S,LOGIN_USERNAME_THROTTLE_MAX_DELAY_S), дефолты работают без изменения.env.runtime.Где живёт счётчик. В памяти процесса — сознательно. Прод-бэкенд запущен ОДНИМ uvicorn-воркером (
docker-compose.prod.yml, комментарий надcommand), значит in-process счётчик и есть глобальный. Redis в проекте есть (app/services/cache.py), но в auth-пути он добавил бы сетевую зависимость, падение которой даёт выбор из двух плохих вариантов: fail-open (дыра ровно там, где защита) или fail-closed (вход лежит, потому что лежит кэш). При нескольких воркерах (--workers N) потолок поделится на N и счётчик придётся переносить в Redis; тот же потолок у соседнего_LOGIN_LIMITER, перезапуск процесса обнуляет оба.Оракул существования учётки. Замедление, применённое только к существующим именам, само становится способом перечислить живые логины по времени ответа. Решено структурно: все ветки отказа по кредам (нет такого имени / неверный пароль /
password_hashNULL / доступ закрыт) сведены в ОДИН хвост_reject_invalid_credentials— счётчик, аудит, задержка, 401. Счётчик ведётся по ПРИСЛАННОМУ имени, без проверки в реестре. Обойти регистром нельзя —get_user_by_usernameсверяетusername = :usernameпо обычнойtext-колонке без нормализации.Аудит.
login_failedвuser_eventsписался и раньше; теперь событие несётpayloadс состоянием счётчика (username_fails_in_window,throttle_delay_s) — это и есть главная ценность PR. Raw-пароль по-прежнему не попадает ни в лог, ни в payload._client_ipне тронут (правый hop XFF) и закреплён тестом.Правки по ревью (второй коммит)
MAJOR-1, подтверждён расчётом.
min()вычисляет оба аргумента, поэтомуfloat(2 ** (excess - 1))приexcess >= 1025падал сOverflowError. Проверено:fails=1044 → 8.0,fails=1045 → OverflowError. С дефолтами это 1045 неудач по имени за час, то есть 0.29 rps — с этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и без аудита, теряя обе ценности PR именно тогда, когда атака идёт всерьёз. Показатель степени зажатmin(excess - 1, 16). Мой прежний тест щупал_throttle_delay_s(1000)=float(2**996)— впритык под обрывом; добавлены 5000 и 10**6.MAJOR-2, подтверждён замером. Проверил всю цепочку сам, а не поверил на слово:
identity_storeпо умолчанию'tradein', иget_identity_dbв этом режиме отдаёт ту же сессию, чтоget_db(в коде это заявлено явным «⚠️ отдаётся РОВНО ТОТ ЖЕ объектSession»);QueuePool,pool_size=5,max_overflow=10→ 15 соединений,pool_timeout=30.0— снято с живогоengine.pool, а не из документации;SELECTв сессии сautocommit=False—pool.checkedout() == 1,db.in_transaction() is True;close()возвращает в пул (checkedout() == 0).Арифметика ревьюера сходится: чтобы держать 15 спящих попыток одновременно, нужно ~15/8 ≈ 2 неудачных логина в секунду — достижимо, потолок bcrypt (~4/с) выше. Соединение теперь возвращается в пул перед сном; сессия дальше не используется (сразу
raise), повторныйclose()в зависимости идемпотентен.Мелочи.
usernameограниченmax_length=64— ровно верх CHECK'а реестра, живое имя отсечь нельзя; паттерн и минимум длины НЕ дублирую, потому что в режимеidentity_store="tradein"CHECK'а нет и живут не-ASCII имена (есть тест на кириллицу — проверил перед правкой). Проlimitу счётчика на имя написан явный комментарий, что это не порог. Добавлен тест на спад счётчика по истечении окна.Про маленький семафор на имя: не тащу и не советую сюда. Он превращает латентность в реальный потолок, но ровно тем же механизмом создаёт очередь, в которой легитимный владелец имени ждёт за спинами атакующих — то есть возвращает DoS против конкретного человека, от которого issue сознательно уходит, только в менее заметной форме. Это решение уровня #2665, вместе с выбором про bcrypt.
Что НЕ входит
user_events.payloadужеjsonb.Test plan
tradein-mvp/backend/tests/test_auth_api.py, 10 новых тестов (43 в файле, все зелёные):login_failedс username / ip / user-agent / path и состоянием счётчика; raw-пароль не утёк.disabled) идёт тем же хвостом.close()и концом ответа лежит вся задержка._client_ipберёт правый hop XFF при подделанном левом.Фальсификация, честно. Первый заход: с откаченной реализацией 7/7 новых тестов красные, 33 существующих зелёные. Мутанты, каждый пойман ровно целевым тестом: убрать
await asyncio.sleep; тормозить только существующие имена; вернуть IP в ключ счётчика (падают три, включая DoD-1); вернутьfloat(2 ** (excess - 1))→OverflowErrorв тесте формулы; убратьdb.close()перед сном → падает тест на возврат соединения. Полный прогон бэкенда: 3358 passed, 1 failed —test_search_api.py::test_search_cache_hit, воспроизводится на чистомorigin/mainв отдельном worktree, к этому PR отношения не имеет.Refs #2571
Порог 20 неудач в час был назван автором как «угадан, не измерен» — измерил на проде (только SELECT).
То есть у самого неудачливого живого пользователя шесть промахов за час — втрое ниже порога. Ни один легитимный сценарий в имеющейся истории замедления не поймает.
Честная оговорка: выборка маленькая (55 событий за всё время, продукт до публичного запуска), так что это «противопоказаний не найдено», а не сильная валидация. Когда пойдёт живой трафик, распределение стоит пересмотреть — запрос выше воспроизводимый.
Про остальные две неизвестности из отчёта
db.close()в режимеidentity_store="auth"не снят эмпирически, но тамget_identity_dbотдаёт отдельную сессию auth-БД, и после закрытия она не используется — сразуraise. Логика та же, риск ниже, чем в основном режиме (который как раз и проверен).Исчерпание пула нагрузочно не воспроизводилось — подтверждено по звеньям: пул снят с живого движка (
QueuePool, 5+10, таймаут 30), открытая транзакция после SELECT показана эмпирически, тот же объект сессии — по коду и дефолту настройки. Для решения о мерже этого достаточно: чинится всё равно закрытием сессии перед задержкой, а оно теперь есть и покрыто тестом, который проверяет порядок, а не факт вызова.Отдельно — про отказ от семафора
Автор отказался и обосновал так: семафор превращает латентность в настоящий потолок, но тем же механизмом создаёт очередь, где легитимный владелец имени ждёт за спинами атакующих. Это возвращает отказ в обслуживании против конкретного человека — тот самый, от которого задача уходила, просто в менее заметной форме: не «вход отключён», а «вход не открывается».
Согласен. Настоящий потолок — это #2665 вместе с судьбой синхронного bcrypt, и решать их надо одним заходом.