gendesign/tradein-mvp/backend/app/core/request_audit.py
lekss361 76016fd469
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m52s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 1m8s
fix(tradein/security): утечка ключа прокси, аудит действий админа, отличимость неудачного входа, IDOR в заявке (#2536)
2026-07-26 22:42:15 +00:00

130 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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