Четыре независимых 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.
130 lines
8.8 KiB
Python
130 lines
8.8 KiB
Python
"""RequestAuditMiddleware — пишет `api_request` / `admin_action` (+ дедуплицированный
|
||
`login`/`login_failed`) события в `user_events` для каждого аутентифицированного
|
||
`/api/*` запроса.
|
||
|
||
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
|
||
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
|
||
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
|
||
не влияет на HTTP-ответ.
|
||
|
||
Порядок middleware-стека (см. `app/main.py`: `rbac_guard` — `@app.middleware("http")`,
|
||
объявлен ДО `app.add_middleware(RequestAuditMiddleware)`) делает `RequestAudit`
|
||
ВНЕШНИМ по отношению к `rbac_guard` (Starlette строит стек в обратном порядке
|
||
регистрации — последний `add_middleware` оборачивает предыдущие). Поэтому к моменту,
|
||
когда код ниже читает `response.status_code`, в нём уже отражён исход rbac_guard
|
||
(401/403 short-circuit) ИЛИ реального хендлера — статус несёт реальный смысл
|
||
"успех/отказ", а не только "запрос дошёл до хендлера".
|
||
|
||
Заведомо неаутентифицированный трафик (сканеры, долбящиеся в /wp-login.php и т.п.
|
||
без валидного basic_auth) сюда вообще не попадает: Caddy гейтит basic_auth ПЕРЕД
|
||
проксированием, так что `X-Authenticated-User` в таких запросах нет — условие
|
||
`if username and ...` ниже их уже отсекает. Поэтому шум сканеров не нужно
|
||
дополнительно фильтровать в этом файле — тот класс проблемы («сигнал тонет в шуме
|
||
сканера») здесь структурно невозможен: событие может появиться только для
|
||
запроса, прошедшего Caddy basic_auth.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from fastapi import Request
|
||
from starlette.middleware.base import BaseHTTPMiddleware
|
||
from starlette.responses import Response
|
||
|
||
from app.core.ratelimit import _client_ip
|
||
from app.services.user_events import schedule_event, should_log_login
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
|
||
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
|
||
# циклическую зависимость.
|
||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||
|
||
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
|
||
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил
|
||
# прокси / изменил настройки скрапера / выполнил bulk-операцию). GET/HEAD/OPTIONS
|
||
# на /admin/* остаются вне аудита (см. комментарий ниже — это просмотр дашбордов,
|
||
# не действие).
|
||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||
|
||
|
||
class RequestAuditMiddleware(BaseHTTPMiddleware):
|
||
"""Логирует активность аутентифицированных пользователей в `user_events`."""
|
||
|
||
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
||
response: Response = await call_next(request)
|
||
|
||
try:
|
||
username = request.headers.get("x-authenticated-user")
|
||
path = request.url.path
|
||
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
|
||
ip = _client_ip(request)
|
||
ua = request.headers.get("user-agent")
|
||
method = request.method
|
||
success = response.status_code < 400
|
||
is_admin_path = path.startswith("/api/v1/admin/")
|
||
|
||
# Общий behavior/activity-поток — каждый authenticated API-запрос.
|
||
# /api/v1/admin/* исключаем из `api_request`: это ops-действия
|
||
# (просмотр дашбордов аудита/аналитики), а не поведение пилота —
|
||
# иначе запросы дашборда зашумляют top_paths и счётчики активности.
|
||
if not is_admin_path:
|
||
schedule_event(
|
||
event_type="api_request",
|
||
username=username,
|
||
ip=ip,
|
||
user_agent=ua,
|
||
path=path,
|
||
method=method,
|
||
payload={"status_code": response.status_code},
|
||
)
|
||
elif method in _MUTATING_METHODS:
|
||
# Admin-аудит (security-audit fix): раньше ЛЮБОЙ запрос под
|
||
# /api/v1/admin/* (включая меняющие состояние — загрузка кук,
|
||
# авто-логин, правка прокси, настройки скраперов, bulk-операции)
|
||
# полностью исключался из `user_events` тем же условием, что и
|
||
# шумные GET-дашборды — установить, КТО совершил действие, было
|
||
# невозможно. Пишем факт действия + атрибуцию (username/ip/path/
|
||
# method/статус) — БЕЗ тела запроса (там куки/пароли/секреты
|
||
# правки прокси), это НЕ payload-лог, а событие "что произошло".
|
||
schedule_event(
|
||
event_type="admin_action",
|
||
username=username,
|
||
ip=ip,
|
||
user_agent=ua,
|
||
path=path,
|
||
method=method,
|
||
payload={"status_code": response.status_code, "success": success},
|
||
)
|
||
|
||
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
|
||
# на (юзер, IP, устройство). Security-audit fix: раньше событие
|
||
# всегда писалось как `login` независимо от исхода запроса —
|
||
# отражённая RBAC-попытка (валидный Caddy basic_auth, но
|
||
# 401/403 от rbac_guard: протухший X-Internal-Auth-Secret,
|
||
# неизвестная роль, scope-блок) была неотличима от настоящего
|
||
# входа. Теперь тип события расходится по `response.status_code`:
|
||
# `login` — успех, `login_failed` — otказ. Дедуп-бакет (once per
|
||
# user+ip+ua+day) НЕ разбит отдельно на success/fail (это
|
||
# потребовало бы менять `should_log_login` в user_events.py —
|
||
# вне scope этого фикса): если в рамках одного дня с этого же
|
||
# устройства сначала случился отказ, а затем реальный успешный
|
||
# вход, второе событие в тот же день не запишется — тот же
|
||
# компромисс дедупа, что был и раньше, разница только в том, что
|
||
# теперь ЕДИНСТВЕННОЕ событие дня корректно отражает, чем оно было.
|
||
if should_log_login(username, ip, ua):
|
||
schedule_event(
|
||
event_type="login" if success else "login_failed",
|
||
username=username,
|
||
ip=ip,
|
||
user_agent=ua,
|
||
path=path,
|
||
method=method,
|
||
payload={"status_code": response.status_code},
|
||
)
|
||
except Exception:
|
||
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
|
||
|
||
return response
|