From f47cbeeddafbd49290aed1f07be2df856b8e90ef Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:46:17 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20X-Forwarded-For=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BE=D1=82=20=D0=B4=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D1=81=D0=B8=20=E2=80=94=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=20FORWARDED=5FALLOW=5FIPS=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20R-2,=20OQ-65,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProxyHeadersMiddleware(trusted_hosts=settings.FORWARDED_ALLOW_IPS)` вместо `["*"]`: по умолчанию `127.0.0.1`, список через запятую или `*`. Иначе любой клиент подставлял свой адрес в `audit_event.ip_address` (в том числе в LOGIN_FAILED). `.env.example`/`.env.test`, README «Конфигурация» и «Безопасность dev-режима» дополнены; compose `docs/dev` не менялся. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 4 ++++ api/.env.example | 4 ++++ api/.env.test | 4 ++++ api/src/app.py | 3 ++- api/src/core/config.py | 3 +++ api/tests/test_app.py | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 51 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f2e099..5b43b1d 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,7 @@ email без учёта регистра проверяет только сер |---|---|---| | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | +| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | | `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*`) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | | `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) | @@ -603,6 +604,9 @@ Dockerfile только для локального compose - Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет. - Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. +- **`audit_event.ip_address` достоверен только за доверенным прокси.** `X-Forwarded-For` принимается лишь от адресов + `FORWARDED_ALLOW_IPS` (по умолчанию `127.0.0.1`); без прокси в журнал попадает адрес TCP-соединения, а + `FORWARDED_ALLOW_IPS=*` позволяет любому клиенту подставить свой адрес — в проде так не настраивать. ## Соседние документы diff --git a/api/.env.example b/api/.env.example index a1ad609..2a5ed79 100644 --- a/api/.env.example +++ b/api/.env.example @@ -34,6 +34,10 @@ OPENBAO__SMB__SMB_PASSWORD= # При DEBUG=true не используется (CORS `*`); документация API (/docs, /redoc, /openapi.json) — тоже только при DEBUG=true CORS_ORIGINS= +# Доверенные прокси (X-Forwarded-For): адреса через запятую или `*`; адрес клиента в audit_event берётся из заголовка +# только от них. По умолчанию — 127.0.0.1 +FORWARDED_ALLOW_IPS=127.0.0.1 + # Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true. AUTH_DEV_ISSUER=true # ТОЛЬКО ДЛЯ РАЗРАБОТКИ: секрет HS256 dev-эмитента, в проде не использовать diff --git a/api/.env.test b/api/.env.test index 8b949e9..8f8be01 100644 --- a/api/.env.test +++ b/api/.env.test @@ -27,6 +27,10 @@ OPENBAO__SMB__SMB_PATH= OPENBAO__SMB__SMB_LOGIN= OPENBAO__SMB__SMB_PASSWORD= +# Доверенные прокси (X-Forwarded-For): адреса через запятую или `*`; адрес клиента в audit_event берётся из заголовка +# только от них. По умолчанию — 127.0.0.1 +FORWARDED_ALLOW_IPS=127.0.0.1 + # CORS при DEBUG=false (в тестах DEBUG=true — не используется), как в .env.example CORS_ORIGINS= diff --git a/api/src/app.py b/api/src/app.py index b67d7c5..20fad77 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -240,7 +240,8 @@ def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI: register_exception_handlers(application) application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) - application.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) + # X-Forwarded-For — только от доверенных прокси: иначе любой клиент подставит свой ip в audit_event (OQ-65) + application.add_middleware(ProxyHeadersMiddleware, trusted_hosts=app_settings.FORWARDED_ALLOW_IPS) cors = cors_options(app_settings) if cors is not None: application.add_middleware(CORSMiddleware, allow_methods=["*"], allow_headers=["*"], **cors) diff --git a/api/src/core/config.py b/api/src/core/config.py index f658835..69d174d 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): # CORS при DEBUG=false: разрешённые источники (JSON-список или строка через запятую); пусто — кросс-доменные # запросы запрещены. При DEBUG=true — всегда `*` (OQ-65) CORS_ORIGINS: Annotated[list[str], NoDecode] = [] + # Доверенные прокси для X-Forwarded-For/-Proto (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Только от + # них берётся адрес клиента (`audit_event.ip_address`); от остальных заголовок игнорируется (OQ-65, ревью R-2) + FORWARDED_ALLOW_IPS: str = "127.0.0.1" # Аутентификация: dev-эмитент (только для разработки; роутер подключается при AUTH_DEV_ISSUER и DEBUG одновременно) AUTH_DEV_ISSUER: bool = False diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 549fdf1..5395491 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -12,9 +12,12 @@ from typing import Any import httpx import pytest +from fastapi import Request +from fastapi.routing import APIRoute from loguru import logger from src.api.router import router +from src.core.config import Settings from src.core.nats import NatsConnection from src.services import recognition_exchange @@ -613,3 +616,34 @@ async def test_dev_route_absent_without_debug_is_404_not_405(tmp_path: Any) -> N for method in ("GET", "POST"): response = await _request(application, method, "/api/v1/dev/token") assert response.status_code == HTTPStatus.NOT_FOUND, method + + +# --- доверенные прокси FORWARDED_ALLOW_IPS (ревью R-2, OQ-65) ------------------------------------------------------- + + +def _with_ip_route(application: Any) -> Any: + """Служебный маршрут `/whoip` перед монтированием SPA: адрес клиента глазами приложения.""" + + async def whoip(request: Request) -> dict[str, str | None]: + return {"ip": request.client.host if request.client else None} + + application.router.routes.insert(0, APIRoute("/whoip", whoip, methods=["GET"])) + return application + + +@pytest.mark.parametrize( + ("trusted", "expected"), + [("127.0.0.1", "203.0.113.7"), ("10.0.0.1,10.0.0.2", "127.0.0.1"), ("*", "203.0.113.7")], + ids=["default-loopback-trusted", "client-not-trusted", "all-trusted"], +) +async def test_forwarded_for_only_from_trusted_proxy(tmp_path: Any, trusted: str, expected: str) -> None: + """X-Forwarded-For принимается только от адресов FORWARDED_ALLOW_IPS (клиент ASGITransport — 127.0.0.1).""" + application = _with_ip_route(_app_with(tmp_path, FORWARDED_ALLOW_IPS=trusted)) + + response = await _request(application, "GET", "/whoip", headers={"X-Forwarded-For": "203.0.113.7"}) + + assert response.json() == {"ip": expected} + + +def test_forwarded_allow_ips_default_is_loopback() -> None: + assert Settings.model_fields["FORWARDED_ALLOW_IPS"].default == "127.0.0.1"