Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 0 additions & 18 deletions .env

This file was deleted.

28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test-docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
21 changes: 16 additions & 5 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import warnings
from typing import Literal, Self

from pydantic import (
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions backend/tests/test_config_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""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
10 changes: 9 additions & 1 deletion development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
41 changes: 41 additions & 0 deletions scripts/generate-env.sh
Original file line number Diff line number Diff line change
@@ -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."
Loading