# План: 021 — безопасность (по ревью 2026-09-27) ## Context Ревью `docs/reviews/2026-09-27-codebase-review.md`, раздел «Безопасность», пункты 1–4: - **п. 1** — `API_TOKEN`, `ADMIN_PASSWORD`, `SESSION_SECRET` по умолчанию `change-me` (`app/config.py`) и при старте не проверяются. На стенде так и было: `API_TOKEN=change-me` — API был открыт с общеизвестным токеном. - **п. 2** — вход в UI (`POST /login`, `app/ui/routes.py::login`) без ограничения попыток: пароль администратора можно перебирать. - **п. 3** — сессионная cookie без флага Secure (`SessionMiddleware(..., https_only=False)` в `app/main.py`). - **п. 4** — `POST /ui/move`: проверка `next` (`startswith("/") and not startswith("//")`) пропускает `/\evil.com`, которое браузеры трактуют как `//evil.com` — открытый редирект. Решения пользователя: - п. 1 — при старте приложение **не запускается**, если `API_TOKEN`, `ADMIN_PASSWORD`, `SESSION_SECRET` пустые или `change-me`, а также при недостаточной длине: `API_TOKEN` и `SESSION_SECRET` ≥ 32 символов, `ADMIN_PASSWORD` ≥ 12. - `.env` стенда — оркестратор уже сгенерировал новые `API_TOKEN` (43), `SESSION_SECRET` (64), `ADMIN_PASSWORD` (20); прежний файл — `.env.bak-20260928` (добавлен в `.gitignore` шаблоном `.env.bak-*`). Сессии UI и внешние скрипты со старым токеном перестанут работать. - п. 2 — блокировка **по IP клиента**: 5 неверных попыток за 10 минут → IP заблокирован на 10 минут. Администратора (единственного) нельзя заблокировать чужими попытками. Reverse-proxy сейчас нет — `request.client.host` достоверен (ограничение описать в README). - п. 3 — настройка `SESSION_COOKIE_SECURE`, **по умолчанию `false`** (UI по http продолжает работать); README — включить за TLS. ## Изменения ### п. 1 — проверка секретов при старте (`app/config.py`, `app/main.py`, `.env.example`, `tests/conftest.py`) - `config.py`: `def insecure_settings(s: Settings) -> list[str]` — список проблем **без значений секретов** (например, «API_TOKEN: пустой или change-me», «SESSION_SECRET: короче 32 символов», «SECRET_KEY: не ключ Fernet»). Правила: `API_TOKEN`, `SESSION_SECRET` — не пустые, не `change-me`, длина ≥ 32; `ADMIN_PASSWORD` — не пустой, не `change-me`, длина ≥ 12; `SECRET_KEY` — не пустой и валидный ключ Fernet (`Fernet(key)` без исключения) — сейчас пустой ключ всплывает только при первом шифровании. Константы минимальной длины — рядом с правилами. - `main.py::lifespan`: **первым шагом** (до `process_lock.acquire`) — если список не пуст, `RuntimeError("Небезопасная конфигурация: …; см. README «Настройки»")`, приложение не стартует. `create_app()` проверку не делает (модуль импортируется в тестах). - `.env.example`: у секретов — пустые значения вместо `change-me` и комментарии с требованиями и командой генерации (`python -c "import secrets; print(secrets.token_urlsafe(32))"`). - `tests/conftest.py`: `API_TOKEN`, `ADMIN_PASSWORD` — значения, проходящие проверку (≥ 32 / ≥ 12); обновить тесты, где эти значения захардкожены (`"pw"`, `"test-token"`) — через общие константы в conftest, а не копипастой. ### п. 2 — ограничение попыток входа по IP (`app/security.py`, `app/ui/routes.py`, `app/ui/templates/login.html`) - **Переиспользовать** счётчики `security.lockout_remaining / register_failure / reset_failures` (5 за 10 минут → блок 10 минут). Они сейчас ключуются именем пользователя (очистка журнала) — параметр переименовать в `key`, для входа ключ `f"login:{ip}"`, для очистки журнала — прежний (имя пользователя), чтобы пространства ключей не пересекались. Поведение очистки журнала не меняется. - `login`: `ip = request.client.host if request.client else "unknown"`. 1) если `lockout_remaining(key) > 0` — пароль **не проверяется**, форма с ошибкой «Слишком много попыток входа. Повторите через N мин.», код 429, событие `auth.locked` (IP, введённый логин ≤ 64 символов); 2) неверные данные — `register_failure(key)`; при 0 оставшихся — событие `auth.locked`, иначе `auth.failed` (в `data` — IP и число оставшихся попыток); ответ, как сейчас (ошибка «Неверный логин или пароль», код 200 → **401**); 3) успех — `reset_failures(key)`, событие `auth.login` (в `data` добавить IP). - `_EV_KIND` в `routes.py`: `auth.locked` — в группу `bad`. - Пароли и секреты в журнал не пишутся (как сейчас). ### п. 3 — Secure-cookie (`app/config.py`, `app/main.py`, `.env.example`, README) - `Settings.session_cookie_secure: bool = False`; `SessionMiddleware(..., https_only=get_settings().session_cookie_secure)`. ### п. 4 — безопасный `next` (`app/ui/routes.py`) - Хелпер `_safe_next(value: str, default: str = "/", prefix: str = "/") -> str`: допускается только локальный путь — начинается с `prefix`, не начинается с `//`, не содержит `\` и управляющих символов (`ord < 32`, `\x7f`), `urllib.parse.urlsplit(value)` без `scheme` и `netloc`; иначе `default`. - Применить в `move` (`prefix="/"`) и в `backups_delete_many` (`prefix="/backups"`, вместо текущего `startswith("/backups")`). - Найти (grep `RedirectResponse(` и параметры `next`/`back`) другие редиректы с пользовательским вводом и применить тот же хелпер. ## Тесты (минимально, `tests/test_app.py`) - п. 1: `insecure_settings` для `change-me`, коротких значений и невалидного `SECRET_KEY` возвращает проблемы и **не содержит значений секретов**; запуск `TestClient(create_app())` с `API_TOKEN=change-me` падает с `RuntimeError`. - п. 2: 5 неверных входов → 6-й (даже с верным паролем) → 429 и событие `auth.locked`; с другого IP (TestClient `client=("10.0.0.2", 123)`) вход работает; после успешного входа счётчик сброшен. - п. 4: `POST /ui/move` с `next` = `/\evil.com`, `//evil.com`, `https://evil.com`, `/%0d%0aX` → `Location: /`; `next=/?f_group=none` сохраняется. - п. 3 — без отдельного теста (одна строка конфигурации). ## Документация README: «Безопасность» (требования к секретам и отказ старта, блокировка входа по IP и ограничение за reverse-proxy, `SESSION_COOKIE_SECURE`), «Настройки» (новые требования, `SESSION_COOKIE_SECURE`), число тестов, строка 021 в истории изменений. `summary.md` — оркестратор. ## Исполнение Исполнитель (Sonnet): код, тесты, README, `.env.example`, пересборка стенда. `.env` стенда **не трогает** (уже обновлён оркестратором) и не выводит. Тесты не запускает, не коммитит. ## Проверка - `pytest` — все зелёные. - Стенд (override 8001, `--force-recreate`): стартует с новым `.env`; `/login` → 200; новый код в контейнере. - Отказ старта: временный контейнер из того же образа **без тома** с `API_TOKEN=change-me` → `RuntimeError`, в логе нет значений секретов. - API: старый токен `change-me` → 401, новый → 200. - Вход: 5 неверных паролей с одного адреса → 6-я попытка 429 даже с верным паролем; события `auth.failed` ×4, `auth.locked`; верный пароль с другого адреса — вход (стенд без прокси: второй адрес — запрос изнутри контейнера или с другого интерфейса хоста). После проверки — блокировка в памяти снимается перезапуском контейнера (данные не затрагиваются). - Редирект: `POST /ui/move` с `next=/\evil.com` → `Location: /`. - Боевые данные: сверка по ID до/после (согласованная копия через `sqlite3 backup`). - Ручная проверка UI — пользователь: вход с новым паролем из `.env`, сообщение о блокировке после 5 ошибок.