Конфиг: 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:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core import config
|
||||
|
||||
_STRONG_SECRET = "k" * 40
|
||||
_STRONG_ADMIN_PW = "a-strong-admin-password"
|
||||
|
||||
|
||||
def test_production_rejects_default_secret_key():
|
||||
with pytest.raises(ValidationError):
|
||||
config.Settings(
|
||||
app_env="production",
|
||||
secret_key=config._DEFAULT_SECRET_KEY,
|
||||
admin_password=_STRONG_ADMIN_PW,
|
||||
)
|
||||
|
||||
|
||||
def test_production_rejects_short_secret_key():
|
||||
with pytest.raises(ValidationError):
|
||||
config.Settings(
|
||||
app_env="production",
|
||||
secret_key="too-short",
|
||||
admin_password=_STRONG_ADMIN_PW,
|
||||
)
|
||||
|
||||
|
||||
def test_production_rejects_default_admin_password():
|
||||
with pytest.raises(ValidationError):
|
||||
config.Settings(
|
||||
app_env="production",
|
||||
secret_key=_STRONG_SECRET,
|
||||
admin_bootstrap_enabled=True,
|
||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def test_production_accepts_strong_secrets():
|
||||
s = config.Settings(
|
||||
app_env="production",
|
||||
secret_key=_STRONG_SECRET,
|
||||
admin_password=_STRONG_ADMIN_PW,
|
||||
)
|
||||
assert s.is_production
|
||||
|
||||
|
||||
def test_production_skips_admin_check_when_bootstrap_disabled():
|
||||
# Админ управляется вручную (bootstrap off) — дефолтный ADMIN_PASSWORD не блокирует старт.
|
||||
s = config.Settings(
|
||||
app_env="production",
|
||||
secret_key=_STRONG_SECRET,
|
||||
admin_bootstrap_enabled=False,
|
||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||
)
|
||||
assert s.is_production
|
||||
|
||||
|
||||
def test_development_allows_defaults():
|
||||
s = config.Settings(
|
||||
app_env="development",
|
||||
secret_key=config._DEFAULT_SECRET_KEY,
|
||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||
)
|
||||
assert s.is_development
|
||||
Reference in New Issue
Block a user