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:
parent
ea119ffaa5
commit
f47cbeedda
@ -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=*` позволяет любому клиенту подставить свой адрес — в проде так не настраивать.
|
||||
|
||||
## Соседние документы
|
||||
|
||||
|
||||
@ -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-эмитента, в проде не использовать
|
||||
|
||||
@ -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=
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user