diff --git a/.env.example b/.env.example index 7f0f8b8..6932f87 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index f4079f5..c8150fe 100644 --- a/README.md +++ b/README.md @@ -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 `. - Генерация секретов: `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) diff --git a/app/config.py b/app/config.py index 9965e7d..8ad74ae 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/security.py b/app/security.py index 1bd7f73..ed25577 100644 --- a/app/security.py +++ b/app/security.py @@ -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() diff --git a/app/ui/routes.py b/app/ui/routes.py index 13afd2b..b77459e 100644 --- a/app/ui/routes.py +++ b/app/ui/routes.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 43b49c7..c4c85ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/changes/025-trusted-proxies-port/plan.md b/docs/changes/025-trusted-proxies-port/plan.md new file mode 100644 index 0000000..f50a1bb --- /dev/null +++ b/docs/changes/025-trusted-proxies-port/plan.md @@ -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:` и `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`. diff --git a/docs/changes/025-trusted-proxies-port/summary.md b/docs/changes/025-trusted-proxies-port/summary.md new file mode 100644 index 0000000..7749ba6 --- /dev/null +++ b/docs/changes/025-trusted-proxies-port/summary.md @@ -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 пользователем на момент коммита не подтверждена. diff --git a/tests/test_app.py b/tests/test_app.py index 2f65790..c15cc1d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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: