From 0799aee684d6ff9bb655a870eb1ca1df07cc0482 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:18:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=84=D0=B8=D0=B3:=20fail-fa?= =?UTF-8?q?st=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0=D1=85?= =?UTF-8?q?=20=D0=B2=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model_validator в Settings: при APP_ENV=production приложение не стартует, если SECRET_KEY дефолтный/короче 32 символов или (при включённом бутстрапе) ADMIN_PASSWORD дефолтный/пустой. dev/test не затронуты — там дефолты остаются нормой. #59 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/core/config.py | 36 +++++++++++++- backend/tests/test_config_security.py | 67 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_config_security.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e73c3aa..0085fbe 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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: diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py new file mode 100644 index 0000000..8c73066 --- /dev/null +++ b/backend/tests/test_config_security.py @@ -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