Конфиг: fail-fast на дефолтных секретах в production

model_validator в Settings: при APP_ENV=production приложение не стартует, если
SECRET_KEY дефолтный/короче 32 символов или (при включённом бутстрапе) ADMIN_PASSWORD
дефолтный/пустой. dev/test не затронуты — там дефолты остаются нормой. #59

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
2026-09-13 17:18:08 +03:00
co-authored by Claude Opus 4.8
parent 112b583264
commit 0799aee684
2 changed files with 101 additions and 2 deletions
+34 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
@@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# (исключён из образа) — там настройки приходят переменными от docker compose.
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -50,7 +57,7 @@ class Settings(BaseSettings):
prod_achievements_dir: str = "/data/achievements"
# JWT / cookie
secret_key: str = "change-me-dev-secret-not-for-production"
secret_key: str = _DEFAULT_SECRET_KEY
jwt_algorithm: str = "HS256"
jwt_user_ttl_minutes: int = 60 * 24 * 7
jwt_admin_ttl_minutes: int = 60 * 8
@@ -66,7 +73,7 @@ class Settings(BaseSettings):
# Бутстрап администратора
admin_bootstrap_enabled: bool = True
admin_username: str = "admin"
admin_password: str = "change-me-admin-password"
admin_password: str = _DEFAULT_ADMIN_PASSWORD
admin_nickname: str = "Администратор"
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
@@ -115,6 +122,31 @@ class Settings(BaseSettings):
def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None
@model_validator(mode="after")
def _forbid_default_secrets_in_prod(self) -> "Settings":
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
if self.app_env.lower() != "production":
return self
problems: list[str] = []
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
problems.append(
f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов"
)
if self.admin_bootstrap_enabled:
password = (self.admin_password or "").strip()
if not password or password == _DEFAULT_ADMIN_PASSWORD:
problems.append("ADMIN_PASSWORD не задан или дефолтный")
if problems:
raise ValueError(
"Небезопасная конфигурация production — задайте секреты в .env: "
+ "; ".join(problems)
)
return self
@lru_cache
def get_settings() -> Settings: