Ревью нашло два способа положить сервис ровно под той нагрузкой, ради
которой писалась защита.
Первый: `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))`
при 1045 неудачах по имени за окно падал с OverflowError. Счётчик ничем
не ограничен сверху — `record()` только копит метки и на лимит не смотрит.
С этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и
без записи в аудит: терялись обе ценности PR, и трение, и сигнал. Показатель
степени зажат; 2**16 заведомо выше любого разумного потолка, поэтому видимое
поведение не меняется.
Второй: сон шёл внутри области жизни сессии БД. В дефолтном режиме
`get_identity_db` отдаёт ту же сессию, что `get_db`, а SELECT в
`get_user_by_username` оставляет её в открытой транзакции — соединение
висело занятым все восемь секунд. Пятнадцати одновременных неудач хватало,
чтобы выбрать QueuePool целиком и уронить любой другой эндпоинт по
pool_timeout. Отказ в обслуживании против всех сразу — хуже той блокировки
учётки, ради ухода от которой замедление и выбиралось. Соединение теперь
возвращается в пул перед сном.
Заодно: длина имени ограничена 64 (верх CHECK'а реестра) — сырое имя
становится ключом обоих лимитеров, а их словарь при часовом окне не
подчищается; и явно записано, что `limit` у счётчика на имя не порог.
Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.
Поверх существующего per-IP лимита добавлен глобальный счётчик неудач
на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит
задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы
вектором отказа в обслуживании против конкретного человека — не зная
пароля, злоумышленник гарантированно выключал бы чужой вход.
Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
Первый коммит починил только scripts/geocode_deals_nominatim.py — ручной скрипт.
Тот же дефект оставался на живом пути: POST /admin/geocode-missing?target=deals
отдавал сырой row["city"] в city_hint, а deals.city росреестровое и в хвосте
распределения содержит не-города («Бессонова», «Билейский рыбопитомник»). Любой
не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и уезжает префиксом в запрос
провайдеру, то есть мусорный хинт хуже отсутствия хинта.
Гейт вынесен в geocoder.known_city_hint (сверка с SVERDLOVSK_OBLAST_CITIES —
тем же набором, который уже питает _names_non_ekb_city / _ekb_local_tiers_allowed)
и переиспользуется всеми тремя потребителями city_hint: скриптом, admin-ручкой и
задачей geocode_missing. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.
Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
Правки по deep-review PR #2654.
MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.
MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).
LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.
LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.
LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.
Refs #2600
Хвосты после #2601 (замыкание петли «город → геокодер»).
1. scripts/geocode_deals_nominatim.py — непрошитый sibling-caller.
Скрипт группировал `GROUP BY address` и звал `geocode(address, db)` без
города, хотя deals.city (миграция 177) заполнена на 100%: один и тот же
текст адреса из разных городов схлопывался в одну группу, один geocode-вызов
и один UPDATE по тексту адреса. Теперь — та же форма, что в #2601:
группировка по паре (address, city), city_hint в geocode(), UPDATE и
mark-tried через `city IS NOT DISTINCT FROM` (обычное `=` не ловит NULL-город
→ NULL-группа не обновлялась бы вовсе).
Хинт передаётся ТОЛЬКО для значений из geocoder.SVERDLOVSK_OBLAST_CITIES:
deals.city росреестровое, в хвосте лежит мусор («Бессонова», «Бердюгина»,
«Билейский рыбопитомник»), а любой не-ЕКБ хинт жёстко закрывает EKB-локальные
тиры и подставляется в запрос провайдеру — мусорный хинт хуже отсутствия
хинта. Словарь переиспользован, а не заведён свой: тот же набор уже питает
гейты самого геокодера (_names_non_ekb_city / _ekb_local_tiers_allowed) и
estimator._resolve_target_city.
2. tasks/backfill_listings_coords_geoportal.py — наблюдаемость городского гейта.
Добавлен skipped_non_ekb_by_column (+ в to_counters и в DONE-логи): колоночный
гейт стоит перед парсером адреса, поэтому по мере раскатки областных
развёрток (#2598) строки потекут из no_address в skipped_non_ekb и общий
счётчик поменяет смысл ровно тогда, когда по нему валидируют раскатку.
Старый счётчик не тронут — остаётся суммой обоих гейтов, вклад текстового
считается разностью.
3. tests: test_admin_geocode_missing_passes_city_hint параметризован на
target="deals" (колонка city есть в обеих таблицах, ветка была не покрыта).
4. tasks/geocode_missing.py: dry-run лог печатает city — он с #2594 часть ключа
группы, без него две строки dry-run неотличимы.
Refs #2603
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.
Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.
Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.
Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.
Refs #2600
v2/page.tsx считало insufficient=true как только n_analogs===0, даже когда
median_price_rub>0 — backend уже отдаёт честную цену по deals-фолбэку
(PR #2629), но v2 всё равно рисовал «недостаточно данных» (23 оценки по
Серову, v1/PDF-экспорт показывали этот же случай правильно). Сужено до
единственного честного признака backend'а — estimate.insufficient_data
(= median_price_rub<=0). Когда цена есть, а листинговых аналогов нет,
результат теперь явно подписан «по сделкам Росреестра» вместо ложного
«в объявлении», и таблица аналогов не рисует пустой заголовок без строк.
Заодно (M1 аудита #2583): confidence_explanation бэкенда нигде не
читался в v2 — выведен рядом с «ДОСТОВЕРНОСТЬ» (tooltip в ResultPanel +
видимая строка в ObjectSummary).
house_metadata (OSM/кадастр) отдаёт year_built без валидации; на проде
встретился 1829, который клампился в нижний пол хедонического фактора
(estimate_hedonic_factor_min=0.75) и резал выкупную цену на фикс. -25%
без физического смысла — модель никогда не видела осмысленного объёма
домов старше 1955 (см. COHORTS). Год вне [1917, текущий+3] теперь
трактуется как отсутствующий (None, нейтральный year-term) вместо
клампа, с WARNING-логом по house_id/адресу.
Единая точка входа _sanitize_build_year() в estimate_quality() перед
cohort-фильтром, house-match scoring и хедоническим фактором — покрывает
оба источника года (payload.year_built и house_meta.year_built).
ask_side/ask_global получают предикат (city IS NULL OR city ILIKE :asking_city), симметрично deal-стороне. Областные объявления (развёртки с 12 июля) занижали ask-медиану и завышали коэффициент выкупа на 2.5-4.9%. NULL-толерантность намеренная: listings.city пока заполнена не у всех источников.
Refs #2583
Чтобы включить IDENTITY_STORE=auth, до этого требовалось положить в
runtime-окружение полный AUTH_DATABASE_URL с паролем внутри — при том что
пароль уже лежит там же отдельной переменной AUTH_DB_PASSWORD (её читает
deploy-пайплайн для ALTER ROLE). Один секрет в двух местах разъезжается:
сменили пароль роли, DSN остался старым — вход ложится молча и целиком.
Теперь явный AUTH_DATABASE_URL по-прежнему выигрывает (обратная совместимость
и аварийный обход, скажем sslmode); если он пуст, а AUTH_DB_PASSWORD задан,
DSN собирается из частей. Части переопределяемы через AUTH_DB_HOST, _PORT,
_NAME, _USER.
Дефолт хоста — gendesign-postgres, не postgres. Внутри стека «Меры» имя
postgres резолвится в ЕЁ СОБСТВЕННЫЙ контейнер (tradein-postgres), и такой
дефолт не упал бы «неизвестным хостом», а молча увёл бы аутентификацию в живую
БД tradein, где нет ни роли auth_app, ни таблиц реестра. Нужный сервер виден по
алиасу gendesign-postgres в сети gendesign_shared, к которой tradein-backend
подписан.
Пароль и имя пользователя экранируются quote(safe=""). Имя БД и хост —
намеренно нет: SQLAlchemy раскодирует обратно только userinfo, а path отдаёт
как есть, поэтому quote("c/d") уехало бы в сервер литеральным c%2Fd. Найдено
прогоном, закреплено тестом.
Закрыта реальная утечка на пути ЯВНОГО AUTH_DATABASE_URL: на «почти URL»
SQLAlchemy доходит до int(port) и падает ValueError с символом ПАРОЛЯ в тексте
(он съезжает на позицию порта). Без обрыва цепочки обломок печатался бы в
traceback, то есть в логи и GlitchTip. Теперь ValueError и ArgumentError
перевыбрасываются своим сообщением from None; тест рендерит traceback целиком и
проверяет, что пароля там нет.
Пустое значение AUTH_DB_PORT больше не роняет импорт. Порт типизирован int и
валидируется до всякой нашей логики, а settings создаётся на уровне модуля —
пустая строка уводила контейнер в restart-loop В ЛЮБОМ режиме, включая
дефолтный tradein, где к БД auth нет ни одного обращения.
Прод не меняется: при IDENTITY_STORE=tradein (дефолт) ничего из этого не
читается и соединение с auth не открывается.
Тесты: +16 профильных, 118 passed на связке auth-сьютов. Проверено
исполнением: пустой порт даёт 5432; пароль со спецсимволами экранируется и в
открытом виде в DSN не встречается.
Root cause: event-diff CTE джойнил "today" (снимок за CURRENT_DATE) с "prior"
(DISTINCT ON по всей listing_source_snapshots, ~2.6-2.8M строк) обычным JOIN.
Планировщик оценивал today в 1 строку (свежевставленные в той же транзакции
строки ANALYZE ещё не видел) → Nested Loop без Materialize пересчитывал
DISTINCT ON по всей таблице заново на каждую из ~80-140k реальных строк today
(EXPLAIN на проде: cost≈300k на этом шаге) — прогон не укладывался ни в 6h
zombie-порог, ни в сутки, каждую ночь минимум с 19 июля.
Переписано на JOIN LATERAL (per-row indexed point-lookup через idx_lss_source_date,
cost упал до ~4.4/строку). Плюс budget_sec → SET LOCAL statement_timeout как
defense-in-depth (по образцу geocode_missing_listings) — задача теперь честно
падает в mark_failed вместо того чтобы висеть сутками, если план когда-нибудь
разрегрессирует снова.
Зомби-детектор (reap_zombies) не тронут — он только помечает scrape_runs.status,
не убивает backend (нет pid/application_name в схеме run'а); pg_terminate_backend
для этого — отдельный follow-up, не в этом PR.
DELETE 4 мёртвых mobileproxy-узлов (id 2/3/4/5) из scrape_proxies. Подписка закрыта, узлы мертвы с 4-9 июля; у трёх в rotate_url лежал чужой API-ключ открытым текстом (источник блокера PR #2611). scrape_proxy_rotations пуста, единственный FK не мешает. Условие по домену (url LIKE mobileproxy.space), не по id.
Только фронт. Caddyfile, DNS и бэкенд не тронуты: периметр и домен
meraocenka.ru делаются отдельным PR, чтобы горячий Caddyfile не менялся
параллельно с эпиком «единый вход».
Заменяет заглушку из feat/mera-b2c-perimeter (48 строк «скоро откроется»)
на полноценную страницу: первый экран, как это работает, что человек
получает, откуда данные, вопросы-ответы, подвал и страница обработки ПДн.
## Починен живой баг, из-за которого лэндинг уводил бы людей на форму входа
RouteGuard регистрировал useEffect с router.push('/login') ДО early-return
для публичных путей. Хуки выполняются всегда, поэтому на проде аноним на
/mera-public получал фоновый GET /api/v1/me → 401 → редирект на логин, и
«безусловный bypass» до этого просто не доходил.
Guard разделён: RouteGuard теперь только смотрит pathname, а весь закрытый
контур (useMe, RBAC, экраны отказа) вынесен в GuardedRoute и подключён через
next/dynamic — это точка разрыва графа импортов, а не только логическая
развилка. Проверено на живой странице: запросов к API — НОЛЬ.
## Обещания приведены в соответствие с продуктом
Ревью по честности нашло 1 critical и 4 high — всё это обещания, которых
продукт не выполняет. Убрано или переписано:
- «Отчёт в PDF» — ручка owner-scoped, анониму отдаёт 401 by design;
- «список объектов, на которых построен расчёт» — план прямо запрещает
показывать анониму сырые объявления конкурентов;
- шесть городов подавались как равнозначные, хотя сбор вне Екатеринбурга
выключен (миграция 179, enabled=false) и данных единицы. Теперь честно:
полное покрытие — Екатеринбург, по области данных меньше;
- «в расчёте два типа данных» умалчивало третий — чужие оценочные модели,
которые реально двигают итоговую цифру (estimator.py, IMV/Yandex blend);
- страница ПДн обещала удаление данных, механизма которого нет.
Тексты и правовые формулировки вынесены в content.ts одним местом, рядом с
ссылками на код, который их подтверждает. Финальная редакция privacy —
за юристом, это помечено в файле.
## Форма адреса — честная заглушка, и это вынужденно
Живого автокомплита быть не может: _PUBLIC_PATHS «Меры» (rbac.py) открывает
анониму только health/docs/login/logout и анонимный чат. /geocode/suggest и
/trade-in/estimate отдают анониму 401. Открывать их до анти-абуза (этап 2
плана B2C) прямо запрещено планом. Форма честно говорит, что произойдёт,
и не изображает работу, которой нет. Переключается флагом
PUBLIC_ESTIMATE_ENABLED, рядом с ним — гейт из трёх условий.
## Проверено живьём, не по отчёту
- 360px и 390px: горизонтального переполнения нет (единственный элемент за
экраном — skip-link, так и задумано);
- один h1, иерархия H1→H2→H3 без пропусков, landmark-разметка;
- внешних ресурсов ноль — ни CDN, ни шрифтов, ни картинок с чужих доменов;
- ноль запросов к /api/** со страницы;
- tsc --noEmit чист.
NB для ревьюера: .claude/rules/ui-*.md по frontmatter paths: матчат
frontend/**, то есть корневой фронт Site Finder, а не tradein-mvp/frontend.
Здесь применяется дизайн-система v2/tokens.ts. Tailwind в этом фронте нет.
scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy
changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки
хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер —
security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only,
хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый
запрос без Authorization (смысл ручной ротации — конкретный провайдер).
Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить
ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry
сообщений (не только reason/note).
Миграция 200 (номер 199 занят параллельным PR #2611, не смержен в main).
UPDATE listings SET region_code=NULL WHERE source='avito' и slug города в
source_url не входит в наши шесть (ekaterinburg/nizhniy_tagil/
kamensk-uralskiy/pervouralsk/verhnyaya_pyshma/serov). Строки — наследие
массового заброса 18 июня до появления гео-фильтра карточек (f0264237,
20 июня), канал закрыт, все 16930 строк is_active=false.
NULL вместо настоящего региона: колонку не читает ни одна живая выборка,
восстанавливать регион по тексту не будем. Idempotent (region_code IS NOT
NULL guard). Только UPDATE, без DDL.
Ревью PR #2609: domclick — ровно один узел (прод scrape_proxies.id=1),
намеренно вырезанный из общего пула через provider_affinity='domclick'
(см. 173_scrape_proxies_add_domclick_affinity.sql) — QRATOR банит всё,
кроме этого одного чистого residential-адреса. Fallback-запрос из
предыдущего коммита мог законно забрать его под avito/cian/yandex,
оставив domclick (сейчас исправно собирает: 6501 активных объявлений,
368/сутки) без прокси вообще — чинили бы один источник ценой полной
поломки другого.
- acquire(): fallback-SELECT дополнен условием "affinity='any' ИЛИ есть
ДРУГОЙ enabled-узел той же affinity" через коррелированный EXISTS-
подзапрос (WHERE + FOR UPDATE SKIP LOCKED + ORDER BY last_ok_at NULLS
LAST, id — сохранены). Кандидат с единственным enabled-узлом своей
выделенной affinity в fallback не участвует.
- Тесты: единственный domclick-узел → acquire('avito') возвращает None;
второй enabled domclick-узел появляется — fallback снова срабатывает.
- Починен мок FakeSession (tests/services/test_proxy_pool.py):
ветка "mark_health ok" раньше ставила enabled=True безусловно по
совпадению общей подстроки "SET consecutive_fails = 0" (одинаковой в
старом и новом SQL) — test_mark_health_ok_revives_disabled_proxy
проходил бы и против кода без реанимации. Теперь ставит enabled=True
только если в тексте SQL реально есть "enabled". Та же проблема была
и в fallback-ветке (protects_last_node переопределял логику в Python
независимо от SQL) — исправлено аналогично: применяется, только если
в SQL реально есть EXISTS-подзапрос.
Прод-замер: disabled-узлы никогда не перепроверялись (WHERE enabled в
run_proxy_healthcheck) — auto-disable по DISABLE_THRESHOLD необратим,
транзиентный сбой = вечный приговор (id 11 сгорел за ночь, будучи
физически исправным). acquire() при пустой выборке по provider_affinity
падал в None, морив источник голодом при живых свободных узлах чужой
affinity.
- run_proxy_healthcheck: disabled-узлы проверяются реже (DISABLED_RECHECK_MINUTES=60
либо last_check_at IS NULL); успешная проба реанимирует узел
(enabled=true через mark_health) и инкрементит новый счётчик revived.
- mark_health(ok=True) теперь безусловно ставит enabled=true (реанимация).
- acquire: вторым заходом при пустой выборке своей affinity берёт любой
свободный здоровый узел любой affinity (WARNING-лог), приоритет своих
сохранён.
- _probe_proxy классифицирует неуспех (timeout/connect_error/http_error/other)
в fail_kind — прокидывается в mark_health только для логирования; полноценное
разделение порогов транзиент/бан отложено (см. docstring mark_health).