"""Контекст HTTP-запроса для журнала аудита: IP клиента, User-Agent, метод, путь, request_id.""" import ipaddress import uuid from contextvars import ContextVar from app.config import settings request_meta: ContextVar[dict | None] = ContextVar("request_meta", default=None) def _parse(value: str): try: ip = ipaddress.ip_address(value.strip()) except ValueError: return None return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip # ::ffff:a.b.c.d -> a.b.c.d def parse_networks(raw: str) -> list: nets = [] for item in raw.split(","): if item.strip(): nets.append(ipaddress.ip_network(item.strip(), strict=False)) return nets def resolve_client_ip(peer: str | None, x_forwarded_for: str | None, trusted: list) -> str | None: """IP клиента. X-Forwarded-For учитывается только от доверенного пира (иначе заголовок можно подделать): идём по цепочке справа налево и берём первый адрес, не принадлежащий доверенным прокси.""" peer_ip = _parse(peer) if peer else None if peer_ip is None: return None is_trusted = lambda ip: any(ip in net for net in trusted) # noqa: E731 if not x_forwarded_for or not is_trusted(peer_ip): return str(peer_ip) for part in reversed([p for p in x_forwarded_for.split(",") if p.strip()]): ip = _parse(part) if ip is None: return str(peer_ip) # мусор в заголовке — доверяем только сокету if not is_trusted(ip): return str(ip) return str(peer_ip) class RequestContextMiddleware: """ASGI-middleware: кладёт метаданные запроса в ContextVar и возвращает X-Request-ID.""" def __init__(self, app): self.app = app self.trusted = parse_networks(settings.trusted_proxies) async def __call__(self, scope, receive, send): if scope["type"] != "http": return await self.app(scope, receive, send) headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} request_id = uuid.uuid4().hex[:12] peer = scope["client"][0] if scope.get("client") else None token = request_meta.set({ "client_ip": resolve_client_ip(peer, headers.get("x-forwarded-for"), self.trusted), "meta": { "user_agent": headers.get("user-agent", "")[:255], "method": scope["method"], "path": scope["path"], "request_id": request_id, # только путь — без query-строки }, }) async def send_with_id(message): if message["type"] == "http.response.start": message["headers"] = [*message.get("headers", []), (b"x-request-id", request_id.encode())] await send(message) try: await self.app(scope, receive, send_with_id) finally: request_meta.reset(token)