diff --git a/README.md b/README.md index cae5278..205b23a 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,8 @@ cp .env.example .env Edit `.env` and replace `LINKLOG_SECRET_KEY` with a long random value. Docker Compose automatically reads `.env` from the repository root. The committed `.env.example` contains safe defaults and placeholders; the real `.env` is ignored by Git. +When `APP_ENV=production`, application startup fails closed unless `LINKLOG_SECRET_KEY` is a non-default high-entropy value of at least 32 characters and `LINKLOG_DATA_ENCRYPTION_KEY` is a valid Fernet key. Development mode may use local defaults, but production secrets should come from a protected secret mechanism. + The main configurable values are: | Variable | Purpose | Default | diff --git a/Security-audit.md b/Security-audit.md index b033238..b6a7fca 100644 --- a/Security-audit.md +++ b/Security-audit.md @@ -90,14 +90,18 @@ The application should remain behind the production reverse proxy, with real DNS ### SA-005: Production secret fallback is not fail-closed -**Severity:** Medium -**Evidence:** `Settings.secret_key` defaults to `dev-secret-key-change-me`, and `LINKLOG_DATA_ENCRYPTION_KEY` is validated when encryption is used rather than fully validated during startup. +**Severity:** Medium, remediated in current worktree +**Evidence before remediation:** `Settings.secret_key` defaulted to `dev-secret-key-change-me`, and `LINKLOG_DATA_ENCRYPTION_KEY` was validated when encryption was used rather than fully validated during startup. **Impact:** A deployment that omits required configuration can start with a known development secret or fail only when a protected feature is exercised. This creates dangerous configuration drift and complicates incident response. -**Recommendation:** During startup, reject a missing or known development `LINKLOG_SECRET_KEY` when `APP_ENV=production`; validate minimum length and entropy. Require and validate the encryption key before startup when encrypted data exists or production requires it. Add configuration tests for fail-closed production behavior. +**Current state:** `validate_configuration()` runs before FastAPI app construction. In production it rejects a missing or known development `LINKLOG_SECRET_KEY`, application secrets shorter than 32 characters or with insufficient character diversity, and missing `LINKLOG_DATA_ENCRYPTION_KEY`. Any supplied encryption key is checked as a valid Fernet key. Focused tests cover rejection and acceptance paths. -**Priority:** Medium. +**Residual impact:** Secret strength checks are pragmatic length/diversity checks rather than a full entropy estimator. Secret provisioning, rotation, and protected storage remain operational requirements. + +**Recommendation:** Keep production startup fail-closed, provision secrets through a protected secret manager, rotate them after suspected disclosure, and consider a stronger entropy policy if deployment requirements warrant it. + +**Priority:** Completed in code; secret provisioning and rotation remain. ### SA-006: Login and reset-mail throttling are not distributed or atomic diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index f06e950..ae33075 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1,5 +1,11 @@ # Chat Log +### User +Implement SA-005: during startup reject missing or known development `LINKLOG_SECRET_KEY` in production, validate minimum length and entropy, require and validate the encryption key, and add fail-closed configuration tests. + +### Assistant outcome +Added startup configuration validation before FastAPI app construction. Production now rejects missing/default/weak application secrets and missing or invalid Fernet encryption keys, while development defaults remain available. Added configuration tests for rejection and valid production settings, and updated SA-005 documentation. + ### User For SA-002, log technical details server-side with a request/correlation ID, return a stable public message with a short reference ID, redact secrets, and test that exception text is absent from responses. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index dcb1339..d8080c9 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -202,6 +202,7 @@ 195. Update SA-012 and README for the implemented refresh-token lifecycle, revocation behavior, and refresh endpoint. 196. Remedy SA-001: migrate logout from JSON token transport to the Authorization bearer header. 197. Implement SA-002: replace raw infrastructure errors with redacted server-side logging, request IDs, and stable public reference messages. +198. Implement SA-005: reject missing/default/weak production secrets at startup and validate the Fernet encryption key, with configuration tests. ## Future entries diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9aeb6d3..a5e9af5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,6 +5,8 @@ from dataclasses import dataclass import os from pathlib import Path +from cryptography.fernet import Fernet + BASE_DIR = Path(__file__).resolve().parent.parent.parent DB_PATH = BASE_DIR / 'data' / 'linklog.db' @@ -20,6 +22,7 @@ def normalize_public_url(value: str) -> str: @dataclass class Settings: + app_env: str = os.getenv('APP_ENV', 'development').lower() app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog') version: str = os.getenv('LINKLOG_VERSION', '0.1.0') database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}') @@ -59,3 +62,19 @@ class Settings: settings = Settings() + + +def validate_configuration(values: Settings) -> None: + if values.app_env == 'production': + if not values.secret_key or values.secret_key == 'dev-secret-key-change-me': + raise RuntimeError('LINKLOG_SECRET_KEY must be configured in production') + if len(values.secret_key) < 32 or len(set(values.secret_key)) < 12: + raise RuntimeError('LINKLOG_SECRET_KEY must be at least 32 characters with sufficient entropy') + if not values.data_encryption_key: + raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY must be configured in production') + + if values.data_encryption_key: + try: + Fernet(values.data_encryption_key.encode('ascii')) + except (ValueError, UnicodeEncodeError) as error: + raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY must be a valid Fernet key') from error diff --git a/backend/app/main.py b/backend/app/main.py index 8bc5649..b682edd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,11 +18,12 @@ from backend.app.api.public import router as public_router from backend.app.api.setup import router as setup_router from backend.app.api.setup import has_administrator from backend.app.api.user_config import router as user_config_router -from backend.app.core.config import settings +from backend.app.core.config import settings, validate_configuration from backend.app.database import AVATARS_DIR from backend.app.services.link_service import get_public_profile, list_public_links logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO)) +validate_configuration(settings) app = FastAPI(title='LinkLog API', version=settings.version) diff --git a/backend/tests/test_configuration.py b/backend/tests/test_configuration.py index 9ca7bbb..f89dd6a 100644 --- a/backend/tests/test_configuration.py +++ b/backend/tests/test_configuration.py @@ -1,10 +1,36 @@ import re from pathlib import Path +import pytest + +from backend.app.core.config import Settings, validate_configuration ROOT = Path(__file__).resolve().parents[2] +def test_production_configuration_rejects_missing_or_default_secret(): + with pytest.raises(RuntimeError, match='LINKLOG_SECRET_KEY'): + validate_configuration(Settings(app_env='production', secret_key='', data_encryption_key='')) + with pytest.raises(RuntimeError, match='LINKLOG_SECRET_KEY'): + validate_configuration(Settings(app_env='production', secret_key='dev-secret-key-change-me', data_encryption_key='')) + + +def test_production_configuration_rejects_weak_or_missing_encryption_key(): + with pytest.raises(RuntimeError, match='entropy'): + validate_configuration(Settings(app_env='production', secret_key='A' * 32, data_encryption_key='')) + with pytest.raises(RuntimeError, match='DATA_ENCRYPTION_KEY'): + validate_configuration(Settings(app_env='production', secret_key='A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6', data_encryption_key='invalid')) + + +def test_production_configuration_accepts_strong_secrets(): + values = Settings( + app_env='production', + secret_key='A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6', + data_encryption_key='L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV=', + ) + validate_configuration(values) + + def test_production_compose_configuration_matches_settings_environment_keys(): compose = (ROOT / 'docker-compose.yml').read_text() settings = (ROOT / 'backend' / 'app' / 'core' / 'config.py').read_text()