fix(tradein/security): утечка ключа прокси, аудит действий админа, отличимость неудачного входа, IDOR в заявке #2536
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2536
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/tradein-audit-security"
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?
Четыре правки по аудиту.
1. Ключ прокси утекал в ответ API и в трекер ошибок
admin.py:2397— ручка смены IP возвращала клиентуstr(exc), а внутри был полный URL смены IP с ключом в строке запроса. Тот же секрет попадал в breadcrumb трекера.reason="changeip request failed", детали только в лог.sentry_scrub.pyдобавлен_redact_url_secrets_inplace— вычищает распространённые имена секретных параметров запроса (api_key,proxy_key,token,secret,password,access_token,auth). Встроен внутрьscrub_pii_event, то есть композицией, а не заменой — это разные классы секретов с разными механизмами обнаружения. Реализован in-place, чтобы сохранить identity объекта события, которую проверяют существующие тесты.Контекст: в проекте это уже третий случай секрета в URL. Штатный
sanitize_urlиз SDK режет толькоuser:pass@и параметры запроса, но не путь — для токена Telegram пришлось делать отдельный redact-regex.2. Действия администратора не оставляли следа
Загрузка кук, авто-логин, правка прокси и настроек скраперов не писались в аудит — установить, кто именно это сделал, было нельзя.
Правка сделана в middleware, а не в
admin.py: одним изменением покрыты все ~80 админских ручек. Пишетсяadmin_actionс атрибуцией (пользователь, адрес, путь, метод, статус) и без тела запроса — там куки и пароли. Дашбордaudit.pyчитает события без фильтра по типу, поэтому новые появятся там автоматически.3. Неудачный вход был неотличим от успешного
request_audit.py:32— код ответа не сохранялся. Теперь читается, и событие расходится наloginиlogin_failed.Проверено, что middleware аудита внешний по отношению к защитному слою (порядок регистрации в
main.pyдаёт обратную вложенность), поэтому к моменту чтения статуса 401 и 403 от защиты уже проставлены.Про шум сканеров, который я отдельно заметил в трекере (31 запись из 50 за сутки — долбёжка в
/wp-login.php,/.git/config): отдельная фильтрация не нужна и добавлена не была. Этот шум структурно не может попасть в события: Caddy отсекает его basic-auth'ом до проксирования, а код срабатывает только при наличии заголовка пользователя. Задокументировано в файле.Известный компромисс, прописанный в коде: корзина дедупликации событий входа (одно на пользователя+адрес+устройство в день) не разделена на успешные и неуспешные — это требует правки
user_events.py, вне границ задачи. Если в один день будет сначала отказ, а потом реальный вход с того же устройства, второй не запишется. Объём событий не изменился, просто единственное событие дня теперь корректно отражает свой исход.4. Заявку можно было привязать к чужой оценке
lead.py:80—POST /leadпринималestimate_idи не проверял владельца. Переиспользован тот же guard «владелец или админ», что и вGET /estimate/{id}, без правок в самомtrade_in.py.Test plan
admin_actionс атрибуцией и без тела;loginпротивlogin_failed; чужойestimate_idв заявке → 404, обход админом, 401/403uv run pytest -q— 2635 passed, 8 skippeduv run ruff checkс проектным конфигом — чистоЧетыре независимых security-audit находки: 1. rotate_proxy_ip (admin.py) отдавал str(httpx exc) клиенту и в Sentry — mobileproxy changeip-URL несёт API-ключ в query-string. Ответ теперь нейтральный ("changeip request failed"); sentry_scrub.py получил composable full-text редактор секрет-подобных query-параметров (?token=/?proxy_key=/?api_key=/... — не завязан на конкретного провайдера), встроенный в scrub_pii_event (in-place, сохраняет event identity) — main.py не тронут, редактор подключается автоматически через существующую ссылку. 2. Меняющие состояние /api/v1/admin/* ручки (куки, авто-логin, прокси, настройки скрапера, bulk) были полностью исключены из user_events — установить, кто их вызвал, было нельзя. RequestAuditMiddleware теперь пишет `admin_action` для POST/PUT/PATCH/DELETE на /admin/* с атрибуцией (username/ip/path/method/status), БЕЗ тела запроса (там секреты). GET дашборды по-прежнему не логируются (design как раньше — не шумят). 3. login-событие писалось безусловно, без учёта response.status_code — отражённая RBAC-попытка (протухший внутренний секрет / неизвестная роль / scope-блок) была неотличима от настоящего входа. Теперь event_type расходится на login/login_failed по фактическому статусу ответа; payload несёт status_code. Дедуп-бакет (once/user+ip+ua+day) оставлен как есть — разбивка на success/fail потребовала бы правки user_events.py (вне scope); задокументировано как известный trade-off. Побочный вопрос (шум basic_auth 401 от сканеров) не требует доп. фильтра здесь: Caddy гейтит basic_auth ДО проксирования — трафик без валидного X-Authenticated-User в этот код вообще не попадает. 4. POST /lead принимал estimate_id без проверки владельца — можно было привязать заявку к чужой оценке. Переиспользован owner-or-admin guard _assert_estimate_access из trade_in.py (тот же подход, что #690). Тесты на каждый пункт (secret не в ответе/событии, admin_action с атрибуцией и без body, login vs login_failed, чужой estimate_id -> 404) + regression на существующие сьюты. Полный `pytest -q --deselect tests/test_search_api.py::test_search_cache_hit`: 2635 passed, 8 skipped.