Безопасность: проверка секретов, блокировка перебора входа, безопасные редиректы
Безопасность, пункты 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:
1 parent
6b5c521461
commit
c4079724c5
11 files changed
+332
-58
No files matched your search
+16
-13
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user