fix: X-Forwarded-For только от доверенных прокси — настройка FORWARDED_ALLOW_IPS (ревью R-2, OQ-65, #9)

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:46:17 +04:00
parent ea119ffaa5
commit f47cbeedda
6 changed files with 51 additions and 1 deletions

View File

@ -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=*` позволяет любому клиенту подставить свой адрес — в проде так не настраивать.
## Соседние документы

View File

@ -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-эмитента, в проде не использовать

View File

@ -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=

View File

@ -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)

View File

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

View File

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