Coverage for cookie / settings.py: 98%

101 statements  

« 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""" 

5 

6import os 

7from pathlib import Path 

8 

9import dj_database_url 

10from django.core.exceptions import ImproperlyConfigured 

11 

12BASE_DIR = Path(__file__).resolve().parent.parent 

13 

14# =========================================== 

15# Environment-based Configuration 

16# =========================================== 

17 

18DEBUG = os.environ.get("DEBUG", "False").lower() == "true" 

19 

20 

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 

29 

30 return get_random_secret_key() 

31 

32 

33SECRET_KEY = get_secret_key() 

34 

35ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") 

36 

37# Use X-Forwarded-Host header (preserves port when behind nginx proxy) 

38USE_X_FORWARDED_HOST = True 

39 

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()] 

43 

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 

53 

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 

62 

63 

64def _resolve_cookie_version() -> str: 

65 """Return the release version, refusing silent "dev" fallbacks in prod. 

66 

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 ) 

83 

84 

85COOKIE_VERSION = _resolve_cookie_version() 

86 

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")) 

98 

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] 

110 

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] 

126 

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") 

131 

132ROOT_URLCONF = "cookie.urls" 

133 

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] 

147 

148WSGI_APPLICATION = "cookie.wsgi.application" 

149 

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") 

155 

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 ) 

162 

163DATABASES = { 

164 "default": dj_database_url.parse( 

165 DATABASE_URL, 

166 conn_max_age=60, 

167 conn_health_checks=True, 

168 ) 

169} 

170 

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() 

194 

195LANGUAGE_CODE = "en-us" 

196TIME_ZONE = "UTC" 

197USE_I18N = True 

198USE_TZ = True 

199 

200STATIC_URL = "static/" 

201STATIC_ROOT = BASE_DIR / "staticfiles" 

202 

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 [] 

206 

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} 

216 

217WHITENOISE_ROOT = BASE_DIR / "rootfiles" 

218 

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" 

222 

223DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 

224 

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} 

235 

236# Search result cache: 5 days (shared globally across all profiles) 

237SEARCH_CACHE_TIMEOUT = 432000 # 5 days in seconds 

238 

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 

250 

251CSRF_FAILURE_VIEW = "apps.core.views.csrf_failure" 

252 

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" 

261 

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") 

272 

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"] 

280 

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" 

285 

286# Logging configuration 

287LOG_FORMAT = os.environ.get("LOG_FORMAT", "text") 

288LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") 

289 

290_log_formatter = "json" if LOG_FORMAT == "json" else "verbose" 

291 

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} 

← Back to Dashboard