Доверенные прокси (реальный IP клиента) и параметризация порта
Пункты 14 и 16 ревью 2026-09-28 17:35 (docs/changes/025):
- TRUSTED_PROXIES (CIDR, по умолчанию пусто): X-Forwarded-For учитывается
только от доверенного peer, цепочка разбирается справа налево; иначе
заголовок игнорируется — подделать IP нельзя. Реальный IP — в блокировке
входа и событиях auth.*; неверный CIDR — отказ старта;
- docker-compose: "${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"; стенд на
8001 через APP_PORT в .env, override-файл больше не нужен.
Тесты: 36 из 36. Стенд: поддельный X-Forwarded-For проигнорирован,
заблокирован реальный адрес; боевые данные не изменены. Ручная проверка
UI пользователем на момент коммита не подтверждена.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ff7f1b5f1
commit
5367cb5e9c
9 files changed
+211
-8
No files matched your search
@@ -1,3 +1,8 @@
|
|||||||
|
# Публикация порта в docker-compose.yml (приложение эти переменные не читает)
|
||||||
|
APP_PORT=8000
|
||||||
|
# Адрес публикации: 0.0.0.0 — снаружи хоста; 127.0.0.1 — только за reverse-proxy на том же хосте
|
||||||
|
APP_BIND=0.0.0.0
|
||||||
|
|
||||||
# База метаданных
|
# База метаданных
|
||||||
DATABASE_URL=sqlite:///./data/ros_control.db
|
DATABASE_URL=sqlite:///./data/ros_control.db
|
||||||
|
|
||||||
@@ -18,6 +23,10 @@ API_TOKEN=
|
|||||||
# Без заданных выше значений (или со значением change-me/короче требуемой длины) приложение не запускается —
|
# Без заданных выше значений (или со значением change-me/короче требуемой длины) приложение не запускается —
|
||||||
# см. README «Безопасность».
|
# см. README «Безопасность».
|
||||||
|
|
||||||
|
# Доверенные прокси (CIDR через запятую): только от них принимается X-Forwarded-For для определения IP клиента
|
||||||
|
# (блокировка входа, журнал). Пусто по умолчанию — заголовок всегда игнорируется, берётся адрес TCP-соединения.
|
||||||
|
TRUSTED_PROXIES=
|
||||||
|
|
||||||
# Yandex Object Storage (S3)
|
# Yandex Object Storage (S3)
|
||||||
S3_ENDPOINT=https://storage.yandexcloud.net
|
S3_ENDPOINT=https://storage.yandexcloud.net
|
||||||
S3_REGION=ru-central1
|
S3_REGION=ru-central1
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
|
|||||||
cp .env.example .env # заполнить SECRET_KEY, SESSION_SECRET, ADMIN_PASSWORD, API_TOKEN, S3_* (см. «Конфигурация»)
|
cp .env.example .env # заполнить SECRET_KEY, SESSION_SECRET, ADMIN_PASSWORD, API_TOKEN, S3_* (см. «Конфигурация»)
|
||||||
docker compose up -d --build # БД — в томе ros_data, схема обновляется при старте
|
docker compose up -d --build # БД — в томе ros_data, схема обновляется при старте
|
||||||
```
|
```
|
||||||
- UI: `http://<хост>:8000/`, OpenAPI: `http://<хост>:8000/docs`.
|
- UI: `http://<хост>:${APP_PORT:-8000}/`, OpenAPI: `http://<хост>:${APP_PORT:-8000}/docs`.
|
||||||
- Вход в UI: `ADMIN_USER` / `ADMIN_PASSWORD` из `.env`; API — заголовок `Authorization: Bearer <API_TOKEN>`.
|
- Вход в UI: `ADMIN_USER` / `ADMIN_PASSWORD` из `.env`; API — заголовок `Authorization: Bearer <API_TOKEN>`.
|
||||||
- Генерация секретов: `python -c "import secrets; print(secrets.token_urlsafe(32))"`;
|
- Генерация секретов: `python -c "import secrets; print(secrets.token_urlsafe(32))"`;
|
||||||
ключ Fernet: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`.
|
ключ Fernet: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`.
|
||||||
@@ -23,11 +23,14 @@ docker compose up -d --build # БД — в томе ros_data, схема
|
|||||||
## Конфигурация (`.env`)
|
## Конфигурация (`.env`)
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `APP_PORT` | `8000` | Порт хоста, публикуемый `docker-compose.yml` (читает только compose, не приложение) |
|
||||||
|
| `APP_BIND` | `0.0.0.0` | Адрес публикации порта; `127.0.0.1` — только за reverse-proxy на том же хосте |
|
||||||
| `SECRET_KEY` | — | Ключ Fernet для паролей устройств. Обязателен; потеря ключа — потеря доступа к сохранённым паролям |
|
| `SECRET_KEY` | — | Ключ Fernet для паролей устройств. Обязателен; потеря ключа — потеря доступа к сохранённым паролям |
|
||||||
| `SESSION_SECRET` | — | Подпись cookie-сессии UI. Обязателен, ≥ 32 символов |
|
| `SESSION_SECRET` | — | Подпись cookie-сессии UI. Обязателен, ≥ 32 символов |
|
||||||
| `SESSION_COOKIE_SECURE` | `false` | Флаг Secure у cookie сессии; включить, когда приложение работает за TLS |
|
| `SESSION_COOKIE_SECURE` | `false` | Флаг Secure у cookie сессии; включить, когда приложение работает за TLS |
|
||||||
| `ADMIN_USER`, `ADMIN_PASSWORD` | `admin`, — | Вход в UI. Пароль обязателен, ≥ 12 символов |
|
| `ADMIN_USER`, `ADMIN_PASSWORD` | `admin`, — | Вход в UI. Пароль обязателен, ≥ 12 символов |
|
||||||
| `API_TOKEN` | — | Bearer-токен API. Обязателен, ≥ 32 символов |
|
| `API_TOKEN` | — | Bearer-токен API. Обязателен, ≥ 32 символов |
|
||||||
|
| `TRUSTED_PROXIES` | пусто | Доверенные прокси (CIDR через запятую). Только от них принимается `X-Forwarded-For` для IP клиента; пусто — заголовок всегда игнорируется |
|
||||||
| `DATABASE_URL` | `sqlite:///./data/ros_control.db` | База метаданных (в compose — том `ros_data`) |
|
| `DATABASE_URL` | `sqlite:///./data/ros_control.db` | База метаданных (в compose — том `ros_data`) |
|
||||||
| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_PREFIX` | Yandex Object Storage, `backups` | Бакет резервных копий |
|
| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_PREFIX` | Yandex Object Storage, `backups` | Бакет резервных копий |
|
||||||
| `BACKUPS_CACHE_TTL` | `60` | Время жизни кэша списка бакета, с; `0` — без кэша |
|
| `BACKUPS_CACHE_TTL` | `60` | Время жизни кэша списка бакета, с; `0` — без кэша |
|
||||||
@@ -140,7 +143,8 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
|
|||||||
|
|
||||||
**Вход в UI**
|
**Вход в UI**
|
||||||
- 5 неверных попыток за 10 минут с одного IP → IP заблокирован на 10 минут (429, пароль не проверяется); неверный пароль — 401.
|
- 5 неверных попыток за 10 минут с одного IP → IP заблокирован на 10 минут (429, пароль не проверяется); неверный пароль — 401.
|
||||||
- Блокировка по IP, а не по имени: единственного администратора нельзя заблокировать чужими попытками. За reverse-proxy все клиенты видны с IP прокси — нужен доверенный `X-Forwarded-For` (сейчас не поддерживается).
|
- Блокировка по IP, а не по имени: единственного администратора нельзя заблокировать чужими попытками. За reverse-proxy без `TRUSTED_PROXIES` все клиенты видны с IP прокси — одна блокировка на всех.
|
||||||
|
- `TRUSTED_PROXIES` (CIDR через запятую) включает разбор `X-Forwarded-For`: только когда адрес соединения (peer) сам входит в доверенную сеть, заголовок берётся в расчёт — цепочка разбирается справа налево, первый адрес не из доверенной сети становится IP клиента (блокировка входа, `data.ip` в `auth.*`). Пустой список (по умолчанию) или недоверенный peer — заголовок полностью игнорируется, подделать IP нельзя. Доверяя сети Docker-моста, вы делаете доверенными и процессы хоста (не только сам reverse-proxy) — используйте узкий CIDR.
|
||||||
- Очистка журнала требует пароль; 5 неверных за 10 минут блокируют её на 10 минут.
|
- Очистка журнала требует пароль; 5 неверных за 10 минут блокируют её на 10 минут.
|
||||||
- Редиректы после форм — только на локальный путь.
|
- Редиректы после форм — только на локальный путь.
|
||||||
|
|
||||||
@@ -154,7 +158,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
|
|||||||
- **Один процесс на БД**: файловая блокировка `<файл БД>.lock`; второй процесс (`--workers 2+`, вторая копия контейнера на том же томе) не стартует. В памяти процесса — семафор задач, счётчики неудачных паролей, кэш бакета.
|
- **Один процесс на БД**: файловая блокировка `<файл БД>.lock`; второй процесс (`--workers 2+`, вторая копия контейнера на том же томе) не стартует. В памяти процесса — семафор задач, счётчики неудачных паролей, кэш бакета.
|
||||||
- **Кэш бакета**: страница «Бэкапы» и `GET /api/v1/backups` перечитывают бакет не чаще `BACKUPS_CACHE_TTL`; собственные изменения сбрасывают кэш, внешние видны по «Обновить список».
|
- **Кэш бакета**: страница «Бэкапы» и `GET /api/v1/backups` перечитывают бакет не чаще `BACKUPS_CACHE_TTL`; собственные изменения сбрасывают кэш, внешние видны по «Обновить список».
|
||||||
- **Копия БД**: SQLite работает в режиме WAL — файл БД без `-wal` может быть неполным. Копировать через `sqlite3 <БД> ".backup <копия>"` или вместе с `-wal`/`-shm`.
|
- **Копия БД**: SQLite работает в режиме WAL — файл БД без `-wal` может быть неполным. Копировать через `sqlite3 <БД> ".backup <копия>"` или вместе с `-wal`/`-shm`.
|
||||||
- Текущий стенд опубликован на порту **8001** через override-файл вне репозитория: порт 8000 на хосте занят другим процессом.
|
- Текущий стенд опубликован на порту **8001** — задан `APP_PORT=8001` в `.env` стенда (порт 8000 на хосте занят другим процессом); override-файл не нужен, `docker compose up -d` берёт порт из `.env`.
|
||||||
|
|
||||||
## Интерфейс
|
## Интерфейс
|
||||||
- Экраны: «Устройства» (вкладки групп, фильтры, таблица, карточка «Задачи»), «Бэкапы», «Группы», «Журнал».
|
- Экраны: «Устройства» (вкладки групп, фильтры, таблица, карточка «Задачи»), «Бэкапы», «Группы», «Журнал».
|
||||||
@@ -164,7 +168,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
|
|||||||
- Светлая и тёмная темы — по настройке системы.
|
- Светлая и тёмная темы — по настройке системы.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
33 теста, фоновый опрос выключен; стенд не нужен (временная SQLite, RouterOS и S3 — заглушки). Тест-линтер не допускает синхронных обращений к БД в `async`-коде.
|
36 тестов, фоновый опрос выключен; стенд не нужен (временная SQLite, RouterOS и S3 — заглушки). Тест-линтер не допускает синхронных обращений к БД в `async`-коде.
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv venv && venv/bin/pip install -r requirements.txt
|
python3 -m venv venv && venv/bin/pip install -r requirements.txt
|
||||||
venv/bin/python -m pytest -q
|
venv/bin/python -m pytest -q
|
||||||
@@ -200,6 +204,7 @@ venv/bin/python -m pytest -q
|
|||||||
| 022 | Остаток синхронной БД в async-коде, тест-линтер | [план](docs/changes/022-async-db-remainder/plan.md) · [итог](docs/changes/022-async-db-remainder/summary.md) |
|
| 022 | Остаток синхронной БД в async-коде, тест-линтер | [план](docs/changes/022-async-db-remainder/plan.md) · [итог](docs/changes/022-async-db-remainder/summary.md) |
|
||||||
| 023 | Состояния «Upgrade ROS», откат ROS до версии канала | [план](docs/changes/023-ros-downgrade/plan.md) · [итог](docs/changes/023-ros-downgrade/summary.md) |
|
| 023 | Состояния «Upgrade ROS», откат ROS до версии канала | [план](docs/changes/023-ros-downgrade/plan.md) · [итог](docs/changes/023-ros-downgrade/summary.md) |
|
||||||
| 024 | Оптимизация README | [план](docs/changes/024-readme-restructure/plan.md) · [итог](docs/changes/024-readme-restructure/summary.md) |
|
| 024 | Оптимизация README | [план](docs/changes/024-readme-restructure/plan.md) · [итог](docs/changes/024-readme-restructure/summary.md) |
|
||||||
|
| 025 | Доверенные прокси (реальный IP клиента), параметризация порта | [план](docs/changes/025-trusted-proxies-port/plan.md) · [итог](docs/changes/025-trusted-proxies-port/summary.md) |
|
||||||
|
|
||||||
## Отчёты ревью
|
## Отчёты ревью
|
||||||
- [Ревью кодовой базы 2026-09-27](docs/reviews/2026-09-27-codebase-review.md) (→ 018–021)
|
- [Ревью кодовой базы 2026-09-27](docs/reviews/2026-09-27-codebase-review.md) (→ 018–021)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ipaddress
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -40,12 +41,22 @@ class Settings(BaseSettings):
|
|||||||
events_retention_days: int = 90 # 0 — без ограничения по сроку
|
events_retention_days: int = 90 # 0 — без ограничения по сроку
|
||||||
events_max_rows: int = 100000 # 0 — без ограничения по числу записей
|
events_max_rows: int = 100000 # 0 — без ограничения по числу записей
|
||||||
|
|
||||||
|
# доверенные прокси (CIDR через запятую, например 10.0.0.0/8,172.16.0.0/12; одиночный адрес — как /32 или /128):
|
||||||
|
# только от них принимается X-Forwarded-For; пусто — заголовок всегда игнорируется, IP клиента берётся напрямую
|
||||||
|
trusted_proxies: str = ""
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def trusted_networks(value: str) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
|
||||||
|
"""Сети TRUSTED_PROXIES, разобранные из строки настройки; результат кэшируется по значению строки."""
|
||||||
|
return tuple(ipaddress.ip_network(part.strip(), strict=False) for part in value.split(",") if part.strip())
|
||||||
|
|
||||||
|
|
||||||
def insecure_settings(s: Settings) -> list[str]:
|
def insecure_settings(s: Settings) -> list[str]:
|
||||||
"""Проблемы конфигурации, с которыми приложению нельзя стартовать. Без значений секретов — только названия проблем."""
|
"""Проблемы конфигурации, с которыми приложению нельзя стартовать. Без значений секретов — только названия проблем."""
|
||||||
problems = []
|
problems = []
|
||||||
@@ -68,4 +79,9 @@ def insecure_settings(s: Settings) -> list[str]:
|
|||||||
Fernet(s.secret_key.encode())
|
Fernet(s.secret_key.encode())
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
problems.append("SECRET_KEY: не ключ Fernet")
|
problems.append("SECRET_KEY: не ключ Fernet")
|
||||||
|
if s.trusted_proxies:
|
||||||
|
try:
|
||||||
|
trusted_networks(s.trusted_proxies)
|
||||||
|
except ValueError:
|
||||||
|
problems.append(f"TRUSTED_PROXIES: неверная сеть {s.trusted_proxies!r}")
|
||||||
return problems
|
return problems
|
||||||
+36
-2
@@ -1,12 +1,13 @@
|
|||||||
import hmac
|
import hmac
|
||||||
|
import ipaddress
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings, trusted_networks
|
||||||
|
|
||||||
_bearer = HTTPBearer(auto_error=False)
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -33,6 +34,39 @@ def check_admin(user: str, password: str) -> bool:
|
|||||||
return ok_user and ok_pass
|
return ok_user and ok_pass
|
||||||
|
|
||||||
|
|
||||||
|
def _in_networks(ip: str, networks) -> bool:
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(addr in net for net in networks)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""IP клиента с учётом TRUSTED_PROXIES. Пустой список или peer не из доверенной сети — X-Forwarded-For
|
||||||
|
полностью игнорируется (подделка заголовка бессмысленна), возвращается адрес соединения.
|
||||||
|
|
||||||
|
Иначе цепочка `X-Forwarded-For` (все заголовки, значения через запятую) разбирается справа налево:
|
||||||
|
доверенные прокси пропускаются, первый адрес не из доверенной сети — IP клиента. Невалидная запись
|
||||||
|
обрывает разбор — возвращается последний валидный разобранный адрес (или peer, если такого не было).
|
||||||
|
Если все адреса цепочки доверенные (или заголовка нет) — возвращается peer."""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
networks = trusted_networks(get_settings().trusted_proxies)
|
||||||
|
if not networks or not _in_networks(peer, networks):
|
||||||
|
return peer
|
||||||
|
chain = [part.strip() for header in request.headers.getlist("x-forwarded-for") for part in header.split(",")]
|
||||||
|
last_valid = None
|
||||||
|
for hop in reversed(chain):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(hop)
|
||||||
|
except ValueError:
|
||||||
|
return last_valid if last_valid is not None else peer
|
||||||
|
if not _in_networks(hop, networks):
|
||||||
|
return hop
|
||||||
|
last_valid = hop
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
# --- защита от перебора: вход в UI (по IP) и подтверждение действий паролем (очистка журнала, по пользователю) ---
|
# --- защита от перебора: вход в UI (по IP) и подтверждение действий паролем (очистка журнала, по пользователю) ---
|
||||||
FAIL_LIMIT, WINDOW_S, LOCK_S = 5, 600, 600 # 5 неверных за 10 минут → блокировка на 10 минут
|
FAIL_LIMIT, WINDOW_S, LOCK_S = 5, 600, 600 # 5 неверных за 10 минут → блокировка на 10 минут
|
||||||
_guard = threading.Lock()
|
_guard = threading.Lock()
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ def login_form(request: Request):
|
|||||||
def login(request: Request, username: str = Form(), password: str = Form()):
|
def login(request: Request, username: str = Form(), password: str = Form()):
|
||||||
"""Вход в UI: попытки ограничены по IP клиента (не по имени — иначе одного администратора
|
"""Вход в UI: попытки ограничены по IP клиента (не по имени — иначе одного администратора
|
||||||
можно заблокировать чужими неверными попытками). При блокировке пароль не проверяется."""
|
можно заблокировать чужими неверными попытками). При блокировке пароль не проверяется."""
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = security.client_ip(request)
|
||||||
key = f"login:{ip}"
|
key = f"login:{ip}"
|
||||||
events.set_actor("anonymous")
|
events.set_actor("anonymous")
|
||||||
lock = security.lockout_remaining(key)
|
lock = security.lockout_remaining(key)
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: sqlite:////srv/data/ros_control.db
|
DATABASE_URL: sqlite:////srv/data/ros_control.db
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ros_data:/srv/data # именованный том: права наследуются от образа (uid 10001)
|
- ros_data:/srv/data # именованный том: права наследуются от образа (uid 10001)
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# План: 025 — доверенные прокси (реальный IP клиента) и параметризация порта
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Ревью `docs/reviews/2026-09-28-1735-codebase-review.md`:
|
||||||
|
- **п. 14** — блокировка входа по IP (021) и IP в событиях `auth.*` берут `request.client.host`. За reverse-proxy все клиенты видны
|
||||||
|
с адреса прокси: одна блокировка на всех и журнал без реальных адресов. Разбора `X-Forwarded-For` и доверенных прокси нет.
|
||||||
|
- **п. 16** — `docker-compose.yml` публикует жёстко `"8000:8000"`; порт 8000 хоста занят посторонним процессом, стенд работает на 8001
|
||||||
|
через override-файл вне репозитория.
|
||||||
|
|
||||||
|
Решения пользователя:
|
||||||
|
- п. 16 — переменные **`APP_PORT`** (порт на хосте, по умолчанию 8000) и **`APP_BIND`** (адрес публикации, по умолчанию `0.0.0.0`),
|
||||||
|
как в `ipam_control`. В `.env` стенда оркестратор добавил `APP_PORT=8001` — стенд поднимается обычным `docker compose up -d`, override не нужен.
|
||||||
|
- п. 14 — **`TRUSTED_PROXIES` пустой по умолчанию и на стенде**: `X-Forwarded-For` игнорируется, пока прокси явно не указаны.
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### п. 16 — порт (`docker-compose.yml`, `.env.example`, README)
|
||||||
|
- `ports: - "${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"`. Внутренний порт контейнера не меняется (8000).
|
||||||
|
- `.env.example`: `APP_PORT=8000`, `APP_BIND=0.0.0.0` с комментарием (`127.0.0.1` — только за reverse-proxy на том же хосте).
|
||||||
|
- Приложение эти переменные не читает (только compose); `Settings` — `extra="ignore"`, конфликтов нет.
|
||||||
|
|
||||||
|
### п. 14 — реальный IP клиента (`app/config.py`, `app/security.py` или новый `app/client_ip.py`, `app/ui/routes.py`)
|
||||||
|
- `Settings.trusted_proxies: str = ""` — CIDR через запятую (`10.0.0.0/8,172.16.0.0/12`); одиночный адрес — как `/32`/`/128`.
|
||||||
|
Невалидный CIDR — проблема в `insecure_settings` (отказ старта, как у секретов): «TRUSTED_PROXIES: неверная сеть <значение>» (это не секрет — значение можно показать).
|
||||||
|
- `client_ip(request) -> str`: `peer = request.client.host` (или `"unknown"`).
|
||||||
|
- Если `peer` не входит в доверенные сети (или список пуст) — вернуть `peer`, **`X-Forwarded-For` игнорируется**.
|
||||||
|
- Иначе разобрать `X-Forwarded-For` (все заголовки, через запятую) **справа налево**: пропускать адреса из доверенных сетей;
|
||||||
|
первый недоверенный — IP клиента. Невалидная запись в цепочке — остановиться и вернуть последний валидный разобранный адрес
|
||||||
|
(или `peer`, если таких нет). Все адреса доверенные / заголовка нет — `peer`.
|
||||||
|
- Сети разбираются один раз (кэш по строке настройки).
|
||||||
|
- `login` (`app/ui/routes.py`): `ip = client_ip(request)` вместо `request.client.host` — ключ блокировки `login:<ip>` и `data.ip` в `auth.*`.
|
||||||
|
Другие места с `request.client` — найти grep'ом и перевести на `client_ip`.
|
||||||
|
- uvicorn `--proxy-headers` **не** включать (иначе uvicorn сам подменит `request.client` по своей логике `--forwarded-allow-ips`).
|
||||||
|
|
||||||
|
## Тесты (минимально, `tests/test_app.py`)
|
||||||
|
- `client_ip`: пустой `TRUSTED_PROXIES` + XFF → peer (подделка игнорируется); доверенный peer + `XFF: 1.2.3.4, 10.0.0.2` при `10.0.0.0/8` → `1.2.3.4`;
|
||||||
|
недоверенный peer + XFF → peer; мусор в XFF → последний валидный / peer; IPv6.
|
||||||
|
- Блокировка входа за прокси: `TestClient(client=("10.0.0.5", …))`, `TRUSTED_PROXIES=10.0.0.0/8`, 5 неверных с `XFF: 1.1.1.1` → 429 для `1.1.1.1`,
|
||||||
|
с `XFF: 2.2.2.2` вход проходит.
|
||||||
|
- `insecure_settings`: невалидный `TRUSTED_PROXIES` → проблема.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
README: «Конфигурация» (`APP_PORT`, `APP_BIND`, `TRUSTED_PROXIES`), «Быстрый старт» (адрес с `APP_PORT`), «Безопасность» (вход за прокси —
|
||||||
|
заменить «не поддерживается» на описание `TRUSTED_PROXIES` и предупреждение: доверие к сети Docker-моста делает доверенными и процессы хоста),
|
||||||
|
«Эксплуатация» (стенд на 8001 — через `APP_PORT` в `.env`, override не нужен), число тестов, строка 025 в истории. `summary.md` — оркестратор.
|
||||||
|
|
||||||
|
## Исполнение
|
||||||
|
Исполнитель (Sonnet): код, compose, `.env.example`, тесты, README; пересборка стенда **обычной командой**
|
||||||
|
`docker compose up -d --build --force-recreate` (без override — порт 8001 берётся из `.env`). Тесты не запускает, не коммитит, `.env` не читает.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- `pytest` — все зелёные.
|
||||||
|
- `docker compose config` → опубликован `8001` (из `.env`); контейнер Up на `0.0.0.0:8001->8000`; `/login` 200; новый код.
|
||||||
|
- Подделка: с хоста `curl -H "X-Forwarded-For: 9.9.9.9"` — 5 неверных входов → 6-й 429 уже **без** заголовка (блокируется реальный адрес, XFF проигнорирован);
|
||||||
|
событие `auth.failed` содержит реальный IP (адрес шлюза Docker), не `9.9.9.9`. Блокировка снимается перезапуском контейнера.
|
||||||
|
- Боевые данные — сверка по ID.
|
||||||
|
- Ручная проверка — пользователь: UI на `http://<хост>:8001`.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Итоги: 025 — доверенные прокси (реальный IP клиента) и параметризация порта
|
||||||
|
|
||||||
|
Источник — ревью `docs/reviews/2026-09-28-1735-codebase-review.md`, пункты 14 и 16.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- **п. 16 — порт**: `docker-compose.yml` публикует `"${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"` (как в ipam_control);
|
||||||
|
`.env.example` — `APP_PORT`, `APP_BIND`. В `.env` стенда оркестратор добавил `APP_PORT=8001` (секреты не менялись) — стенд поднимается
|
||||||
|
обычным `docker compose up -d`, override-файл вне репозитория больше не нужен.
|
||||||
|
- **п. 14 — `TRUSTED_PROXIES`** (CIDR через запятую, по умолчанию пусто; на стенде пусто): `app/security.py::client_ip` — пустой список или peer
|
||||||
|
не из доверенной сети → `X-Forwarded-For` игнорируется; иначе цепочка (все заголовки) справа налево, доверенные адреса пропускаются, первый
|
||||||
|
недоверенный — IP клиента; невалидная запись обрывает разбор (последний валидный адрес прокси или peer — клиентский адрес не подставить);
|
||||||
|
все доверенные / нет заголовка — peer. `app/config.py::trusted_networks` (кэш), невалидный CIDR — проблема в `insecure_settings` (отказ старта).
|
||||||
|
`login` использует `client_ip`: ключ блокировки и `data.ip` в `auth.*`. uvicorn `--proxy-headers` не включён.
|
||||||
|
- README: «Быстрый старт», «Конфигурация» (`APP_PORT`, `APP_BIND`, `TRUSTED_PROXIES`), «Безопасность» (разбор XFF, предупреждение о доверии
|
||||||
|
к сети Docker-моста), «Эксплуатация» (стенд на 8001 через `.env`), число тестов, строка 025.
|
||||||
|
|
||||||
|
## Проверено
|
||||||
|
- `pytest`: 36 из 36 (новые: алгоритм `client_ip` — пустой список, недоверенный peer, цепочка с прокси, все доверенные, мусор, нет заголовка, IPv6;
|
||||||
|
блокировка входа за доверенным прокси; невалидный `TRUSTED_PROXIES`).
|
||||||
|
- `docker compose config`: опубликован 8001 из `.env`; стенд без override — `0.0.0.0:8001->8000`, `/login` 200.
|
||||||
|
- Подделка на стенде: 5 неверных входов с `X-Forwarded-For: 9.9.9.9` → 401; далее без заголовка и с `8.8.8.8` → 429 (заблокирован реальный адрес);
|
||||||
|
события `auth.failed` ×4, `auth.locked` ×1 с IP `172.28.0.1` (шлюз Docker), не `9.9.9.9`. Блокировка снята перезапуском контейнера.
|
||||||
|
- Боевые данные: группы и устройства совпадают по ID; добавлены 5 событий проверки (и 2 бэкапа, запущенные пользователем в UI в это время).
|
||||||
|
|
||||||
|
## Оговорки
|
||||||
|
- С reverse-proxy реальный IP появится только после задания `TRUSTED_PROXIES`; `X-Forwarded-Proto` (для Secure-cookie) не разбирается — флаг задаётся `SESSION_COOKIE_SECURE`.
|
||||||
|
- Ручная проверка UI пользователем на момент коммита не подтверждена.
|
||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
from fastapi import Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app import db, ids, process_lock, s3, security
|
from app import db, ids, process_lock, s3, security
|
||||||
@@ -27,6 +28,12 @@ def ros_client(handler) -> RosClient:
|
|||||||
return RosClient("r1", 443, "admin", "pw", transport=httpx.MockTransport(handler))
|
return RosClient("r1", 443, "admin", "pw", transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
def _request(client_host: str | None, xff: str | None = None) -> Request:
|
||||||
|
"""Минимальный ASGI-запрос для проверки security.client_ip без реального соединения."""
|
||||||
|
headers = [(b"x-forwarded-for", xff.encode())] if xff else []
|
||||||
|
return Request({"type": "http", "client": (client_host, 1) if client_host else None, "headers": headers})
|
||||||
|
|
||||||
|
|
||||||
def test_password_encryption_roundtrip():
|
def test_password_encryption_roundtrip():
|
||||||
token = security.encrypt("s3cret")
|
token = security.encrypt("s3cret")
|
||||||
assert token != "s3cret" and security.decrypt(token) == "s3cret"
|
assert token != "s3cret" and security.decrypt(token) == "s3cret"
|
||||||
@@ -821,6 +828,34 @@ def test_insecure_settings_rejects_weak_secrets_without_leaking_them():
|
|||||||
assert insecure_settings(ok) == []
|
assert insecure_settings(ok) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_insecure_settings_rejects_invalid_trusted_proxies():
|
||||||
|
"""TRUSTED_PROXIES с записью не в формате CIDR — тоже отказ старта; значение (не секрет) видно в тексте проблемы."""
|
||||||
|
ok = Settings(api_token="x" * 32, session_secret="y" * 32, admin_password="z" * 12,
|
||||||
|
secret_key=Fernet.generate_key().decode(), trusted_proxies="10.0.0.0/8, not-a-network")
|
||||||
|
problems = insecure_settings(ok)
|
||||||
|
assert len(problems) == 1 and "TRUSTED_PROXIES" in problems[0] and "not-a-network" in problems[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_trusted_proxies(monkeypatch):
|
||||||
|
"""X-Forwarded-For учитывается, только если TRUSTED_PROXIES не пуст и peer — из доверенной сети (иначе подделка
|
||||||
|
заголовка бессмысленна); цепочка разбирается справа налево до первого недоверенного адреса; невалидная запись
|
||||||
|
обрывает разбор на последнем валидном; работает и с IPv6."""
|
||||||
|
monkeypatch.setenv("TRUSTED_PROXIES", "")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert security.client_ip(_request("1.2.3.4", "9.9.9.9")) == "1.2.3.4" # пусто — XFF полностью игнорируется
|
||||||
|
|
||||||
|
monkeypatch.setenv("TRUSTED_PROXIES", "10.0.0.0/8")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert security.client_ip(_request("9.9.9.9", "9.9.9.9")) == "9.9.9.9" # peer не доверенный — XFF игнорируется
|
||||||
|
assert security.client_ip(_request("10.0.0.1", "1.2.3.4, 10.0.0.2")) == "1.2.3.4" # доверенный прокси пропущен
|
||||||
|
assert security.client_ip(_request("10.0.0.1", "10.0.0.3, 10.0.0.2")) == "10.0.0.1" # все доверенные — peer
|
||||||
|
assert security.client_ip(_request("10.0.0.1", "garbage, 10.0.0.2")) == "10.0.0.2" # мусор — последний валидный
|
||||||
|
assert security.client_ip(_request("10.0.0.1", "garbage")) == "10.0.0.1" # мусор без валидных перед ним — peer
|
||||||
|
assert security.client_ip(_request("10.0.0.1")) == "10.0.0.1" # заголовка нет
|
||||||
|
assert security.client_ip(_request("10.0.0.1", "2001:db8::1, 10.0.0.2")) == "2001:db8::1" # IPv6
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def test_app_refuses_to_start_with_insecure_config(monkeypatch):
|
def test_app_refuses_to_start_with_insecure_config(monkeypatch):
|
||||||
"""Приложение не стартует с небезопасной конфигурацией (например, API_TOKEN=change-me)."""
|
"""Приложение не стартует с небезопасной конфигурацией (например, API_TOKEN=change-me)."""
|
||||||
monkeypatch.setenv("API_TOKEN", "change-me")
|
monkeypatch.setenv("API_TOKEN", "change-me")
|
||||||
@@ -859,6 +894,25 @@ def test_login_lockout_by_ip():
|
|||||||
assert c3.post("/login", data={"username": "admin", "password": "wrong"}).status_code == 401
|
assert c3.post("/login", data={"username": "admin", "password": "wrong"}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_lockout_by_ip_behind_trusted_proxy(monkeypatch):
|
||||||
|
"""За доверенным прокси блокировка и событие auth.* считаются по адресу из X-Forwarded-For, а не по адресу
|
||||||
|
прокси: 5 неверных попыток с одним XFF блокируют этот адрес, попытка с другим XFF (тот же прокси-peer) проходит."""
|
||||||
|
monkeypatch.setenv("TRUSTED_PROXIES", "10.0.0.0/8")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
with TestClient(create_app(), client=("10.0.0.5", 1)) as c:
|
||||||
|
for _ in range(5):
|
||||||
|
r = c.post("/login", data={"username": "admin", "password": "wrong"}, headers={"X-Forwarded-For": "1.1.1.1"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
r = c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD}, headers={"X-Forwarded-For": "1.1.1.1"})
|
||||||
|
assert r.status_code == 429 # заблокирован именно реальный адрес клиента
|
||||||
|
assert json.loads(events.list_events(type_="auth.locked")[0].data)["ip"] == "1.1.1.1"
|
||||||
|
|
||||||
|
r = c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD}, headers={"X-Forwarded-For": "2.2.2.2"},
|
||||||
|
follow_redirects=False)
|
||||||
|
assert r.status_code == 303 # другой адрес за тем же прокси не заблокирован
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def test_move_redirect_rejects_open_redirect_next():
|
def test_move_redirect_rejects_open_redirect_next():
|
||||||
"""`next` в /ui/move принимает только локальный путь — иначе редирект на «/» (открытый редирект)."""
|
"""`next` в /ui/move принимает только локальный путь — иначе редирект на «/» (открытый редирект)."""
|
||||||
with TestClient(create_app()) as c:
|
with TestClient(create_app()) as c:
|
||||||
|
|||||||
Reference in new issue
Block a user