Безопасность, пункты 1–4 ревью (docs/changes/021): - приложение не стартует со слабыми секретами: API_TOKEN и SESSION_SECRET пустые, change-me или короче 32 символов, ADMIN_PASSWORD короче 12, невалидный SECRET_KEY; значения секретов в ошибку не попадают; - вход в UI: 5 неверных попыток за 10 минут блокируют IP на 10 минут (429, пароль не проверяется); неверный пароль — 401; событие auth.locked пишется один раз, журнал не засыпается перебором; - SESSION_COOKIE_SECURE — флаг Secure у cookie сессии (по умолчанию выкл.); - _safe_next: редирект только на локальный путь (/ui/move, удаление бэкапов); - .env.bak-* в .gitignore. Тесты: 28 из 28. Стенд: change-me → 401, новый токен → 200, отказ старта со слабым токеном, блокировка входа и редиректы проверены, боевые данные не изменены. Ручная проверка UI пользователем на момент коммита не подтверждена. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
10 KiB
Markdown
88 lines
10 KiB
Markdown
# План: 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 ошибок.
|