Безопасность, пункты 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>
10 KiB
10 KiB
План: 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".- если
lockout_remaining(key) > 0— пароль не проверяется, форма с ошибкой «Слишком много попыток входа. Повторите через N мин.», код 429, событиеauth.locked(IP, введённый логин ≤ 64 символов); - неверные данные —
register_failure(key); при 0 оставшихся — событиеauth.locked, иначеauth.failed(вdata— IP и число оставшихся попыток); ответ, как сейчас (ошибка «Неверный логин или пароль», код 200 → 401); - успех —
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 (TestClientclient=("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 ошибок.