Безопасность: опубликованный dev не стартует с дефолтными секретами

При LOCAL_PUBLIC=vps dev доступен на forbidden-stars.ru, а fail-fast по
SECRET_KEY/ADMIN_PASSWORD работал только в production: снаружи оставались
общеизвестный ключ JWT (подделка любого токена, включая админский) и пароль
админки. Теперь проверка срабатывает при is_published — у прода и у dev на
домене; на нём же cookie_secure.

Dev-инструменты и Swagger на опубликованном dev остаются (решение владельца):
лаунчеры и лог старта перечисляют, что открыто любому посетителю. В .env.example —
что открывает vps и что у dev и prod должны быть разные SECRET_KEY. #69

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-18 23:52:25 +03:00
co-authored by Claude Opus 5
parent bb5f2a1121
commit cff48f7cc7
6 changed files with 113 additions and 15 deletions
+26 -13
View File
@@ -12,8 +12,8 @@ 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).
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32
@@ -38,7 +38,8 @@ class Settings(BaseSettings):
# Публикация локального dev-окружения наружу через VPS-туннель.
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
# от этого зависят Secure-cookie и проверка секретов при старте.
local_public: str = "local"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
@@ -111,11 +112,18 @@ class Settings(BaseSettings):
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
@property
def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
нативный dev на localhost по HTTP (development + local_public=local)."""
def is_published(self) -> bool:
"""Приложение доступно снаружи по домену: production или dev, выставленный через
VPS-туннель. Не опубликован только нативный dev на localhost
(development + local_public=local)."""
return not (self.is_development and self.local_public.lower() == "local")
@property
def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
return self.is_published
@property
def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None
@@ -132,13 +140,13 @@ class Settings(BaseSettings):
return value
@model_validator(mode="after")
def _forbid_default_secrets_in_prod(self) -> "Settings":
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
def _forbid_default_secrets_when_published(self) -> "Settings":
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
if self.app_env.lower() != "production":
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
if not self.is_published:
return self
problems: list[str] = []
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
@@ -150,8 +158,13 @@ class Settings(BaseSettings):
if not password or password == _DEFAULT_ADMIN_PASSWORD:
problems.append("ADMIN_PASSWORD не задан или дефолтный")
if problems:
where = (
"production"
if self.is_production
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
)
raise ValueError(
"Небезопасная конфигурация production — задайте секреты в .env: "
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
+ "; ".join(problems)
)
return self