Coverage for cookie / settings.py: 98%
101 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
1"""
2Django settings for cookie project.
3Single settings file for simplicity.
4"""
6import os
7from pathlib import Path
9import dj_database_url
10from django.core.exceptions import ImproperlyConfigured
12BASE_DIR = Path(__file__).resolve().parent.parent
14# ===========================================
15# Environment-based Configuration
16# ===========================================
18DEBUG = os.environ.get("DEBUG", "False").lower() == "true"
21def get_secret_key():
22 """Get secret key from environment or generate one."""
23 env_key = os.environ.get("SECRET_KEY")
24 if env_key:
25 return env_key
26 if DEBUG:
27 return "django-insecure-dev-key-change-in-production"
28 from django.core.management.utils import get_random_secret_key
30 return get_random_secret_key()
33SECRET_KEY = get_secret_key()
35ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
37# Use X-Forwarded-Host header (preserves port when behind nginx proxy)
38USE_X_FORWARDED_HOST = True
40# CSRF trusted origins (for reverse proxies)
41csrf_origins = os.environ.get("CSRF_TRUSTED_ORIGINS", "")
42CSRF_TRUSTED_ORIGINS = [o.strip() for o in csrf_origins.split(",") if o.strip()]
44# ===========================================
45# Authentication Mode
46# ===========================================
47# "home" (default): Profile-based sessions, no user accounts, no login required.
48# "passkey": WebAuthn passkey-only authentication with device code flow for legacy devices.
49_raw_auth_mode = os.environ.get("AUTH_MODE", "home")
50if _raw_auth_mode not in ("home", "passkey"):
51 import logging as _logging
52 import re as _re
54 # Sanitise for log output: strip control characters to prevent log injection
55 _safe_mode = _re.sub(r"[\x00-\x1f\x7f]", "", _raw_auth_mode)[:50]
56 _logging.getLogger("cookie.settings").warning(
57 "Unrecognised AUTH_MODE=%r — falling back to 'home'. Valid modes: 'home', 'passkey'.",
58 _safe_mode,
59 )
60 _raw_auth_mode = "home"
61AUTH_MODE = _raw_auth_mode
64def _resolve_cookie_version() -> str:
65 """Return the release version, refusing silent "dev" fallbacks in prod.
67 In prod, the CD pipeline bakes `COOKIE_VERSION` into the image via
68 `--build-arg` (see `Dockerfile.prod` + `.github/workflows/cd.yml`).
69 Missing-or-empty at startup means the image was built outside the
70 pipeline, so we fail loudly rather than render "dev" in the UI.
71 """
72 value = os.environ.get("COOKIE_VERSION", "").strip()
73 if value:
74 return value
75 if DEBUG:
76 return "dev"
77 raise ImproperlyConfigured(
78 "COOKIE_VERSION environment variable is required in production. "
79 "The CD pipeline bakes it into the image via `--build-arg "
80 "COOKIE_VERSION=<semver>`. An empty value here means the image was "
81 "built without that arg — fix the build rather than papering over it."
82 )
85COOKIE_VERSION = _resolve_cookie_version()
87# ===========================================
88# WebAuthn / Passkey Configuration (Passkey Mode)
89# ===========================================
90WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "") # Derived from request hostname if empty
91WEBAUTHN_RP_NAME = os.environ.get("WEBAUTHN_RP_NAME", "Cookie")
92# Pin the expected origin for WebAuthn verification (F-33 fix). When set,
93# _get_origin() returns this value directly, decoupling origin binding from
94# the request Host header (and therefore from USE_X_FORWARDED_HOST injection).
95# Required in production: set to https://<your-domain>.
96WEBAUTHN_RP_ORIGIN = os.environ.get("WEBAUTHN_RP_ORIGIN", "")
97DEVICE_CODE_EXPIRY_SECONDS = int(os.environ.get("DEVICE_CODE_EXPIRY_SECONDS", "600"))
99INSTALLED_APPS = [
100 "apps.core", # before django.contrib.auth to override createsuperuser
101 "django.contrib.auth",
102 "django.contrib.contenttypes",
103 "django.contrib.sessions",
104 "django.contrib.staticfiles",
105 "apps.profiles",
106 "apps.recipes",
107 "apps.ai",
108 "apps.legacy",
109]
111MIDDLEWARE = [
112 "django.middleware.security.SecurityMiddleware",
113 "whitenoise.middleware.WhiteNoiseMiddleware",
114 "apps.core.middleware.HomeOnlyRouteGateMiddleware",
115 # Collapse every 405 Method Not Allowed response to a 404 so POST-only
116 # endpoints (e.g. /api/auth/logout/, /api/auth/device/authorize/) don't
117 # leak their existence on GET probes (pentest round 6 / F-5).
118 "apps.core.middleware.MethodNotAllowedToNotFoundMiddleware",
119 "django.contrib.sessions.middleware.SessionMiddleware",
120 "django.middleware.csrf.CsrfViewMiddleware",
121 "django.middleware.common.CommonMiddleware",
122 "django.middleware.clickjacking.XFrameOptionsMiddleware",
123 "apps.core.middleware.RequestIDMiddleware",
124 "apps.core.middleware.DeviceDetectionMiddleware",
125]
127# Add Django auth middleware in passkey mode (user accounts required)
128if AUTH_MODE == "passkey":
129 _session_idx = MIDDLEWARE.index("django.contrib.sessions.middleware.SessionMiddleware")
130 MIDDLEWARE.insert(_session_idx + 1, "django.contrib.auth.middleware.AuthenticationMiddleware")
132ROOT_URLCONF = "cookie.urls"
134TEMPLATES = [
135 {
136 "BACKEND": "django.template.backends.django.DjangoTemplates",
137 "DIRS": [],
138 "APP_DIRS": True,
139 "OPTIONS": {
140 "context_processors": [
141 "django.template.context_processors.request",
142 "apps.core.context_processors.app_context",
143 ],
144 },
145 },
146]
148WSGI_APPLICATION = "cookie.wsgi.application"
150# Database configuration
151# PostgreSQL is required — no SQLite fallback.
152# conn_max_age=60 and conn_health_checks=True are appropriate for single-server
153# deployment with Gunicorn. Upgrade path: use pgbouncer for multi-server.
154DATABASE_URL = os.environ.get("DATABASE_URL")
156if not DATABASE_URL:
157 raise ImproperlyConfigured(
158 "DATABASE_URL environment variable is required. "
159 "Set it to a PostgreSQL connection string, "
160 "e.g. postgres://user:pass@host:5432/dbname" # pragma: allowlist secret
161 )
163DATABASES = {
164 "default": dj_database_url.parse(
165 DATABASE_URL,
166 conn_max_age=60,
167 conn_health_checks=True,
168 )
169}
171# Defence-in-depth Postgres timeouts (HexStrike R17 A4 hardening).
172# Prevents a stuck query or a pathologically-held row/advisory lock from
173# blocking quota / session operations indefinitely. Values chosen to cover
174# every request path observed in prod (scrape external fetch happens outside
175# the DB tx; AI reserve/release is microseconds) while failing fast on
176# genuine pathology. Migrations run via `manage.py migrate` use the same
177# DATABASES config; if a migration legitimately needs longer, override per
178# command with `PG_STATEMENT_TIMEOUT_MS=0 python manage.py migrate`.
179_pg_statement_timeout = os.environ.get("PG_STATEMENT_TIMEOUT_MS", "30000")
180_pg_lock_timeout = os.environ.get("PG_LOCK_TIMEOUT_MS", "5000")
181_pg_idle_in_tx_timeout = os.environ.get("PG_IDLE_IN_TX_TIMEOUT_MS", "10000")
182_pg_options_parts = [
183 f"-c statement_timeout={_pg_statement_timeout}",
184 f"-c lock_timeout={_pg_lock_timeout}",
185 f"-c idle_in_transaction_session_timeout={_pg_idle_in_tx_timeout}",
186]
187DATABASES["default"].setdefault("OPTIONS", {})
188# Preserve any options already set by dj_database_url (e.g. sslmode parsed
189# from the URL's query string) by concatenating rather than overwriting.
190_existing_options = DATABASES["default"]["OPTIONS"].get("options", "")
191DATABASES["default"]["OPTIONS"]["options"] = " ".join(
192 part for part in [_existing_options, *_pg_options_parts] if part
193).strip()
195LANGUAGE_CODE = "en-us"
196TIME_ZONE = "UTC"
197USE_I18N = True
198USE_TZ = True
200STATIC_URL = "static/"
201STATIC_ROOT = BASE_DIR / "staticfiles"
203# Include built frontend assets in static files (only if directory exists)
204_frontend_dist = BASE_DIR / "frontend" / "dist"
205STATICFILES_DIRS = [_frontend_dist] if _frontend_dist.exists() else []
207# WhiteNoise configuration for efficient static file serving
208STORAGES = {
209 "default": {
210 "BACKEND": "django.core.files.storage.FileSystemStorage",
211 },
212 "staticfiles": {
213 "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
214 },
215}
217WHITENOISE_ROOT = BASE_DIR / "rootfiles"
219MEDIA_URL = "/media/"
220data_dir = os.environ.get("DATA_DIR", str(BASE_DIR))
221MEDIA_ROOT = Path(data_dir) / "data" / "media" if not DEBUG else BASE_DIR / "media"
223DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
225# Cache configuration — database-backed for sharing across Gunicorn workers
226CACHES = {
227 "default": {
228 "BACKEND": "apps.core.cache.PostgreSafeDatabaseCache",
229 "LOCATION": "django_cache",
230 "OPTIONS": {
231 "MAX_ENTRIES": 5000,
232 },
233 }
234}
236# Search result cache: 5 days (shared globally across all profiles)
237SEARCH_CACHE_TIMEOUT = 432000 # 5 days in seconds
239# Session settings
240# Database-backed sessions: intentional for single-server deployment.
241# Upgrade path: switch to django.contrib.sessions.backends.cache with Redis
242# for multi-server deployments.
243SESSION_ENGINE = "django.contrib.sessions.backends.db"
244SESSION_COOKIE_AGE = 86400 # 24 hours
245SESSION_COOKIE_SECURE = not DEBUG
246SESSION_COOKIE_HTTPONLY = True
247SESSION_COOKIE_SAMESITE = "Lax"
248CSRF_COOKIE_SECURE = not DEBUG
249CSRF_COOKIE_HTTPONLY = False # SPA reads CSRF cookie via JavaScript
251CSRF_FAILURE_VIEW = "apps.core.views.csrf_failure"
253# Security response headers Django adds by default. In production these are
254# disabled below because `nginx/security-headers.conf` is the sole owner —
255# emitting from both sources duplicates the headers on every Django-served
256# response (finding from pentest round 3). Kept enabled in DEBUG so that
257# `runserver` deployments without nginx still get the baseline.
258SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
259SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
260X_FRAME_OPTIONS = "DENY"
262# Production security hardening (inactive in development)
263if not DEBUG:
264 SECURE_HSTS_SECONDS = 63072000 # 2 years, matching nginx.prod.conf
265 SECURE_HSTS_INCLUDE_SUBDOMAINS = True
266 SECURE_HSTS_PRELOAD = True
267 # Default false: when behind a TLS-terminating proxy (Cloudflare, Traefik),
268 # Django sees plain HTTP and redirect-loops. The proxy enforces HTTPS at the edge.
269 SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "false").lower() == "true"
270 SECURE_REDIRECT_EXEMPT = [r"^api/system/health/$", r"^api/system/ready/$"]
271 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
273 # Defer security response headers to nginx/security-headers.conf.
274 # Django's SecurityMiddleware and XFrameOptionsMiddleware would otherwise
275 # emit duplicates on every response that passes through Django.
276 SECURE_CROSS_ORIGIN_OPENER_POLICY = None
277 SECURE_REFERRER_POLICY = None
278 SECURE_CONTENT_TYPE_NOSNIFF = False
279 MIDDLEWARE = [m for m in MIDDLEWARE if m != "django.middleware.clickjacking.XFrameOptionsMiddleware"]
281# Rate limiting (django-ratelimit)
282# Use a callable that safely extracts the first IP from X-Forwarded-For,
283# handling multi-proxy chains (Cloudflare → Traefik → nginx → Django).
284RATELIMIT_IP_META_KEY = "apps.core.middleware.get_client_ip"
286# Logging configuration
287LOG_FORMAT = os.environ.get("LOG_FORMAT", "text")
288LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
290_log_formatter = "json" if LOG_FORMAT == "json" else "verbose"
292LOGGING = {
293 "version": 1,
294 "disable_existing_loggers": False,
295 "formatters": {
296 "verbose": {
297 "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
298 "style": "{",
299 },
300 "simple": {
301 "format": "{levelname} {message}",
302 "style": "{",
303 },
304 "json": {
305 "()": "apps.core.logging.JSONFormatter",
306 },
307 },
308 "handlers": {
309 "console": {
310 "class": "logging.StreamHandler",
311 "formatter": _log_formatter,
312 },
313 },
314 "loggers": {
315 "apps.recipes": {
316 "handlers": ["console"],
317 "level": "INFO",
318 "propagate": False,
319 },
320 "apps.recipes.services": {
321 "handlers": ["console"],
322 "level": "INFO",
323 "propagate": False,
324 },
325 "apps.ai": {
326 "handlers": ["console"],
327 "level": "INFO",
328 "propagate": False,
329 },
330 "django.security": {
331 "handlers": ["console"],
332 "level": "WARNING",
333 "propagate": False,
334 },
335 "security": {
336 "handlers": ["console"],
337 "level": "INFO",
338 "propagate": False,
339 },
340 },
341 "root": {
342 "handlers": ["console"],
343 "level": LOG_LEVEL,
344 },
345}