Доверенные прокси (реальный 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:
ayurishchevandClaude Opus 5.5 committed 2026-09-28 21:19:10 +03:00
1 parent 6ff7f1b5f1
commit 5367cb5e9c
9 files changed
+211 -8

No files matched your search

+9
View File
@@ -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
@@ -18,6 +23,10 @@ API_TOKEN=
# Без заданных выше значений (или со значением change-me/короче требуемой длины) приложение не запускается —
# см. README «Безопасность».
# Доверенные прокси (CIDR через запятую): только от них принимается X-Forwarded-For для определения IP клиента
# (блокировка входа, журнал). Пусто по умолчанию — заголовок всегда игнорируется, берётся адрес TCP-соединения.
TRUSTED_PROXIES=
# Yandex Object Storage (S3)
S3_ENDPOINT=https://storage.yandexcloud.net
S3_REGION=ru-central1
+9 -4
View File
@@ -15,7 +15,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
cp .env.example .env # заполнить SECRET_KEY, SESSION_SECRET, ADMIN_PASSWORD, API_TOKEN, S3_* (см. «Конфигурация»)
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>`.
- Генерация секретов: `python -c "import secrets; print(secrets.token_urlsafe(32))"`;
ключ 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`)
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `APP_PORT` | `8000` | Порт хоста, публикуемый `docker-compose.yml` (читает только compose, не приложение) |
| `APP_BIND` | `0.0.0.0` | Адрес публикации порта; `127.0.0.1` — только за reverse-proxy на том же хосте |
| `SECRET_KEY` | — | Ключ Fernet для паролей устройств. Обязателен; потеря ключа — потеря доступа к сохранённым паролям |
| `SESSION_SECRET` | — | Подпись cookie-сессии UI. Обязателен, ≥ 32 символов |
| `SESSION_COOKIE_SECURE` | `false` | Флаг Secure у cookie сессии; включить, когда приложение работает за TLS |
| `ADMIN_USER`, `ADMIN_PASSWORD` | `admin`, — | Вход в UI. Пароль обязателен, ≥ 12 символов |
| `API_TOKEN` | — | Bearer-токен API. Обязателен, ≥ 32 символов |
| `TRUSTED_PROXIES` | пусто | Доверенные прокси (CIDR через запятую). Только от них принимается `X-Forwarded-For` для IP клиента; пусто — заголовок всегда игнорируется |
| `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` | Бакет резервных копий |
| `BACKUPS_CACHE_TTL` | `60` | Время жизни кэша списка бакета, с; `0` — без кэша |
@@ -140,7 +143,8 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
**Вход в UI**
- 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 минут.
- Редиректы после форм — только на локальный путь.
@@ -154,7 +158,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
- **Один процесс на БД**: файловая блокировка `<файл БД>.lock`; второй процесс (`--workers 2+`, вторая копия контейнера на том же томе) не стартует. В памяти процесса — семафор задач, счётчики неудачных паролей, кэш бакета.
- **Кэш бакета**: страница «Бэкапы» и `GET /api/v1/backups` перечитывают бакет не чаще `BACKUPS_CACHE_TTL`; собственные изменения сбрасывают кэш, внешние видны по «Обновить список».
- **Копия БД**: 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
python3 -m venv venv && venv/bin/pip install -r requirements.txt
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) |
| 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) |
| 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)
+16
View File
@@ -1,3 +1,4 @@
import ipaddress
from functools import lru_cache
from cryptography.fernet import Fernet
@@ -40,12 +41,22 @@ class Settings(BaseSettings):
events_retention_days: int = 90 # 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
def get_settings() -> 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]:
"""Проблемы конфигурации, с которыми приложению нельзя стартовать. Без значений секретов — только названия проблем."""
problems = []
@@ -68,4 +79,9 @@ def insecure_settings(s: Settings) -> list[str]:
Fernet(s.secret_key.encode())
except Exception: # noqa: BLE001
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
+36 -2
View File
@@ -1,12 +1,13 @@
import hmac
import ipaddress
import threading
import time
from cryptography.fernet import Fernet
from fastapi import Depends, HTTPException
from fastapi import Depends, HTTPException, Request
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)
@@ -33,6 +34,39 @@ def check_admin(user: str, password: str) -> bool:
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) и подтверждение действий паролем (очистка журнала, по пользователю) ---
FAIL_LIMIT, WINDOW_S, LOCK_S = 5, 600, 600 # 5 неверных за 10 минут → блокировка на 10 минут
_guard = threading.Lock()
+1 -1
View File
@@ -142,7 +142,7 @@ def login_form(request: Request):
def login(request: Request, username: str = Form(), password: str = Form()):
"""Вход в UI: попытки ограничены по IP клиента (не по имени — иначе одного администратора
можно заблокировать чужими неверными попытками). При блокировке пароль не проверяется."""
ip = request.client.host if request.client else "unknown"
ip = security.client_ip(request)
key = f"login:{ip}"
events.set_actor("anonymous")
lock = security.lockout_remaining(key)
+1 -1
View File
@@ -5,7 +5,7 @@ services:
environment:
DATABASE_URL: sqlite:////srv/data/ros_control.db
ports:
- "8000:8000"
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"
volumes:
- ros_data:/srv/data # именованный том: права наследуются от образа (uid 10001)
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 пользователем на момент коммита не подтверждена.
+54
View File
@@ -10,6 +10,7 @@ from pathlib import Path
import httpx
import pytest
from cryptography.fernet import Fernet
from fastapi import Request
from fastapi.testclient import TestClient
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))
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():
token = security.encrypt("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) == []
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):
"""Приложение не стартует с небезопасной конфигурацией (например, 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
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():
"""`next` в /ui/move принимает только локальный путь — иначе редирект на «/» (открытый редирект)."""
with TestClient(create_app()) as c: