From 69294d68a0ee52e7ac7c2368157c5e948a67fff9 Mon Sep 17 00:00:00 2001 From: failsafesecurity Date: Fri, 9 Oct 2026 17:12:44 -0700 Subject: [PATCH 1/2] fix(security): fail closed on default/weak SECRET_KEY and stop shipping .env The template shipped FASTAPI_ENV=development together with SECRET_KEY=changethis, and the placeholder check only warned in development. Because the same HS256 key signs and verifies access tokens and password-reset tokens, a shipped default key let anyone forge a JWT for any user, including the built-in superuser (CWE-321, CVSS 9.3). - Make _check_default_secret raise unconditionally (all environments); remove the development warning path so the app refuses to start on a placeholder secret. - Enforce a minimum SECRET_KEY length (32 chars). - Stop tracking .env and add it to .gitignore/.dockerignore; add .env.example. - Add scripts/generate-env.sh to generate strong random secrets for local dev/CI. - Wire the generator into the backend, docker-compose, and playwright workflows. - Update development.md and add a fail-closed config regression test. --- .dockerignore | 5 ++ .env | 18 ------- .env.example | 28 +++++++++++ .github/workflows/playwright.yml | 3 ++ .github/workflows/test-backend.yml | 3 ++ .github/workflows/test-docker-compose.yml | 3 ++ .gitignore | 6 +++ backend/app/core/config.py | 21 ++++++-- backend/tests/test_config_security.py | 58 +++++++++++++++++++++++ development.md | 10 +++- scripts/generate-env.sh | 41 ++++++++++++++++ 11 files changed, 172 insertions(+), 24 deletions(-) delete mode 100644 .env create mode 100644 .env.example create mode 100644 backend/tests/test_config_security.py create mode 100755 scripts/generate-env.sh diff --git a/.dockerignore b/.dockerignore index c892b070cd..2a40427baa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,8 @@ frontend/node_modules frontend/playwright-report frontend/test-results node_modules + +# Local environment files hold secrets and must never be sent to the Docker daemon. +.env +.env.* +!.env.example diff --git a/.env b/.env deleted file mode 100644 index e16784221f..0000000000 --- a/.env +++ /dev/null @@ -1,18 +0,0 @@ -# Enable development behavior for commands that import the app directly -FASTAPI_ENV=development - -PROJECT_NAME="Full Stack FastAPI Project" - -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis - -# Emails -SMTP_HOST=localhost -EMAILS_FROM_EMAIL=info@example.com -SMTP_TLS=False -SMTP_PORT=1025 - -# Postgres -POSTGRES_PASSWORD=changethis -DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/app diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..15f68dee2a --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy this file to `.env` and replace every placeholder below with a real value. +# Generate strong random secrets with, for example: +# python -c 'import secrets; print(secrets.token_urlsafe(32))' +# +# `.env` is ignored by git and Docker. Never commit real secrets. + +# Enable development behavior for commands that import the app directly. +# Remove this line for non-development runs so the app boots with production +# defaults (this does not weaken any check: placeholder secrets are rejected in +# every environment). +FASTAPI_ENV=development + +PROJECT_NAME="Full Stack FastAPI Project" + +# Required. At least 32 characters. Placeholder values are rejected at startup. +SECRET_KEY= +FIRST_SUPERUSER=admin@example.com +FIRST_SUPERUSER_PASSWORD= + +# Emails +SMTP_HOST=localhost +EMAILS_FROM_EMAIL=info@example.com +SMTP_TLS=False +SMTP_PORT=1025 + +# Postgres +POSTGRES_PASSWORD= +DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/app diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 102f2d53a8..15a39b54eb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -65,6 +65,9 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} with: limit-access-to-actor: true + # Generate a local .env with strong random secrets (see scripts/generate-env.sh). + # Placeholder secrets are rejected at startup, so CI must provide real ones. + - run: bash scripts/generate-env.sh - name: Install uv uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index df6c93f5ca..c907c7fb81 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -25,6 +25,9 @@ jobs: uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: version: "latest-known" + # Generate a local .env with strong random secrets (see scripts/generate-env.sh). + # Placeholder secrets are rejected at startup, so CI must provide real ones. + - run: bash scripts/generate-env.sh - run: docker compose down -v --remove-orphans - run: docker compose up -d --wait db mailpit - name: Migrate DB diff --git a/.github/workflows/test-docker-compose.yml b/.github/workflows/test-docker-compose.yml index 4551a15760..4d6834371d 100644 --- a/.github/workflows/test-docker-compose.yml +++ b/.github/workflows/test-docker-compose.yml @@ -18,6 +18,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # Generate a local .env with strong random secrets (see scripts/generate-env.sh). + # Placeholder secrets are rejected at startup, so CI must provide real ones. + - run: bash scripts/generate-env.sh - run: docker compose build - run: docker compose down -v --remove-orphans - run: docker compose run --rm backend bash scripts/prestart.sh diff --git a/.gitignore b/.gitignore index 3c697eb7da..30c7dc80d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ .vscode/* !.vscode/extensions.json node_modules/ + +# Local environment files hold secrets and must never be committed. +.env +.env.* +!.env.example + backend/app/frontend/ /test-results/ /playwright-report/ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 095f72eeca..f3e2a66598 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,4 +1,3 @@ -import warnings from typing import Literal, Self from pydantic import ( @@ -11,6 +10,10 @@ ) from pydantic_settings import BaseSettings, SettingsConfigDict +# Minimum acceptable length (in characters) for the token signing key. A shorter +# key is treated as insecure and rejected outright, in every environment. +MINIMUM_SECRET_KEY_LENGTH = 32 + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -66,19 +69,27 @@ def emails_enabled(self) -> bool: FIRST_SUPERUSER_PASSWORD: str def _check_default_secret(self, var_name: str, value: str | None) -> None: + # Fail closed in *every* environment (including development): a + # placeholder secret must never be silently accepted, because the same + # signing key is used to verify access and password-reset tokens, so a + # known default allows anyone to forge a token for any user. if value == "changethis": message = ( f'The value of {var_name} is "changethis", ' "for security, please change it, at least for deployments." ) - if self.FASTAPI_ENV == "development": - warnings.warn(message, stacklevel=1) - else: - raise ValueError(message) + raise ValueError(message) @model_validator(mode="after") def _enforce_non_default_secrets(self) -> Self: self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + if len(self.SECRET_KEY) < MINIMUM_SECRET_KEY_LENGTH: + raise ValueError( + "SECRET_KEY must be at least " + f"{MINIMUM_SECRET_KEY_LENGTH} characters long, " + "for security, please generate a strong random value " + "(e.g. `python -c 'import secrets; print(secrets.token_urlsafe(32))'`)." + ) for host in self.DATABASE_URL.hosts(): self._check_default_secret("DATABASE_URL password", host["password"]) self._check_default_secret( diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py new file mode 100644 index 0000000000..8aed364327 --- /dev/null +++ b/backend/tests/test_config_security.py @@ -0,0 +1,58 @@ +"""Regression tests for fail-closed secret handling. + +These lock in the fix for the shipped-default-secret finding: the application +must refuse to load settings when ``SECRET_KEY`` is a known placeholder or is +too short, in *every* environment (including development). Previously the +placeholder was only a warning in development, which let the template ship a +working signing key that could be used to forge JWTs. +""" + +import pytest +from pydantic import ValidationError + +from app.core.config import MINIMUM_SECRET_KEY_LENGTH, Settings + +VALID_SECRET = "x" * MINIMUM_SECRET_KEY_LENGTH + + +def _settings(**overrides: str) -> Settings: + values = { + "_env_file": None, + "PROJECT_NAME": "Test Project", + "SECRET_KEY": VALID_SECRET, + "FIRST_SUPERUSER": "admin@example.com", + "FIRST_SUPERUSER_PASSWORD": "a-strong-superuser-password", + "DATABASE_URL": "postgresql://postgres:a-strong-db-password@localhost:5432/app", + } + values.update(overrides) + return Settings(**values) # type: ignore[arg-type] + + +@pytest.mark.parametrize("env", [None, "development"]) +def test_placeholder_secret_key_is_rejected_in_every_environment( + env: str | None, +) -> None: + with pytest.raises(ValidationError): + _settings(SECRET_KEY="changethis", FASTAPI_ENV=env) + + +def test_short_secret_key_is_rejected() -> None: + with pytest.raises(ValidationError): + _settings(SECRET_KEY="too-short") + + +def test_placeholder_superuser_password_is_rejected() -> None: + with pytest.raises(ValidationError): + _settings(FIRST_SUPERUSER_PASSWORD="changethis") + + +def test_placeholder_database_password_is_rejected() -> None: + with pytest.raises(ValidationError): + _settings( + DATABASE_URL="postgresql://postgres:changethis@localhost:5432/app" + ) + + +def test_valid_secret_key_is_accepted() -> None: + settings = _settings() + assert settings.SECRET_KEY == VALID_SECRET diff --git a/development.md b/development.md index efd21b2f3e..498fddc88d 100644 --- a/development.md +++ b/development.md @@ -99,7 +99,15 @@ docker compose watch ## The `.env` File -The tracked `.env` file contains local development defaults, passwords, and other configuration. Its hostnames use `localhost` for processes running on your machine. Docker Compose overrides hostnames such as the database and SMTP server with their Compose service names. +Local settings come from the `.env` file. It is ignored by Git and Docker and is **not** tracked, so each checkout holds its own secrets. Its hostnames use `localhost` for processes running on your machine. Docker Compose overrides hostnames such as the database and SMTP server with their Compose service names. + +Create it with strong, random secrets before starting the stack: + +```bash +bash scripts/generate-env.sh +``` + +`.env.example` documents every supported variable. The backend refuses to start when `SECRET_KEY` or `FIRST_SUPERUSER_PASSWORD` is left as a placeholder (`changethis`) or when `SECRET_KEY` is shorter than 32 characters, so the application can never boot with a shipped default signing key. Do not store deployment secrets in `.env`. Configure them as described in the [FastAPI Cloud deployment guide](./deployment.md) or the [Docker Compose deployment guide](./deployment-docker-compose.md). diff --git a/scripts/generate-env.sh b/scripts/generate-env.sh new file mode 100755 index 0000000000..38bcdcd2fa --- /dev/null +++ b/scripts/generate-env.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Generate a local `.env` file with strong, random secrets. +# +# `.env` is ignored by Git and Docker and must never be committed. This script +# creates it (or refreshes only the missing secret values) so local development +# and CI can start the stack without shipping a known signing key. +# +# Usage: +# bash scripts/generate-env.sh # create .env if missing +# FORCE=1 bash scripts/generate-env.sh # overwrite an existing .env +set -e + +cd "$(dirname "$0")/.." + +ENV_FILE=".env" +EXAMPLE_FILE=".env.example" + +gen() { python3 -c 'import secrets; print(secrets.token_urlsafe(32))'; } + +if [ -f "$ENV_FILE" ] && [ -z "${FORCE:-}" ]; then + echo "$ENV_FILE already exists; leaving it untouched. Use FORCE=1 to overwrite." + exit 0 +fi + +if [ ! -f "$EXAMPLE_FILE" ]; then + echo "Missing $EXAMPLE_FILE; cannot generate $ENV_FILE." >&2 + exit 1 +fi + +SECRET_KEY="$(gen)" +FIRST_SUPERUSER_PASSWORD="$(gen)" +POSTGRES_PASSWORD="$(gen)" + +# Start from the example and fill in the generated secrets. +sed \ + -e "s|^SECRET_KEY=.*|SECRET_KEY=${SECRET_KEY}|" \ + -e "s|^FIRST_SUPERUSER_PASSWORD=.*|FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD}|" \ + -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${POSTGRES_PASSWORD}|" \ + "$EXAMPLE_FILE" > "$ENV_FILE" + +echo "Wrote $ENV_FILE with freshly generated secrets." From 4ed3256fd3cddc72f644e01d903464928ed49a6a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:14:11 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format=20and=20update?= =?UTF-8?q?=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_config_security.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py index 8aed364327..4a46ed4cbb 100644 --- a/backend/tests/test_config_security.py +++ b/backend/tests/test_config_security.py @@ -48,9 +48,7 @@ def test_placeholder_superuser_password_is_rejected() -> None: def test_placeholder_database_password_is_rejected() -> None: with pytest.raises(ValidationError): - _settings( - DATABASE_URL="postgresql://postgres:changethis@localhost:5432/app" - ) + _settings(DATABASE_URL="postgresql://postgres:changethis@localhost:5432/app") def test_valid_secret_key_is_accepted() -> None: