Files
ayurishchevandClaude Opus 5.5 c4079724c5 Безопасность: проверка секретов, блокировка перебора входа, безопасные редиректы
Безопасность, пункты 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>
2026-09-28 12:41:58 +03:00

10 KiB
Raw Permalink Blame History

План: 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.
  • Пароли и секреты в журнал не пишутся (как сейчас).
  • 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 ошибок.