Хардненинг по итогам пен-теста #25 (#56–#62) #63
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||||
@@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -50,7 +57,7 @@ class Settings(BaseSettings):
|
|||||||
prod_achievements_dir: str = "/data/achievements"
|
prod_achievements_dir: str = "/data/achievements"
|
||||||
|
|
||||||
# JWT / cookie
|
# JWT / cookie
|
||||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
secret_key: str = _DEFAULT_SECRET_KEY
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||||
jwt_admin_ttl_minutes: int = 60 * 8
|
jwt_admin_ttl_minutes: int = 60 * 8
|
||||||
@@ -66,7 +73,7 @@ class Settings(BaseSettings):
|
|||||||
# Бутстрап администратора
|
# Бутстрап администратора
|
||||||
admin_bootstrap_enabled: bool = True
|
admin_bootstrap_enabled: bool = True
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = "change-me-admin-password"
|
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
||||||
admin_nickname: str = "Администратор"
|
admin_nickname: str = "Администратор"
|
||||||
|
|
||||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||||
@@ -115,6 +122,31 @@ class Settings(BaseSettings):
|
|||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or 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
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
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