From cff48f7cc76e2f4c31c03325da0e0de3cfeb6678 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 23:52:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C:=20=D0=BE=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?dev=20=D0=BD=D0=B5=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D1=83=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=D0=B8=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При 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 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- .env.example | 4 ++ backend/app/core/config.py | 39 ++++++++++------ backend/app/main.py | 10 +++++ backend/tests/test_config_security.py | 65 ++++++++++++++++++++++++++- run.ps1 | 3 ++ run.sh | 7 ++- 6 files changed, 113 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index add725e..eede0a4 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ APP_ENV=development # ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ───────────────────────────────────── # LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost; # vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru. +# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля, +# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY +# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69). # PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему # LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает. LOCAL_PUBLIC=local @@ -54,6 +57,7 @@ PUBLIC_BASE_URL= # Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))" # ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как # подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно). +# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод. SECRET_KEY=change-me-dev-secret-not-for-production JWT_ALGORITHM=HS256 JWT_USER_TTL_MINUTES=10080 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index dfca3b6..b91f37e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 1b43672..7e78e7f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -132,6 +132,16 @@ async def _lifespan(_app: FastAPI): hub.bind_loop(asyncio.get_running_loop()) + if settings.is_development and settings.is_published: + # Решение владельца (#69): dev-инструменты остаются и на опубликованном dev — + # но о том, что они открыты любому посетителю домена, нужно сказать громко. + logging.getLogger("fs").warning( + "DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты " + "вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов " + "и Swagger. Не держите в dev-базе копию прод-данных.", + settings.local_public, + ) + # В DEV приложение само подтягивает справочники и админа из .env при старте # (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0": diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py index 965d38e..8c87325 100644 --- a/backend/tests/test_config_security.py +++ b/backend/tests/test_config_security.py @@ -1,6 +1,9 @@ -"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4).""" +"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не +стартует с дефолтными секретами (#59, F4, #69).""" from __future__ import annotations +import logging + import pytest from pydantic import ValidationError @@ -61,10 +64,70 @@ def test_production_skips_admin_check_when_bootstrap_disabled(): def test_development_allows_defaults(): s = config.Settings( app_env="development", + local_public="local", secret_key=config._DEFAULT_SECRET_KEY, admin_password=config._DEFAULT_ADMIN_PASSWORD, ) assert s.is_development + assert not s.is_published and not s.cookie_secure + + +# ─── Dev, опубликованный на домен (LOCAL_PUBLIC=vps, #69) ───────────────────── +# Снаружи он так же доступен, как прод: общеизвестный ключ JWT и пароль админа там +# открывают админку и подделку любого токена. + + +@pytest.mark.parametrize( + "secret_key,admin_password", + [ + (config._DEFAULT_SECRET_KEY, _STRONG_ADMIN_PW), + ("too-short", _STRONG_ADMIN_PW), + (_STRONG_SECRET, config._DEFAULT_ADMIN_PASSWORD), + ], + ids=["default-secret", "short-secret", "default-admin-password"], +) +def test_published_dev_rejects_weak_secrets(secret_key, admin_password): + with pytest.raises(ValidationError, match="LOCAL_PUBLIC=vps"): + config.Settings( + app_env="development", + local_public="vps", + secret_key=secret_key, + admin_password=admin_password, + ) + + +def test_published_dev_accepts_strong_secrets(): + s = config.Settings( + app_env="development", + local_public="vps", + secret_key=_STRONG_SECRET, + admin_password=_STRONG_ADMIN_PW, + ) + assert s.is_development and s.is_published and s.cookie_secure + + +def test_published_dev_warns_on_startup(monkeypatch, caplog): + """Dev-инструменты на опубликованном dev остаются (решение владельца) — но старт + громко перечисляет, что открыто любому посетителю домена.""" + from fastapi.testclient import TestClient + + from app import main + + monkeypatch.setattr(main.settings, "local_public", "vps") + with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()): + pass + assert "DEV ОПУБЛИКОВАН НАРУЖУ" in caplog.text + assert "вход по нику без пароля" in caplog.text + + +def test_local_dev_starts_quietly(caplog): + from fastapi.testclient import TestClient + + from app import main + + with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()): + pass + assert "DEV ОПУБЛИКОВАН НАРУЖУ" not in caplog.text @pytest.mark.parametrize("app_env", ["test", "staging", ""]) diff --git a/run.ps1 b/run.ps1 index 03ab897..c19cae2 100644 --- a/run.ps1 +++ b/run.ps1 @@ -128,6 +128,9 @@ switch ($appEnv) { if ($localPublic -eq "vps") { Start-VpsTunnel 5173 Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green + Write-Host " WARNING: dev is public. Anyone can log in by nickname without a password," -ForegroundColor Yellow + Write-Host " list/create players, hard-delete accounts and read Swagger." -ForegroundColor Yellow + Write-Host " Do not keep a copy of production data in the dev database." -ForegroundColor Yellow } } "production" { diff --git a/run.sh b/run.sh index f36818a..b959ef3 100644 --- a/run.sh +++ b/run.sh @@ -75,7 +75,12 @@ case "$app_env" in trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)" echo " Фронт: http://127.0.0.1:5173" - [ "$local_public" = "vps" ] && start_tunnel 5173 + if [ "$local_public" = "vps" ]; then + start_tunnel 5173 + echo " ВНИМАНИЕ: dev опубликован. Любой посетитель может войти по нику без пароля," + echo " смотреть и создавать игроков, жёстко удалять аккаунты и читать Swagger." + echo " Не держите в dev-базе копию прод-данных." + fi ( cd "$root/frontend" && npm run dev ) ;; production)