Безопасность: проверка секретов, блокировка перебора входа, безопасные редиректы

Безопасность, пункты 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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-28 12:41:58 +03:00
1 parent 6b5c521461
commit c4079724c5
11 files changed
+332 -58

No files matched your search

+16 -13
View File
@@ -33,7 +33,7 @@ def check_admin(user: str, password: str) -> bool:
return ok_user and ok_pass
# --- подтверждение действий паролем (очистка журнала) и защита от перебора ---
# --- защита от перебора: вход в UI (по IP) и подтверждение действий паролем (очистка журнала, по пользователю) ---
FAIL_LIMIT, WINDOW_S, LOCK_S = 5, 600, 600 # 5 неверных за 10 минут → блокировка на 10 минут
_guard = threading.Lock()
_fails: dict[str, list[float]] = {}
@@ -47,29 +47,32 @@ def verify_password(user: str, password: str) -> bool:
and hmac.compare_digest(password.encode(), s.admin_password.encode()))
def lockout_remaining(user: str) -> int:
"""Сколько секунд осталось до конца блокировки (0 — не заблокирован)."""
def lockout_remaining(key: str) -> int:
"""Сколько секунд осталось до конца блокировки по ключу (0 — не заблокирован).
Ключ — своё пространство на каждый вид перебора (например, `f"login:{ip}"` для входа в UI
и имя пользователя для очистки журнала), чтобы счётчики не пересекались."""
with _guard:
return max(0, int(_locked_until.get(user, 0) - time.monotonic() + 0.999))
return max(0, int(_locked_until.get(key, 0) - time.monotonic() + 0.999))
def register_failure(user: str) -> int:
"""Учитывает неверный пароль; возвращает число оставшихся попыток (0 — пользователь заблокирован)."""
def register_failure(key: str) -> int:
"""Учитывает неверную попытку по ключу; возвращает число оставшихся попыток (0 — ключ заблокирован)."""
now = time.monotonic()
with _guard:
recent = [t for t in _fails.get(user, []) if now - t < WINDOW_S] + [now]
_fails[user] = recent
recent = [t for t in _fails.get(key, []) if now - t < WINDOW_S] + [now]
_fails[key] = recent
if len(recent) >= FAIL_LIMIT:
_locked_until[user] = now + LOCK_S
_fails[user] = []
_locked_until[key] = now + LOCK_S
_fails[key] = []
return 0
return FAIL_LIMIT - len(recent)
def reset_failures(user: str) -> None:
def reset_failures(key: str) -> None:
with _guard:
_fails.pop(user, None)
_locked_until.pop(user, None)
_fails.pop(key, None)
_locked_until.pop(key, None)
async def require_api_token(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None: