73 lines
3.2 KiB
Python
73 lines
3.2 KiB
Python
"""
|
|
Production settings.
|
|
|
|
Everything that can be hardened is hardened here; the module intentionally
|
|
fails loudly on start-up when a required secret is missing rather than falling
|
|
back to an insecure default.
|
|
"""
|
|
|
|
from .base import * # noqa: F403
|
|
from .base import DATABASES, DEBUG, SECRET_KEY, env
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail fast on unsafe configuration
|
|
# ---------------------------------------------------------------------------
|
|
if DEBUG: # pragma: no cover - configuration guard
|
|
raise RuntimeError("DJANGO_DEBUG must be false when using production settings.")
|
|
|
|
if len(SECRET_KEY) < 50: # pragma: no cover - configuration guard
|
|
raise RuntimeError("DJANGO_SECRET_KEY must be at least 50 characters in production.")
|
|
|
|
# A length check alone is false comfort: the placeholder shipped in
|
|
# .env.example is 68 characters long and would sail straight through it.
|
|
_PLACEHOLDER_MARKERS = ("change-me", "changeme", "build-only", "not-a-real-secret")
|
|
|
|
|
|
def _reject_placeholder(name: str, value: str) -> None: # pragma: no cover - guard
|
|
lowered = value.lower()
|
|
if any(marker in lowered for marker in _PLACEHOLDER_MARKERS):
|
|
raise RuntimeError(
|
|
f"{name} still holds a placeholder value from .env.example. "
|
|
"Generate a real secret before deploying: "
|
|
'python -c "import secrets; print(secrets.token_urlsafe(64))"'
|
|
)
|
|
|
|
|
|
_reject_placeholder("DJANGO_SECRET_KEY", SECRET_KEY)
|
|
_reject_placeholder("POSTGRES_PASSWORD", DATABASES["default"].get("PASSWORD") or "")
|
|
_reject_placeholder("ELASTICSEARCH_PASSWORD", env("ELASTICSEARCH_PASSWORD", default=""))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transport security
|
|
# ---------------------------------------------------------------------------
|
|
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True)
|
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
|
|
# Container/orchestrator probes hit the API over plain HTTP on localhost, so
|
|
# without an exemption every probe gets a 301 to https. `curl -fsS` treats a
|
|
# 3xx as success, which means a completely broken container would still report
|
|
# itself healthy - the probe has to reach the real view to be worth anything.
|
|
SECURE_REDIRECT_EXEMPT = [r"^api/v1/health/"]
|
|
SECURE_HSTS_SECONDS = 31_536_000 # one year
|
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
SECURE_HSTS_PRELOAD = True
|
|
|
|
SESSION_COOKIE_SECURE = True
|
|
CSRF_COOKIE_SECURE = True
|
|
SESSION_COOKIE_SAMESITE = "Strict"
|
|
CSRF_COOKIE_SAMESITE = "Strict"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Misc hardening
|
|
# ---------------------------------------------------------------------------
|
|
# Admin lives behind an unguessable prefix so credential stuffing bots miss it.
|
|
ADMIN_URL = env("DJANGO_ADMIN_URL", default="admin/")
|
|
|
|
# Deny by default; CORS_ALLOWED_ORIGINS must be set explicitly.
|
|
CORS_ALLOW_ALL_ORIGINS = False
|
|
|
|
# The interactive docs describe every endpoint and payload in the service and
|
|
# are served unauthenticated. Off unless the operator explicitly opts in (for
|
|
# an internal-only origin, say) - see config/urls.py.
|
|
SERVE_API_DOCS = env.bool("SERVE_API_DOCS", default=False)
|