+116
-17
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
@@ -29,6 +30,63 @@ def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int
|
||||
return value
|
||||
|
||||
|
||||
def env_cidrs(name: str, default: str = "") -> list[str]:
|
||||
networks: list[str] = []
|
||||
for value in env_list(name, default):
|
||||
try:
|
||||
networks.append(str(ipaddress.ip_network(value, strict=False)))
|
||||
except ValueError as exc:
|
||||
raise ImproperlyConfigured(f"{name} bevat een ongeldig IP-netwerk: {value}") from exc
|
||||
return networks
|
||||
|
||||
|
||||
def merge_unique(*groups: list[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for value in group:
|
||||
key = value.casefold()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_public_base_url(value: str) -> tuple[str, str]:
|
||||
raw = value.strip().rstrip("/")
|
||||
if not raw:
|
||||
return "", ""
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL moet een geldige http(s)-URL zijn.")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ImproperlyConfigured(
|
||||
"PUBLIC_BASE_URL mag geen credentials, query of fragment bevatten."
|
||||
)
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL mag geen applicatiepad bevatten.")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL bevat een ongeldige poort.") from exc
|
||||
host = parsed.hostname.casefold()
|
||||
display_host = f"[{host}]" if ":" in host else host
|
||||
default_port = 443 if parsed.scheme == "https" else 80
|
||||
port_suffix = f":{port}" if port and port != default_port else ""
|
||||
return f"{parsed.scheme}://{display_host}{port_suffix}", host
|
||||
|
||||
|
||||
def configured_public_base_url() -> tuple[str, str]:
|
||||
raw = os.getenv("PUBLIC_BASE_URL", "").strip()
|
||||
if not raw:
|
||||
app_host = os.getenv("APP_HOST", "").strip()
|
||||
if app_host:
|
||||
scheme = os.getenv("APP_SCHEME", "https").strip().lower() or "https"
|
||||
public_port = os.getenv("APP_PUBLIC_PORT", "").strip()
|
||||
raw = f"{scheme}://{app_host}{f':{public_port}' if public_port else ''}"
|
||||
return normalize_public_base_url(raw)
|
||||
|
||||
|
||||
def _validate_production_security(
|
||||
*,
|
||||
debug: bool,
|
||||
@@ -38,6 +96,9 @@ def _validate_production_security(
|
||||
session_cookie_secure: bool,
|
||||
csrf_cookie_secure: bool,
|
||||
secure_ssl_redirect: bool,
|
||||
public_base_url: str = "",
|
||||
demo_mode_enabled: bool = False,
|
||||
demo_read_only: bool = True,
|
||||
) -> None:
|
||||
if debug:
|
||||
return
|
||||
@@ -49,6 +110,8 @@ def _validate_production_security(
|
||||
raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.")
|
||||
if not allowed_hosts:
|
||||
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.")
|
||||
if any(host.strip() == "*" for host in allowed_hosts):
|
||||
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie geen wildcard bevatten.")
|
||||
if not csrf_trusted_origins:
|
||||
raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.")
|
||||
if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins):
|
||||
@@ -57,14 +120,26 @@ def _validate_production_security(
|
||||
raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.")
|
||||
if not secure_ssl_redirect:
|
||||
raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.")
|
||||
if public_base_url and not public_base_url.startswith("https://"):
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL moet HTTPS gebruiken in productie.")
|
||||
if demo_mode_enabled and not demo_read_only:
|
||||
raise ImproperlyConfigured("Een publieke productiedemo moet alleen-lezen zijn.")
|
||||
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||
DEBUG = env_bool("DJANGO_DEBUG", True)
|
||||
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver")
|
||||
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
|
||||
PUBLIC_BASE_URL, PUBLIC_HOST = configured_public_base_url()
|
||||
ALLOWED_HOSTS = merge_unique(
|
||||
env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver"),
|
||||
[PUBLIC_HOST] if PUBLIC_HOST else [],
|
||||
)
|
||||
CSRF_TRUSTED_ORIGINS = merge_unique(
|
||||
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
|
||||
[PUBLIC_BASE_URL] if PUBLIC_BASE_URL else [],
|
||||
)
|
||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
|
||||
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
|
||||
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.12").strip() or "0.3.12"
|
||||
LANGUAGE_CODE = "nl-be"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
@@ -92,6 +167,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"apps.core.middleware.DemoReadOnlyMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"apps.core.middleware.SecurityHeadersMiddleware",
|
||||
]
|
||||
@@ -207,9 +283,11 @@ ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
|
||||
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
|
||||
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
|
||||
|
||||
# Demo-toegang: logt in als afgeschermd gastaccount met een fictieve, gevulde
|
||||
# demo-omgeving. Standaard aan zodat de knop 'Bekijk demo' zichtbaar is.
|
||||
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", True)
|
||||
# Demo-toegang: in ontwikkeling standaard beschikbaar; in productie alleen expliciet.
|
||||
# Een publiek gastaccount blijft standaard read-only en krijgt een begrensde sessie.
|
||||
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", DEBUG)
|
||||
DEMO_READ_ONLY = env_bool("DEMO_READ_ONLY", True)
|
||||
DEMO_SESSION_SECONDS = env_int_range("DEMO_SESSION_SECONDS", 3600, minimum=300, maximum=86400)
|
||||
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
|
||||
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
|
||||
|
||||
@@ -223,27 +301,45 @@ EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
|
||||
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False)
|
||||
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False)
|
||||
_PUBLIC_HTTPS = PUBLIC_BASE_URL.startswith("https://")
|
||||
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", _PUBLIC_HTTPS)
|
||||
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", _PUBLIC_HTTPS)
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", False)
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", _PUBLIC_HTTPS)
|
||||
TRUST_PROXY_HEADERS = env_bool("TRUST_PROXY_HEADERS", bool(PUBLIC_BASE_URL))
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") if TRUST_PROXY_HEADERS else None
|
||||
USE_X_FORWARDED_HOST = env_bool("USE_X_FORWARDED_HOST", False)
|
||||
TRUSTED_PROXY_CIDRS = env_cidrs("TRUSTED_PROXY_CIDRS")
|
||||
# Interne containerprobes blijven HTTP; publieke applicatieroutes worden wel omgeleid.
|
||||
SECURE_REDIRECT_EXEMPT = [r"^health/(?:live|ready)/$"]
|
||||
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
|
||||
SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False)
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "vacatureradar-rate-limit",
|
||||
}
|
||||
}
|
||||
SEARCH_ENGINE_INDEXING_ENABLED = env_bool("SEARCH_ENGINE_INDEXING_ENABLED", False)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
CACHE_URL = os.getenv("CACHE_URL", "" if DEBUG else REDIS_URL).strip()
|
||||
_IS_TESTING = bool(os.getenv("PYTEST_CURRENT_TEST") or "pytest" in os.sys.modules)
|
||||
if CACHE_URL and not _IS_TESTING:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"LOCATION": CACHE_URL,
|
||||
"KEY_PREFIX": "vacatureradar",
|
||||
"TIMEOUT": 300,
|
||||
}
|
||||
}
|
||||
else:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "vacatureradar-local",
|
||||
}
|
||||
}
|
||||
HEALTHCHECK_REQUIRE_CACHE = env_bool("HEALTHCHECK_REQUIRE_CACHE", not DEBUG)
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
|
||||
@@ -358,6 +454,9 @@ _validate_production_security(
|
||||
session_cookie_secure=SESSION_COOKIE_SECURE,
|
||||
csrf_cookie_secure=CSRF_COOKIE_SECURE,
|
||||
secure_ssl_redirect=SECURE_SSL_REDIRECT,
|
||||
public_base_url=PUBLIC_BASE_URL,
|
||||
demo_mode_enabled=DEMO_MODE_ENABLED,
|
||||
demo_read_only=DEMO_READ_ONLY,
|
||||
)
|
||||
|
||||
LOGGING = {
|
||||
|
||||
+3
-2
@@ -4,7 +4,6 @@ from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.templatetags.static import static as static_url
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
@@ -13,14 +12,16 @@ from apps.core.views import (
|
||||
demo_login,
|
||||
entra_callback,
|
||||
entra_login,
|
||||
robots_txt,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"favicon.ico",
|
||||
RedirectView.as_view(url=static_url("favicon.svg"), permanent=True),
|
||||
RedirectView.as_view(url=f"{settings.STATIC_URL}favicon.svg", permanent=True),
|
||||
name="favicon",
|
||||
),
|
||||
path("robots.txt", robots_txt, name="robots-txt"),
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"login/",
|
||||
|
||||
Reference in New Issue
Block a user