This commit is contained in:
@@ -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.
|
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:
|
The main configurable values are:
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
| Variable | Purpose | Default |
|
||||||
|
|||||||
+8
-4
@@ -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
|
### SA-005: Production secret fallback is not fail-closed
|
||||||
|
|
||||||
**Severity:** Medium
|
**Severity:** Medium, remediated in current worktree
|
||||||
**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.
|
**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.
|
**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
|
### SA-006: Login and reset-mail throttling are not distributed or atomic
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Chat Log
|
# 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
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@
|
|||||||
195. Update SA-012 and README for the implemented refresh-token lifecycle, revocation behavior, and refresh endpoint.
|
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.
|
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.
|
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
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from dataclasses import dataclass
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
DB_PATH = BASE_DIR / 'data' / 'linklog.db'
|
DB_PATH = BASE_DIR / 'data' / 'linklog.db'
|
||||||
@@ -20,6 +22,7 @@ def normalize_public_url(value: str) -> str:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Settings:
|
class Settings:
|
||||||
|
app_env: str = os.getenv('APP_ENV', 'development').lower()
|
||||||
app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog')
|
app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog')
|
||||||
version: str = os.getenv('LINKLOG_VERSION', '0.1.0')
|
version: str = os.getenv('LINKLOG_VERSION', '0.1.0')
|
||||||
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}')
|
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}')
|
||||||
@@ -59,3 +62,19 @@ class Settings:
|
|||||||
|
|
||||||
|
|
||||||
settings = 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
|
||||||
|
|||||||
+2
-1
@@ -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 router as setup_router
|
||||||
from backend.app.api.setup import has_administrator
|
from backend.app.api.setup import has_administrator
|
||||||
from backend.app.api.user_config import router as user_config_router
|
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.database import AVATARS_DIR
|
||||||
from backend.app.services.link_service import get_public_profile, list_public_links
|
from backend.app.services.link_service import get_public_profile, list_public_links
|
||||||
|
|
||||||
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
|
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
|
||||||
|
validate_configuration(settings)
|
||||||
|
|
||||||
app = FastAPI(title='LinkLog API', version=settings.version)
|
app = FastAPI(title='LinkLog API', version=settings.version)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.core.config import Settings, validate_configuration
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
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():
|
def test_production_compose_configuration_matches_settings_environment_keys():
|
||||||
compose = (ROOT / 'docker-compose.yml').read_text()
|
compose = (ROOT / 'docker-compose.yml').read_text()
|
||||||
settings = (ROOT / 'backend' / 'app' / 'core' / 'config.py').read_text()
|
settings = (ROOT / 'backend' / 'app' / 'core' / 'config.py').read_text()
|
||||||
|
|||||||
Reference in New Issue
Block a user