feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8 #2546

Merged
lekss361 merged 3 commits from feat/mera-b2c-antiabuse into main 2026-08-25 16:27:03 +00:00
Owner

Второй этап плана запуска B2C. Наружу ничего не открывает — готовит защиту, без которой открывать эндпоинт оценки нельзя категорически. Мержится независимо от DNS.

Блокер, который закрывается

Анонимный запрос означал безлимит. В сервисе квот отсутствие имени пользователя трактовалось как unlimited во всех функциях, с комментарием «dev без Caddy, fail-open». Единственной защитой оставался общий лимит 300 запросов в минуту на IP — это анти-флуд для дешёвых запросов, а не бизнес-лимит для пайплайна, который работает десятки секунд и дёргает пять внешних сервисов.

Что сделано

Fail-open больше не по умолчанию. Пустая личность передаётся только при явно включённом флаге разработки, по умолчанию выключенном.

Анонимная личность — подписанная кука с HMAC-SHA256, отдельным каналом от X-Authenticated-User. Это принципиально: тот заголовок ставит Caddy и валидирует внутренним секретом, смешивать схемы нельзя — сломалась бы модель безопасности.

Квота на паре «сессия + IP», переиспользует существующую таблицу и тот же атомарный инкремент под WHERE used < lim — уже проверенную защиту от гонки. По умолчанию 3 оценки в месяц на анонимную сессию. В коде честно оговорено: смена IP или чистка куки обходит лимит; задача — поднять стоимость злоупотребления, а не сделать его невозможным.

Отдельный жёсткий лимит частоты на оценку — 5 запросов за 5 минут, проверяется до квоты, действует и на админов тоже, потому что это защита ёмкости сервера, а не бизнес-правило.

Потолок времени ответа. Вызов Avito IMV шёл без бюджета, в отличие от всех соседних — единственный источник неограниченного времени. Теперь обёрнут.

было стало
худший случай ~186 с 56 с

Складывается из геокодирования 12 с, метаданных дома 8 с, IMV 20 с, оценки Яндекса 8 с и ЦИАН 8 с — вызовы строго последовательные.

Осознанные решения

Redis не задействован. Прод работает одним воркером, состояние лимитера теряется только при рестарте, а основная защита — месячная квота в Postgres. Компромисс задокументирован в коде.

Ключ подписи куки берётся из окружения. Если не задан — эфемерный на процесс, с предупреждением в лог. ⚠️ Перед публичным запуском переменную надо задать на проде, иначе анонимные сессии будут сбрасываться при каждом рестарте вместе с учётом квоты.

Требует твоего решения

Лимит 3 бесплатные оценки в месяц на анонимную сессию — значение по умолчанию, выбранное мной. Для модели «краткая оценка бесплатно, подробный отчёт за деньги» это, возможно, стоит поднять или опустить. Меняется одной настройкой.

Test plan

  • uv run pytest -q2754 passed, 9 skipped
  • ruff check с проектным конфигом — чисто
  • Тесты: аноним упирается в квоту, лимит частоты срабатывает, fail-open не включается без флага, IMV укладывается в бюджет
  • Два существующих теста обновлены под изменившееся поведение — ожидаемо, не регрессия
  • Сквозная проверка куки через Caddy на проде — тестировалась только логика, не HTTP-стек целиком
Второй этап плана запуска B2C. Наружу ничего не открывает — готовит защиту, без которой открывать эндпоинт оценки нельзя категорически. Мержится независимо от DNS. ## Блокер, который закрывается Анонимный запрос означал **безлимит**. В сервисе квот отсутствие имени пользователя трактовалось как unlimited во всех функциях, с комментарием «dev без Caddy, fail-open». Единственной защитой оставался общий лимит 300 запросов в минуту на IP — это анти-флуд для дешёвых запросов, а не бизнес-лимит для пайплайна, который работает десятки секунд и дёргает пять внешних сервисов. ## Что сделано **Fail-open больше не по умолчанию.** Пустая личность передаётся только при явно включённом флаге разработки, по умолчанию выключенном. **Анонимная личность — подписанная кука с HMAC-SHA256**, отдельным каналом от `X-Authenticated-User`. Это принципиально: тот заголовок ставит Caddy и валидирует внутренним секретом, смешивать схемы нельзя — сломалась бы модель безопасности. **Квота на паре «сессия + IP»**, переиспользует существующую таблицу и тот же атомарный инкремент под `WHERE used < lim` — уже проверенную защиту от гонки. По умолчанию **3 оценки в месяц** на анонимную сессию. В коде честно оговорено: смена IP или чистка куки обходит лимит; задача — поднять стоимость злоупотребления, а не сделать его невозможным. **Отдельный жёсткий лимит частоты на оценку** — 5 запросов за 5 минут, проверяется до квоты, действует и на админов тоже, потому что это защита ёмкости сервера, а не бизнес-правило. **Потолок времени ответа.** Вызов Avito IMV шёл **без бюджета**, в отличие от всех соседних — единственный источник неограниченного времени. Теперь обёрнут. | | было | стало | |---|---|---| | худший случай | ~186 с | **56 с** | Складывается из геокодирования 12 с, метаданных дома 8 с, IMV 20 с, оценки Яндекса 8 с и ЦИАН 8 с — вызовы строго последовательные. ## Осознанные решения **Redis не задействован.** Прод работает одним воркером, состояние лимитера теряется только при рестарте, а основная защита — месячная квота в Postgres. Компромисс задокументирован в коде. **Ключ подписи куки берётся из окружения.** Если не задан — эфемерный на процесс, с предупреждением в лог. ⚠️ **Перед публичным запуском переменную надо задать на проде**, иначе анонимные сессии будут сбрасываться при каждом рестарте вместе с учётом квоты. ## Требует твоего решения Лимит **3 бесплатные оценки в месяц** на анонимную сессию — значение по умолчанию, выбранное мной. Для модели «краткая оценка бесплатно, подробный отчёт за деньги» это, возможно, стоит поднять или опустить. Меняется одной настройкой. ## Test plan - [x] `uv run pytest -q` — **2754 passed**, 9 skipped - [x] `ruff check` с проектным конфигом — чисто - [x] Тесты: аноним упирается в квоту, лимит частоты срабатывает, fail-open не включается без флага, IMV укладывается в бюджет - [x] Два существующих теста обновлены под изменившееся поведение — ожидаемо, не регрессия - [ ] Сквозная проверка куки через Caddy на проде — тестировалась только логика, не HTTP-стек целиком
lekss361 added 1 commit 2026-07-28 12:22:57 +00:00
feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
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 1m12s
eef10f406f
Блокер номер один перед открытием эндпоинта оценки наружу: анонимный запрос
означал БЕЗЛИМИТ. В сервисе квот отсутствие имени пользователя трактовалось
как unlimited во всех функциях, с комментарием «dev без Caddy, fail-open».
Единственной защитой был общий лимит 300 запросов в минуту на IP — это
анти-флуд для дешёвых запросов, а не бизнес-лимит для пайплайна на десятки
секунд.

1. Fail-open больше не по умолчанию. Вызывающая сторона передаёт пустую
   личность только при явно включённом флаге разработки (по умолчанию выкл).

2. Анонимная личность — подписанная кука с HMAC-SHA256, отдельным каналом
   от X-Authenticated-User. Тот заголовок ставит Caddy и валидирует внутренним
   секретом; смешивать схемы нельзя, это сломало бы модель безопасности.
   Ключ подписи из окружения; если не задан — эфемерный на процесс, с
   предупреждением в лог.

3. Анонимная квота на паре «сессия + IP», переиспользует существующую таблицу
   и тот же атомарный инкремент под WHERE used < lim (защита от гонки #747).
   Честно закомментировано: смена IP или чистка куки обходит лимит — задача
   поднять стоимость злоупотребления, а не сделать его невозможным.

4. Отдельный жёсткий лимит частоты на оценку, проверяется ДО квоты.
   Переиспользован готовый SlidingWindowLimiter. Redis намеренно не задействован:
   прод работает одним воркером, состояние теряется только при рестарте, а
   основная защита — месячная квота в Postgres. Компромисс задокументирован.

5. Потолок времени ответа. Вызов Avito IMV шёл БЕЗ бюджета, в отличие от всех
   соседних — единственный источник неограниченного времени. Обёрнут.
   Суммарный худший случай: было ~186 с (36 с ограниченных плюс IMV без
   границы ~150 с), стало 56 с.

Тесты: 2754 passed. Два существующих теста обновлены под изменившееся
поведение fail-open — это ожидаемое изменение, не регрессия.
bot-backend added 1 commit 2026-08-15 20:10:54 +00:00
merge(mera/b2c-antiabuse): влить main — платный флоу, скрубберы Sentry, ревайвл мёртвых оценок
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 4m31s
ddcd526284
Ветка отстала на ~395 коммитов; за это время в main приехали B2C-этапы 1 и 4,
платный флоу, бесплатная проба покрытия и скрубберы Sentry для платежей (#2794).

Конфликты (везде — совмещение обеих сторон, не выбор):
- tradein-mvp/backend/app/api/v1/trade_in.py: _estimate_limiter/
  _resolve_quota_identity (анти-абуз анонимов, этот PR) сохранены рядом с
  ESTIMATE_READABLE_SQL/estimate_readable (retention, PR-D1, main); оба
  комментария перед вызовом estimate_quality() объединены.
- tradein-mvp/backend/tests/conftest.py: обе autouse-фикстуры
  (_reset_estimate_rate_limiter из этого PR и _no_leaked_password_verify_slots
  из main) сохранены, докстринг и импорты (sys, Path) объединены.

Полный прогон pytest после слияния: 4609 passed, 1 skipped (allowlisted —
WeasyPrint native libs), 0 failed. ruff check app tests — чисто.
bot-backend added 1 commit 2026-08-17 07:44:14 +00:00
feat(mera/b2c): бесплатный лимит анонима — 5 расчётов в месяц
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 4m33s
bfe58f9160
Решение владельца при разборе PR #2546 (16.08.2026): в первой редакции
стояло 3. Пять проб — больше шансов, что человек дойдёт до ценности и
купит платный отчёт, ценой чуть более высокого потолка злоупотребления.

Значение переопределяется переменной окружения ANON_ESTIMATE_QUOTA_LIMIT
без выкатки кода, так что пересмотр не требует релиза.

Тесты берут лимит из настроек, а не из литерала, поэтому правка их не
трогает. Единственное место с числом — _ANON_DEFAULT в тесте передачи
произвольного default; там 3 оставлено намеренно и снабжено пояснением,
что число специально не совпадает ни с одной настройкой.
Author
Owner

Решение владельца 16.08.2026 и что осталось до мержа

Бесплатный лимит анонима — 5 расчётов в месяц (в первой редакции стояло 3). Значение выставлено дефолтом в config.py и переопределяется переменной ANON_ESTIMATE_QUOTA_LIMIT без выкатки кода. Обоснование зафиксировано комментарием рядом с настройкой: пять проб дают больше шансов, что человек дойдёт до ценности и купит платный отчёт, ценой чуть более высокого потолка злоупотребления.

Ветка приведена к актуальному main, конфликты разведены композицией. Полный прогон бэкенда: 4609 passed, 1 skipped, 0 упавших (единственный skip — рендер PDF, нативных библиотек нет на машине прогона). После правки лимита tests/test_account_quota.py — 62 passed, ruff чисто.

Блокер, который остался ровно один

ANON_SESSION_SECRET на проде. Сейчас переменная пуста, и тогда секрет подписи анонимной куки генерируется заново при каждом старте процесса. Практическое следствие: любой деплой или перезапуск обнуляет все анонимные квоты — счётчик привязан к куке, а кука после смены секрета перестаёт проверяться. То есть анти-абуз будет работать ровно до ближайшей выкатки, а их у нас по несколько в день.

Сгенерировать значение и положить в файл окружения бэкенда «Меры»:

python -c "import secrets; print(secrets.token_urlsafe(48))"
ANON_SESSION_SECRET=<полученное значение>

Затем пересоздать контейнер — перезапуск не перечитывает окружение:

docker compose --env-file .env.runtime -p gendesign-tradein \
  -f docker-compose.prod.yml up -d --force-recreate --no-deps backend

Проверка, что применилось: выдать анонимный расчёт, перезапустить контейнер, повторить расчёт с той же кукой — счётчик обязан продолжиться, а не начаться заново.

На что обратить внимание сразу после мержа

Вместе с квотой включается ограничитель частоты на POST /estimate — 5 запросов за 5 минут, и он действует в том числе на админов и пилотов. Если планируются демонстрации с частыми расчётами подряд, это стоит проверить до показа, а не во время.

## Решение владельца 16.08.2026 и что осталось до мержа **Бесплатный лимит анонима — 5 расчётов в месяц** (в первой редакции стояло 3). Значение выставлено дефолтом в `config.py` и переопределяется переменной `ANON_ESTIMATE_QUOTA_LIMIT` без выкатки кода. Обоснование зафиксировано комментарием рядом с настройкой: пять проб дают больше шансов, что человек дойдёт до ценности и купит платный отчёт, ценой чуть более высокого потолка злоупотребления. Ветка приведена к актуальному main, конфликты разведены композицией. Полный прогон бэкенда: **4609 passed, 1 skipped, 0 упавших** (единственный skip — рендер PDF, нативных библиотек нет на машине прогона). После правки лимита `tests/test_account_quota.py` — 62 passed, `ruff` чисто. ## Блокер, который остался ровно один `ANON_SESSION_SECRET` на проде. Сейчас переменная пуста, и тогда секрет подписи анонимной куки генерируется заново при каждом старте процесса. Практическое следствие: **любой деплой или перезапуск обнуляет все анонимные квоты** — счётчик привязан к куке, а кука после смены секрета перестаёт проверяться. То есть анти-абуз будет работать ровно до ближайшей выкатки, а их у нас по несколько в день. Сгенерировать значение и положить в файл окружения бэкенда «Меры»: ```bash python -c "import secrets; print(secrets.token_urlsafe(48))" ``` ``` ANON_SESSION_SECRET=<полученное значение> ``` Затем пересоздать контейнер — перезапуск не перечитывает окружение: ```bash docker compose --env-file .env.runtime -p gendesign-tradein \ -f docker-compose.prod.yml up -d --force-recreate --no-deps backend ``` Проверка, что применилось: выдать анонимный расчёт, перезапустить контейнер, повторить расчёт с той же кукой — счётчик обязан продолжиться, а не начаться заново. ## На что обратить внимание сразу после мержа Вместе с квотой включается ограничитель частоты на `POST /estimate` — 5 запросов за 5 минут, и он действует **в том числе на админов и пилотов**. Если планируются демонстрации с частыми расчётами подряд, это стоит проверить до показа, а не во время.
lekss361 merged commit d409e31f00 into main 2026-08-25 16:27:03 +00:00
lekss361 deleted branch feat/mera-b2c-antiabuse 2026-08-25 16:27:03 +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#2546
No description provided.