From 7caea3a1eefd41aca166f6d428843f5737fb7e81 Mon Sep 17 00:00:00 2001 From: Jens Date: Mon, 27 Jul 2026 23:38:37 +0200 Subject: [PATCH] demo mode --- .env.example | 14 + CHANGELOG.md | 13 + PROJECT_MANIFEST.json | 554 +++++++++++------- README.md | 19 +- SECURITY.md | 8 +- VERIFICATION_REPORT.md | 134 ++--- apps/core/context_processors.py | 6 +- apps/core/health.py | 20 +- apps/core/middleware.py | 57 +- apps/core/network.py | 51 ++ apps/core/rate_limit.py | 9 +- apps/core/views.py | 11 +- config/settings.py | 133 ++++- config/urls.py | 5 +- deployment/production.env.example | 90 +++ docker-compose.unraid.yml | 5 +- docker-compose.yml | 9 +- docs/ai/BACKLOG.yaml | 42 ++ docs/ai/PROJECT_STATE.md | 16 +- docs/operations/NGINX_PROXY_MANAGER.md | 136 +++++ docs/operations/UNRAID_DEPLOYMENT.md | 57 +- docs/quality/PRODUCTION_AUDIT_2026-07-27.md | 52 ++ docs/quality/THREAT_MODEL.md | 4 +- docs/quality/TRACEABILITY_MATRIX.md | 6 +- pyproject.toml | 2 +- scripts/configure_public_url.py | 187 ++++++ scripts/deploy_docker.sh | 128 ++-- scripts/entrypoint.sh | 2 + scripts/remote_deploy.sh | 4 + scripts/unraid_aio_entrypoint.sh | 1 + scripts/validate_repository.py | 7 + static/css/app.css | 14 +- static/css/components.css | 22 + static/js/app.js | 12 + templates/base.html | 12 +- .../test_vr114_browser_and_accessibility.py | 3 +- tests/integration/test_auth_login.py | 43 +- tests/integration/test_views.py | 2 +- tests/unit/test_deployment_configuration.py | 35 ++ tests/unit/test_health.py | 27 + tests/unit/test_network.py | 30 + tests/unit/test_public_configuration.py | 126 ++++ tests/unit/test_settings_security.py | 32 + uv.lock | 2 +- 44 files changed, 1682 insertions(+), 460 deletions(-) create mode 100644 apps/core/network.py create mode 100644 deployment/production.env.example create mode 100644 docs/operations/NGINX_PROXY_MANAGER.md create mode 100644 docs/quality/PRODUCTION_AUDIT_2026-07-27.md create mode 100644 scripts/configure_public_url.py create mode 100644 tests/unit/test_health.py create mode 100644 tests/unit/test_network.py create mode 100644 tests/unit/test_public_configuration.py diff --git a/.env.example b/.env.example index 98b22d4..db31904 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,13 @@ # Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME. DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value DJANGO_DEBUG=1 +# Leeg voor lokaal gebruik. In productie: https://vacatureradar.example.be +PUBLIC_BASE_URL= DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226 DJANGO_TIME_ZONE=Europe/Brussels VACATURERADAR_OWNER_NAME=Jens +VACATURERADAR_VERSION=0.3.12 # Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling. POSTGRES_DB=vacatureradar @@ -14,6 +17,9 @@ POSTGRES_HOST=postgres POSTGRES_PORT=5432 DATABASE_URL= REDIS_URL=redis://redis:6379/0 +# Gedeelde cache voor rate limiting en readiness; gebruik een afzonderlijke Redis-database. +CACHE_URL=redis://redis:6379/1 +HEALTHCHECK_REQUIRE_CACHE=1 CELERY_TASK_ALWAYS_EAGER=0 # Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat. @@ -67,14 +73,22 @@ ENTRA_SCOPES=User.Read # Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een # fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen. DEMO_MODE_ENABLED=1 +DEMO_READ_ONLY=1 +DEMO_SESSION_SECONDS=3600 DEMO_USERNAME=demo DEMO_EMAIL=demo@vacatureradar.local # Beveiliging +# Zet TRUST_PROXY_HEADERS alleen aan achter een beheerde proxy die Host en X-Forwarded-Proto overschrijft. +TRUST_PROXY_HEADERS=0 +USE_X_FORWARDED_HOST=0 +# Alleen nodig wanneer client-IPheaders voor rate limiting worden vertrouwd. +TRUSTED_PROXY_CIDRS= SESSION_COOKIE_SECURE=0 CSRF_COOKIE_SECURE=0 SECURE_SSL_REDIRECT=0 SECURE_HSTS_SECONDS=0 +SEARCH_ENGINE_INDEXING_ENABLED=0 AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8 AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300 diff --git a/CHANGELOG.md b/CHANGELOG.md index fe014d9..30e1e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl ## Niet uitgebracht +### Toegevoegd + +- `PUBLIC_BASE_URL` als één productiebron voor hostvalidatie, CSRF-origin, secure cookies en HTTPS-redirect. +- Een idempotente `configure_public_url.py`-helper voor bestaande `.env`-bestanden achter Nginx Proxy Manager. +- Een concrete Nginx Proxy Manager-runbook, productie-envvoorbeeld, publieke `robots.txt` en cache-readinesscheck. +- Server-side alleen-lezenbescherming en begrensde sessies voor de gedeelde gastdemo. + ### Gewijzigd - De repository-README volgt nu de gebruikersreis van doel en dagelijkse workflow naar quickstart, configuratie, veiligheidsgrenzen, beheer en pas daarna architectuur en agentoverdracht. @@ -25,6 +32,12 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl ### Hersteld +- Publieke Nginx-hostnamen veroorzaken niet langer `DisallowedHost` wanneer `PUBLIC_BASE_URL` correct staat. +- HTTPS-deployment gebruikt de externe poort in plaats van de interne applicatiepoort voor CSRF en publieke URL’s. +- Interne healthchecks blijven bereikbaar bij `SECURE_SSL_REDIRECT=1`; productie-rate-limiting gebruikt een gedeelde Redis-cache. +- Forwarded client-IPheaders worden alleen vertrouwd vanaf expliciet ingestelde proxy-CIDR’s. +- Productiechecks kunnen de URL-configuratie laden voordat een staticfilesmanifest aanwezig is. +- De optionele Playwrightfallback voert geen synchrone databasecall meer uit binnen een async context. - Op mobiel overlappen `Wachtwoord` en `Wachtwoord vergeten?` niet meer; loginiconen zijn zelfstandige lijn-SVG's en de dubbele herstel-link is verwijderd. - De Unraid-synchelper negeert verwijderde indexpaden bij een release vanuit een geldige dirty worktree; vervangen merkassets blokkeren de archiefopbouw daardoor niet meer. - Rechtstreekse SSH naar het Unraid-IP koos lokaal de verkeerde gebruiker en identiteit; het gedocumenteerde aliasprofiel selecteert nu reproduceerbaar `root` met de afzonderlijke deploysleutel. diff --git a/PROJECT_MANIFEST.json b/PROJECT_MANIFEST.json index 0ebd8e7..0af558e 100644 --- a/PROJECT_MANIFEST.json +++ b/PROJECT_MANIFEST.json @@ -1,9 +1,9 @@ { "project": "VacatureRadar", "artifact_root": "VacatureRadar_Project", - "generated_at": "2026-07-25T22:17:50.331466+00:00", - "file_count_excluding_manifest": 448, - "total_bytes_excluding_manifest": 11127859, + "generated_at": "2026-07-27T21:28:29.892351+00:00", + "file_count_excluding_manifest": 468, + "total_bytes_excluding_manifest": 11664735, "files": [ { "path": ".agents/skills/vacatureradar-maintainer/SKILL.md", @@ -22,8 +22,8 @@ }, { "path": ".env.example", - "bytes": 2823, - "sha256": "d6d6c96a379eb60be6929fbf014a0f8ebb2c877a7a66d06925ad96a28024981c" + "bytes": 3451, + "sha256": "2bd841af341788a4023ada3a4f27a1d9be30a1f33fc733123d62143e28530fdf" }, { "path": ".gitattributes", @@ -60,6 +60,61 @@ "bytes": 4521, "sha256": "bba752f178136a2ad645e63cdf9f29ae4204cc6b246f2c202a4f9c8940b565a4" }, + { + "path": "CHANGELOG.md", + "bytes": 23325, + "sha256": "a7208f76abd66ea25d2968ea0f55d8686f7ebb82fcf524fd3252d991613f89d3" + }, + { + "path": "CODEX_START_HERE.md", + "bytes": 2964, + "sha256": "225f7804a4616c65addc796417810a9391d135248467b6e69bba4db1820d2834" + }, + { + "path": "CONTRIBUTING.md", + "bytes": 1129, + "sha256": "d7f17ab14e7bb0763d1cee12a1cae9a7cdeebded2d69ab1552ca5a5a5606dcdb" + }, + { + "path": "Dockerfile", + "bytes": 1203, + "sha256": "a231c7f0872c8a6fe8c2fe3bac46479e1b8ce3ec419fb3cdfadb25b541386ec2" + }, + { + "path": "Dockerfile.unraid", + "bytes": 961, + "sha256": "d71824ea8497dcdafb5e140e914ef15401d3966fa8d47e3dcec269b1cfb10884" + }, + { + "path": "LICENSE", + "bytes": 1061, + "sha256": "a1e3ff537d8be01e7bec1376969d1b66ea2088dfcf7dbf1f2227793ef67c13a6" + }, + { + "path": "Makefile", + "bytes": 1236, + "sha256": "1e3b04f7b74167347c2f11bebb9161a716c6fa4e4cb2630caa8f6af044fece81" + }, + { + "path": "PLANS.md", + "bytes": 980, + "sha256": "0bb6d51c01003a0dbf9de3a82aeb13967f024d9c267386f29c76fc7a4be97fa6" + }, + { + "path": "README.md", + "bytes": 9070, + "sha256": "4c54d879ab9914a6867939a5470f68c8946b1126785c06d7dcd1bf27a5b111dd" + }, + { + "path": "SECURITY.md", + "bytes": 2303, + "sha256": "bb5384843d594637d1602624060d440394ebd3f1a9cd102a2cb83ada0da69c42" + }, + { + "path": "VERIFICATION_REPORT.md", + "bytes": 3308, + "sha256": "ccf86d72596855edb04df024bedf4225c996227113a8f7bbfe6fee02f92eb41d" + }, { "path": "apps/__init__.py", "bytes": 0, @@ -82,18 +137,18 @@ }, { "path": "apps/core/auth_backends.py", - "bytes": 1758, - "sha256": "e62583ff633f83cd54a1cfeec3ffd70e5ddfbede031df2e358aad1bd6cf73d47" + "bytes": 1704, + "sha256": "ce1976582b3c14716db86021273b76b5b9643f98e2f3fd70bcbce0c3d27de5f3" }, { "path": "apps/core/context_processors.py", - "bytes": 539, - "sha256": "ce5a7c6102900254cb4fef52adb02286f46141aaef19b995312f9be5beeb270e" + "bytes": 1698, + "sha256": "be31f591e51c2fd2e1b9917c9d9cf42b17a65846e62237de7800f72445367ae4" }, { "path": "apps/core/entra.py", - "bytes": 3529, - "sha256": "9375df18938dd110994edd1b8f24376a829448e8940b387d6d0acf7146a339fb" + "bytes": 3489, + "sha256": "79f69c15793fb62cb9f87bfaa2a711d30c44e80faa853acf8bb80db45d3472fc" }, { "path": "apps/core/forms.py", @@ -102,23 +157,28 @@ }, { "path": "apps/core/health.py", - "bytes": 730, - "sha256": "23c528b43c948b36707b1756e03aec452bb3249a201fa4d467509af94d0b7cb5" + "bytes": 1322, + "sha256": "acd52fd98cb7073c3ce26632078f605147b9e9d4cd79fe4073e7c51229edd1af" }, { "path": "apps/core/middleware.py", - "bytes": 1262, - "sha256": "0c7046ffda8c3db5021ec4584ce59312c073dcdb4b130c208c93c77bb65130ed" + "bytes": 3261, + "sha256": "e624689c993fff0fbe7ad7869d1899e1cb3191bb8195e423e937f5810b738d72" }, { "path": "apps/core/models.py", "bytes": 256, "sha256": "516c3986da0f2ba82519c0828c0d3150524882806025f79a340791411a4ed792" }, + { + "path": "apps/core/network.py", + "bytes": 1605, + "sha256": "9f530808892175695ccfe468eca7d105a4d898482dadda83224f16b99d64f312" + }, { "path": "apps/core/rate_limit.py", - "bytes": 3284, - "sha256": "febf507ccae70502d141c2b2bb23b269353bdf158f7abe4c33f1e7bb91b8b5fb" + "bytes": 3056, + "sha256": "2b2c6e3aa071b9632feb6866fdc72233783cb1a7760b8fdbf0573ad27b96460f" }, { "path": "apps/core/services/__init__.py", @@ -137,8 +197,8 @@ }, { "path": "apps/core/views.py", - "bytes": 9503, - "sha256": "f354cb696d4d7ae47401dc131ae01364c1cc62b6cc7c8c3b77ee63322dcc233e" + "bytes": 9809, + "sha256": "b3575f28c70fabce83028c27faf78de05315212b083b4d79af08ef474a8febaf" }, { "path": "apps/jobs/__init__.py", @@ -232,8 +292,8 @@ }, { "path": "apps/jobs/services/cockpit.py", - "bytes": 6756, - "sha256": "0f69ab47a505682fe4aee9ca0adedafbe649404d6a7b7db40302317ee70d08a8" + "bytes": 6920, + "sha256": "b927af34b3386a639ec1283f7b2f5761aa61e47f1382b09994257c4ad0cb0a6e" }, { "path": "apps/jobs/services/dedupe.py", @@ -755,16 +815,6 @@ "bytes": 102625, "sha256": "a646c89834c6374e230763bcdb548a721c7017efb1bc57fed3fad5deb789c557" }, - { - "path": "CHANGELOG.md", - "bytes": 18562, - "sha256": "583272f45ba2204440d3f015afc022fe3658754d98b7e4862f69bc7d570a5961" - }, - { - "path": "CODEX_START_HERE.md", - "bytes": 2964, - "sha256": "225f7804a4616c65addc796417810a9391d135248467b6e69bba4db1820d2834" - }, { "path": "config/__init__.py", "bytes": 101, @@ -782,13 +832,13 @@ }, { "path": "config/settings.py", - "bytes": 14762, - "sha256": "d854e12cc85f763af1222897abd7c6c93d3c8fbee40b2153841838cdb91ba9fe" + "bytes": 19312, + "sha256": "bb47db00dc4c355c58b8b56790b1e4a64917f7f1e1543c20f2e2a7d73b067769" }, { "path": "config/urls.py", - "bytes": 2141, - "sha256": "4654aaf5c4ad2aa842173dfa0ecc78f991e623df4fd43aae855a680003adbf46" + "bytes": 2162, + "sha256": "d225c586ab8286f7bb21732ad3bacf72599daaf422676587997852e214eb19c6" }, { "path": "config/wsgi.py", @@ -816,9 +866,9 @@ "sha256": "61230414c78326743cf586f686193d05bf5149c965e449b27b8827cf94524936" }, { - "path": "CONTRIBUTING.md", - "bytes": 1129, - "sha256": "d7f17ab14e7bb0763d1cee12a1cae9a7cdeebded2d69ab1552ca5a5a5606dcdb" + "path": "deployment/production.env.example", + "bytes": 2756, + "sha256": "73af67ab337e8903e2f88284b6bad50d61e5ac2be2a2f7e34a2401c833f0aaf6" }, { "path": "deployment/unraid/README.md", @@ -832,23 +882,18 @@ }, { "path": "docker-compose.unraid.yml", - "bytes": 1541, - "sha256": "25a2e6bf6abb3f4dd6bfc6ba0e53f6726ed11c09a3a15ed9f0a5034a56fba740" + "bytes": 1550, + "sha256": "fda20a5e7ac3d6a2623c37ed2f83645a31a46ff9d60242089a9ce202ddb36e5c" }, { "path": "docker-compose.yml", - "bytes": 2499, - "sha256": "b9aaf71d28591a0b4afed0d6f28f2ad31cccae951d6050629e77dcce59e9840d" + "bytes": 2888, + "sha256": "8fd30ee701133e2e02bf5116e38628fb3732b3578a15037b2d4abe79953f3724" }, { - "path": "Dockerfile", - "bytes": 1203, - "sha256": "a231c7f0872c8a6fe8c2fe3bac46479e1b8ce3ec419fb3cdfadb25b541386ec2" - }, - { - "path": "Dockerfile.unraid", - "bytes": 961, - "sha256": "d71824ea8497dcdafb5e140e914ef15401d3966fa8d47e3dcec269b1cfb10884" + "path": "docs/README.md", + "bytes": 1667, + "sha256": "325b8df8b58afdb8cbe8f0d9603ca49d5a9c4ae6a2bb4ed781901a3368e65709" }, { "path": "docs/ai/AUTONOMOUS_EXECUTION_PROTOCOL.md", @@ -857,19 +902,19 @@ }, { "path": "docs/ai/BACKLOG.yaml", - "bytes": 81845, - "sha256": "4a2fb563da45cdcbdb57b36968a9f13da7372370fbcd12b2432e49b62cc1adee" - }, - { - "path": "docs/ai/CODE_REVIEW.md", - "bytes": 1810, - "sha256": "0c6716f0a4da8e8dd76446bff6d9c2ec1e9d8e5f44bcc39e8a0b7094723d3987" + "bytes": 101281, + "sha256": "ac86700bd2976f4a01ef414e77ac3cc8a4a3f201c0709902a6385ac315e030cd" }, { "path": "docs/ai/CODEX_CONFIG.example.toml", "bytes": 469, "sha256": "64de3dce1453851f216ee48dcc6fdcb07b578b1fab7519c6ffc3609420a3907c" }, + { + "path": "docs/ai/CODE_REVIEW.md", + "bytes": 1810, + "sha256": "0c6716f0a4da8e8dd76446bff6d9c2ec1e9d8e5f44bcc39e8a0b7094723d3987" + }, { "path": "docs/ai/DECISION_LOG.md", "bytes": 2476, @@ -877,8 +922,8 @@ }, { "path": "docs/ai/PROJECT_STATE.md", - "bytes": 35480, - "sha256": "63656724c7f86cc75f8d400939445233c8b926fed22f902fdfd293210fe8593c" + "bytes": 46417, + "sha256": "1288acc8425068053a8b3ebafd8abfd89708e6232a0bfb5b04465d161eec5406" }, { "path": "docs/ai/SESSION_BOOTSTRAP_PROMPT.md", @@ -895,6 +940,36 @@ "bytes": 2634, "sha256": "6c3f070ba6aadb3e95fa2a9f4d4bcfce1e4a4e7567f53380cde552d875b62c6e" }, + { + "path": "docs/architecture/DATA_FLOW.md", + "bytes": 3372, + "sha256": "fc162692ffa5a5af0940dd57d3063a34ef551333962074586196bffa85531a89" + }, + { + "path": "docs/architecture/DATA_MODEL.md", + "bytes": 4540, + "sha256": "00372369b4e560da0d7ab4127fbf50a48cac294316b6c11c387dd6d027e0b6f1" + }, + { + "path": "docs/architecture/SCORING_ENGINE.md", + "bytes": 5445, + "sha256": "4a5fbb1e7ff555659c88a792464f00b4594808b47d605cc750bdc78537d64cd9" + }, + { + "path": "docs/architecture/SECURITY_ARCHITECTURE.md", + "bytes": 4166, + "sha256": "868c258928cc8d2b85fd3deb84bf49cd1fcc12d926bd4a1d4f14deb8b635f260" + }, + { + "path": "docs/architecture/SOURCE_ADAPTERS.md", + "bytes": 7445, + "sha256": "99f0c0009ef9b32cb78608a27b9e735111e5eed97ac440d05a85e083e64d825c" + }, + { + "path": "docs/architecture/SYSTEM_ARCHITECTURE.md", + "bytes": 5186, + "sha256": "ca19bcd61db9b5a73b373d97b09977557842488e621e55d173255b2a9a269e24" + }, { "path": "docs/architecture/adrs/0001-modular-django-monolith.md", "bytes": 1045, @@ -940,51 +1015,51 @@ "bytes": 2160, "sha256": "0c2bf6d00a2c603dd205b06af629e7d8f5d3d9190bcd2b4af765f877f0b9dd71" }, - { - "path": "docs/architecture/DATA_FLOW.md", - "bytes": 3372, - "sha256": "fc162692ffa5a5af0940dd57d3063a34ef551333962074586196bffa85531a89" - }, - { - "path": "docs/architecture/DATA_MODEL.md", - "bytes": 4540, - "sha256": "00372369b4e560da0d7ab4127fbf50a48cac294316b6c11c387dd6d027e0b6f1" - }, - { - "path": "docs/architecture/SCORING_ENGINE.md", - "bytes": 5445, - "sha256": "4a5fbb1e7ff555659c88a792464f00b4594808b47d605cc750bdc78537d64cd9" - }, - { - "path": "docs/architecture/SECURITY_ARCHITECTURE.md", - "bytes": 4166, - "sha256": "868c258928cc8d2b85fd3deb84bf49cd1fcc12d926bd4a1d4f14deb8b635f260" - }, - { - "path": "docs/architecture/SOURCE_ADAPTERS.md", - "bytes": 7445, - "sha256": "99f0c0009ef9b32cb78608a27b9e735111e5eed97ac440d05a85e083e64d825c" - }, - { - "path": "docs/architecture/SYSTEM_ARCHITECTURE.md", - "bytes": 5186, - "sha256": "ca19bcd61db9b5a73b373d97b09977557842488e621e55d173255b2a9a269e24" - }, { "path": "docs/design/ACCESSIBILITY.md", "bytes": 2799, "sha256": "103929b971920c85b70328a5e4190dd2c9ae6ddadbacf8ce7793dff8e237a4c9" }, - { - "path": "docs/design/dashboard-wireframe.svg", - "bytes": 4449, - "sha256": "344d02fe9a24bbccd50d78561842d04e50af2e583a9d5b96eb894f653ed43c38" - }, { "path": "docs/design/DESIGN_SYSTEM.md", "bytes": 4103, "sha256": "345b508ea0cc86ed7e64d7f82847991deb3ac6a35c99ee607b608a2576d81d79" }, + { + "path": "docs/design/STITCH_FRONTEND_CONTRACT.md", + "bytes": 4417, + "sha256": "eb5268b70b046c2b28a1535da21e80570c30b9e795fc7878923118f68d5a194c" + }, + { + "path": "docs/design/STITCH_INTEGRATION_PLAN.md", + "bytes": 8546, + "sha256": "ea23ff53bceb27803c56ad98baa896e92faf9f97be9ff28446e93ef4c4a3f31e" + }, + { + "path": "docs/design/STITCH_INTEGRATION_REPORT.md", + "bytes": 12961, + "sha256": "86bee84150c9cd1c5956a53ceef34c95b19c7ba7625d80964993f29b23e33e26" + }, + { + "path": "docs/design/UX_SPEC.md", + "bytes": 3992, + "sha256": "ee1865bb3efae5943952098c42b505547d78b74a4268c44dfe13e3e84a3a5bb2" + }, + { + "path": "docs/design/VISUAL_AUDIT_2026-07-26.md", + "bytes": 7275, + "sha256": "305fcd2e307d5ad4b4b3c99fc9c2f86c38e943b4df18426c89c77708b88eb8d5" + }, + { + "path": "docs/design/WIREFRAMES.md", + "bytes": 5678, + "sha256": "4afdfb392adf415ca77c5e503042c6dd1a4744d2772645267ea4ee7facf27970" + }, + { + "path": "docs/design/dashboard-wireframe.svg", + "bytes": 4449, + "sha256": "344d02fe9a24bbccd50d78561842d04e50af2e583a9d5b96eb894f653ed43c38" + }, { "path": "docs/design/stitch-reference/activiteitenlogboek/code.html", "bytes": 21557, @@ -1081,24 +1156,14 @@ "sha256": "4a49d9d1c9c7249465ddd24f5d64ba5957592eb2a4d9e76eef4f851a8c15ce6d" }, { - "path": "docs/design/STITCH_FRONTEND_CONTRACT.md", - "bytes": 4417, - "sha256": "eb5268b70b046c2b28a1535da21e80570c30b9e795fc7878923118f68d5a194c" + "path": "docs/design/validation/VR-119_REPORT.md", + "bytes": 1254, + "sha256": "e71d3733313a4c8c932dd15fb308d5b1e5618ee003ad22f05d2c5ce21d871edc" }, { - "path": "docs/design/STITCH_INTEGRATION_PLAN.md", - "bytes": 8546, - "sha256": "ea23ff53bceb27803c56ad98baa896e92faf9f97be9ff28446e93ef4c4a3f31e" - }, - { - "path": "docs/design/STITCH_INTEGRATION_REPORT.md", - "bytes": 7007, - "sha256": "ed0b206057c5be278c5ff514fe9b62bd2a08e787ce20b94678eab50d7e659db9" - }, - { - "path": "docs/design/UX_SPEC.md", - "bytes": 3992, - "sha256": "ee1865bb3efae5943952098c42b505547d78b74a4268c44dfe13e3e84a3a5bb2" + "path": "docs/design/validation/VR-206_AUDIT.md", + "bytes": 4671, + "sha256": "50b6dad9d45bcca775fe4d47f50b5f0ab031cca894c02280af5ebbdffc5e99d7" }, { "path": "docs/design/validation/applications-dark-1280x800.png", @@ -1180,16 +1245,6 @@ "bytes": 74148, "sha256": "545febbbce38cab784bff11be4c78e4e608f71c424fbc8271013270305f13c84" }, - { - "path": "docs/design/validation/VR-119_REPORT.md", - "bytes": 1254, - "sha256": "e71d3733313a4c8c932dd15fb308d5b1e5618ee003ad22f05d2c5ce21d871edc" - }, - { - "path": "docs/design/validation/VR-206_AUDIT.md", - "bytes": 4671, - "sha256": "50b6dad9d45bcca775fe4d47f50b5f0ab031cca894c02280af5ebbdffc5e99d7" - }, { "path": "docs/design/validation/vr119-applications-dark-1440x900.png", "bytes": 74492, @@ -1240,11 +1295,6 @@ "bytes": 74142, "sha256": "edca2718bc18cb702caa8d01023a71aeaf68538c8d2042daee56f9c22fb69d9e" }, - { - "path": "docs/design/WIREFRAMES.md", - "bytes": 5678, - "sha256": "4afdfb392adf415ca77c5e503042c6dd1a4744d2772645267ea4ee7facf27970" - }, { "path": "docs/operations/BACKUP_RESTORE.md", "bytes": 3670, @@ -1270,6 +1320,11 @@ "bytes": 3281, "sha256": "c92db78ed20479cfda37c9db26f33d7a33ffc2df6575b63959529a2d5409b244" }, + { + "path": "docs/operations/NGINX_PROXY_MANAGER.md", + "bytes": 4576, + "sha256": "0d46c1deb196f05111f7ba2d3c0d8950b65aa94df9ac8823de6df52aa6f475ff" + }, { "path": "docs/operations/RUNBOOK.md", "bytes": 6922, @@ -1282,8 +1337,8 @@ }, { "path": "docs/operations/UNRAID_DEPLOYMENT.md", - "bytes": 7958, - "sha256": "95a27b670ffb5d64cec30cc8f300dc7d6ad4ae616f39ab96ddf8b4122b554d36" + "bytes": 9612, + "sha256": "cef9dadf60fc3b4a0d6aa3759833cd5e944354238f6f8521d2223900c6bfaf28" }, { "path": "docs/product/ACCEPTANCE_CRITERIA.md", @@ -1315,6 +1370,11 @@ "bytes": 4024, "sha256": "5b06bf21a65ddfefceaa700ceb7ed944ddc4b9597257d0f993951444cac8d6c8" }, + { + "path": "docs/quality/PRODUCTION_AUDIT_2026-07-27.md", + "bytes": 4685, + "sha256": "e4d2e735e5aa034c87ae9b97c3827ed4d19db9741375a49d8b9033fb61e9cabe" + }, { "path": "docs/quality/RELEASE_CHECKLIST.md", "bytes": 2041, @@ -1327,23 +1387,13 @@ }, { "path": "docs/quality/THREAT_MODEL.md", - "bytes": 15196, - "sha256": "ea040b20aff44671fd5da6572e05b6b7629340defb48d511b9e94690a66247f5" + "bytes": 16045, + "sha256": "5b44904a6c66d8730435fb89307c7ded4d25c1017f2813f60bd664e85facf589" }, { "path": "docs/quality/TRACEABILITY_MATRIX.md", - "bytes": 14114, - "sha256": "6b61eba9d17acd3ef1ffab1c76b7d05f20e5b076ecb18aa3105bc19c2920d94a" - }, - { - "path": "docs/README.md", - "bytes": 1667, - "sha256": "325b8df8b58afdb8cbe8f0d9603ca49d5a9c4ae6a2bb4ed781901a3368e65709" - }, - { - "path": "docs/reference/original-design-extract.md", - "bytes": 66524, - "sha256": "f3df06b49c3ad0ef6f2525988d216dd2b9e5fca8e43f4603d1112ef0ec780e2a" + "bytes": 16566, + "sha256": "dca5e059cdd337480d4db72834c301023f50b61ebef595ed826f5f6b082ca29c" }, { "path": "docs/reference/VacatureRadar_functioneel_en_technisch_ontwerp.docx", @@ -1355,6 +1405,31 @@ "bytes": 1298853, "sha256": "c67ea54e9d5eed7c8404e56d00d0a559cce01032b25890c1d3a97e31d8f640db" }, + { + "path": "docs/reference/original-design-extract.md", + "bytes": 66524, + "sha256": "f3df06b49c3ad0ef6f2525988d216dd2b9e5fca8e43f4603d1112ef0ec780e2a" + }, + { + "path": "docs/screenshots/readme/automation-dark.png", + "bytes": 108939, + "sha256": "0f89f900a02395ce5d4392856ea8944c9c4df46a1012ec1eee7e06c7babdf908" + }, + { + "path": "docs/screenshots/readme/dashboard-light.png", + "bytes": 75116, + "sha256": "815712d024b23d6d146cbfa648cce640c04bc387887a2e0eeb52bbe12eac0ce4" + }, + { + "path": "docs/screenshots/readme/matches-dark.png", + "bytes": 111477, + "sha256": "4b0d47b519bb08f4d65698dc118bd74ae0aabf95c25f9def6c925b476af5d44c" + }, + { + "path": "docs/screenshots/readme/profile-mobile-light.png", + "bytes": 29248, + "sha256": "0c65818d05d6c3c36190b580239ddb568334010ac2f9f114ba99fb1e94aa8646" + }, { "path": "fixtures/ai/evaluation_set.json", "bytes": 2349, @@ -1605,40 +1680,20 @@ "bytes": 381, "sha256": "a9f657f8e2d91b79fed284bf4506a5abef2ddb862c35314d6290ccfa291443c7" }, - { - "path": "LICENSE", - "bytes": 1061, - "sha256": "a1e3ff537d8be01e7bec1376969d1b66ea2088dfcf7dbf1f2227793ef67c13a6" - }, { "path": "local/.gitkeep", "bytes": 0, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - { - "path": "Makefile", - "bytes": 1236, - "sha256": "1e3b04f7b74167347c2f11bebb9161a716c6fa4e4cb2630caa8f6af044fece81" - }, { "path": "manage.py", "bytes": 450, "sha256": "ee9c7fa0f1ad7758fbf6fd6c5ec01de1e4d20cee205c6247604d8418ae000d05" }, - { - "path": "PLANS.md", - "bytes": 980, - "sha256": "0bb6d51c01003a0dbf9de3a82aeb13967f024d9c267386f29c76fc7a4be97fa6" - }, { "path": "pyproject.toml", - "bytes": 1912, - "sha256": "7d4566a38c7603c5d264a03792acb31e55fe1a107a00069eb35c5b4c4c6bdf83" - }, - { - "path": "README.md", - "bytes": 9419, - "sha256": "9cef6efa806e4270945f12051366670060d481264df992554b6d6c576a88fdb9" + "bytes": 1913, + "sha256": "fc0262f9980675bace3f2c97eb56db0dff322460bf55d9024587f2ea98f57b61" }, { "path": "scripts/backlog.py", @@ -1665,15 +1720,25 @@ "bytes": 897, "sha256": "619eccea2c0570fd813105818b30840244d26946fa3ea09a63c1b403efaf57c3" }, + { + "path": "scripts/configure_public_url.py", + "bytes": 6633, + "sha256": "272b3ed139aab3105d1d0cd784ffa71e8df5c6674c4af2a35da44d2625c32add" + }, { "path": "scripts/deploy_docker.sh", - "bytes": 5151, - "sha256": "f4f746d6ddc822c293cb60cba127602fb9876181bb634e88e9b408e075a09cce" + "bytes": 6394, + "sha256": "50cdb5f22d37eade00ea29f4903a5f8e08bd2c15f34f181d40c939a1c01c9563" + }, + { + "path": "scripts/deploy_unraid.sh", + "bytes": 1896, + "sha256": "1b84338119304575c06b3384de1b8ae46c1f720b6085fda87c0ace1c0c57d796" }, { "path": "scripts/entrypoint.sh", - "bytes": 309, - "sha256": "ef5ef0561af09e970bbc7dfc49ea28f589e65c108ca02cb3cf5d4932e6a525db" + "bytes": 342, + "sha256": "9f16ae543fc03778e416bf4cac931225a62875ea463593d0bea22552e352b085" }, { "path": "scripts/feedback_learning_report.py", @@ -1707,8 +1772,8 @@ }, { "path": "scripts/remote_deploy.sh", - "bytes": 1464, - "sha256": "42365240d91e665f914bd85f83e1c78e0d40333a6463031e72695f6ea40cc817" + "bytes": 1534, + "sha256": "2193559afd4cdae404da9df01e38bbebf12797501c457ece8b06b9626cb0a284" }, { "path": "scripts/reset_demo.sh", @@ -1722,13 +1787,13 @@ }, { "path": "scripts/unraid_aio_entrypoint.sh", - "bytes": 2172, - "sha256": "710bef477af9138dc9713626637fb1fbbb1b6a8592602cabe7bb00bb64de4543" + "bytes": 2222, + "sha256": "1446d514d4f3516a89dac3d0aab1e12f7a17b74f769bd29bcfea64e4c6f6d34d" }, { "path": "scripts/validate_repository.py", - "bytes": 6602, - "sha256": "adcfb8ef285fee7977aefecd295b0de674e42e900f90e9d6712aada5a9786cb4" + "bytes": 6811, + "sha256": "b5b66de7edc2d2b6be8b95bf7f57bde1fda07246444e56222ac1d565fcd346df" }, { "path": "scripts/validate_task_ledger.py", @@ -1740,20 +1805,15 @@ "bytes": 5096, "sha256": "5c14fd14ace8772230855c6b6fe2cada7ecb6e34faf7756efdf5c613768900f9" }, - { - "path": "SECURITY.md", - "bytes": 1931, - "sha256": "9ca57ce4d8b077fe860343762595c3af3a5aad1a843582faa1bb2197b3b638a6" - }, { "path": "static/css/app.css", - "bytes": 167, - "sha256": "7d9d7af305fe2a432dc063a45b4498a837d8d3d3a02e391a515ef7d41e0013ea" + "bytes": 412, + "sha256": "f10cd9a438835f8c3c1486aebd47a479b06ec70db1f056ad15cac3f97a520d04" }, { "path": "static/css/auth.css", - "bytes": 10851, - "sha256": "7eece0e2ccc5287a3ce1989ce420d6d756e4cc7fab3a16f20a545837612790e9" + "bytes": 11451, + "sha256": "c41b8ceddf290018694c3770d83cf75dda530cb6fdb3629105c8571664e1def8" }, { "path": "static/css/base.css", @@ -1762,18 +1822,23 @@ }, { "path": "static/css/components.css", - "bytes": 8969, - "sha256": "06d20ce96284a9a6467a4490ae4d8cef2289fe068c3fa883e7a17d3bc003bbe3" + "bytes": 9781, + "sha256": "7f2c9afb3ba817120012f119d196a14e09cc439bede3eaabafca82dec5f975c8" }, { "path": "static/css/layout.css", - "bytes": 5792, - "sha256": "abc44cfb128364eb85596d41640088d9d22dc3ac1f223297d0d8a8095724f7c3" + "bytes": 5359, + "sha256": "e518204f414b9335de976a547712223b38ed3bc1e2c83e1df31f2baef8c48152" }, { "path": "static/css/pages.css", - "bytes": 42761, - "sha256": "992ff9b3b369c0acf6094309a703a4c52745f4a1863f1191f9231976e9bdf710" + "bytes": 42824, + "sha256": "2afa1b76faeaa3fa1a16082c4708847e83bf49cd2cb19c4c5471c90fdedbe9d8" + }, + { + "path": "static/css/premium.css", + "bytes": 14552, + "sha256": "3b1590b7edc74ab0490535ecbc73a73d9238d287a5cc61f559e4637a344aad5a" }, { "path": "static/css/responsive.css", @@ -1787,8 +1852,8 @@ }, { "path": "static/favicon.svg", - "bytes": 363, - "sha256": "ff0b841559133c88c7d9a9c804a3b56a80e3cf192c965fd850f3d7995c527b2d" + "bytes": 560, + "sha256": "41183eb09cc0d81dfde974776c64ffaab389aafe21c43f3e0c2202372b235e91" }, { "path": "static/fonts/geist-400.woff2", @@ -1810,20 +1875,35 @@ "bytes": 21832, "sha256": "cb182feeed4d798ff6961d3c79f7026279448fca0676438aaecb21f3fc39553a" }, + { + "path": "static/img/README.md", + "bytes": 841, + "sha256": "422335be5ae558ef202bff22f55b84584260bfa047f9fc51757d1545f03546c6" + }, + { + "path": "static/img/itworx-wordmark.png", + "bytes": 86817, + "sha256": "e35a5a46015df232005628a8f7aa7dbaea9030c195a6a90858d68eec5efd106a" + }, { "path": "static/img/vacatureradar-docker.png", - "bytes": 7111, - "sha256": "5037a1ec225af75e91f0f31bb8496d1b627cc9e03fe67423d42a8a290253b894" + "bytes": 10459, + "sha256": "ca1c32350b6baa048b4f2ca831579bce5fd7aeaa733b853d745f712af463c74c" }, { "path": "static/img/vacatureradar-logo.svg", - "bytes": 424, - "sha256": "382879307fff6e6d0ad03f80723b132f753b0888ab97f5bbad17ef9d567b4f66" + "bytes": 464, + "sha256": "e5fa24a32a3206e44526b1e5e12dabbc3bf0d5968ef4110cb7d451a4b155aa62" }, { "path": "static/js/app.js", - "bytes": 2828, - "sha256": "ec7d0e0c0517f8f9f7bac9d43faa5a642c94bb982553baec649b9aee5d08b65f" + "bytes": 4108, + "sha256": "4ee60180a74287b47d3dc6693f9d198a99fd736098c2b9266f251c4b76a35239" + }, + { + "path": "static/js/login.js", + "bytes": 1016, + "sha256": "e11558f688998ed33d3b157b37786dc7d53646290a0516112e34ec19744ab157" }, { "path": "static/js/manual_import.js", @@ -1867,14 +1947,24 @@ }, { "path": "templates/base.html", - "bytes": 9029, - "sha256": "2e98cb4c987a248f612a2ca585ce838e7c0e603d557cfd86116e7f1d6713df77" + "bytes": 9836, + "sha256": "0e32ba95e8a7d683b977927de8dd61d8dc25519f3c176adaec43b0e850fc85a2" + }, + { + "path": "templates/components/developer_credit.html", + "bytes": 211, + "sha256": "2fed3419d5fa8eb80eee301e8aed197d2431fc96788b8d9652c760f2737026d6" }, { "path": "templates/components/icon_sprite.html", "bytes": 4347, "sha256": "43a0e05b1d65c16d0f76aa948afa8b834c9f4652f1ef1821a41481e754cecfa9" }, + { + "path": "templates/components/radar_illustration.html", + "bytes": 1323, + "sha256": "f740f80ebf18c762f238064be95c8f4fea7593c1059a276abf7065659bc9fc17" + }, { "path": "templates/dashboard/today.html", "bytes": 6907, @@ -1932,28 +2022,28 @@ }, { "path": "templates/profiles/list.html", - "bytes": 1690, - "sha256": "5015f46bc478384f0bc2b0b2dea76fa604536c99cfa9435886d8f8abb7b236c4" + "bytes": 2018, + "sha256": "5a404ef56d047d5312866395a0ab47fad9622fc58ac0b698b40beee7a6cbf0b5" }, { "path": "templates/registration/login.html", - "bytes": 6243, - "sha256": "040770b92d7ad4a7a359cd4f6ad37cd820ec4c2adf9e7855d5a6520388045d08" + "bytes": 6065, + "sha256": "642b3577d26001515738c1ada26e57135e8d252865782d751200d3bf9c694e9e" }, { "path": "templates/registration/password_reset_complete.html", - "bytes": 1415, - "sha256": "6bc77cefd19850711bf8809944c17801373d37bf96c56cab8f62fcd613c09f7a" + "bytes": 1508, + "sha256": "c09655350bc10cd6611ce694176b680a834760edf07d4afd9143acc6072529a5" }, { "path": "templates/registration/password_reset_confirm.html", - "bytes": 2978, - "sha256": "3ef47c477a5f129265dae53b7810f57cbd4050c9998658d7b2ae268966efa3fe" + "bytes": 3071, + "sha256": "a39ed3c869a0db97c00f60822b4bf91d59434cfcc3ebc150f9c61e216631f646" }, { "path": "templates/registration/password_reset_done.html", - "bytes": 1373, - "sha256": "32efffb516923ef982998fa80a75982bd4be72260fd1738869800e9b5e26cd1d" + "bytes": 1466, + "sha256": "22995fb1d6c3125983c1697d7a1e58d12ef8f9fdff7b244f33697b556a3a6a89" }, { "path": "templates/registration/password_reset_email.html", @@ -1962,8 +2052,8 @@ }, { "path": "templates/registration/password_reset_form.html", - "bytes": 1998, - "sha256": "ea27a14361ae7ded6c7185cc9d05ce197388097c6be38b9ec9a66168356768d4" + "bytes": 2091, + "sha256": "31190526b1bfd5b86a04169e12ee47b2088aa992d50c5653364d9402654fca74" }, { "path": "templates/registration/password_reset_subject.txt", @@ -1997,8 +2087,8 @@ }, { "path": "tests/e2e/test_vr114_browser_and_accessibility.py", - "bytes": 11465, - "sha256": "588d75af1ae2ddd44551d76a38a983543da7831e83282d3045884bea62f56336" + "bytes": 12123, + "sha256": "6e0f26b58b47f15173a005b3bc1d81c317aa25270c9d8eb6b56d8f88fd0a4708" }, { "path": "tests/integration/test_applications.py", @@ -2007,8 +2097,8 @@ }, { "path": "tests/integration/test_auth_login.py", - "bytes": 5311, - "sha256": "2668e0af951061eb3faadf9d2f26bd304abb10c6c697611b2994c655c6ad319d" + "bytes": 7471, + "sha256": "cb2b81368c601f2ef274db48d3a0f46e380ae27555e0b931df16fa2962015220" }, { "path": "tests/integration/test_dedupe_lifecycle_feedback.py", @@ -2082,8 +2172,8 @@ }, { "path": "tests/integration/test_views.py", - "bytes": 6145, - "sha256": "9784f5e94cbf4b43dba2d9b76f943dcc3db990b1b6b7e89c257414c45a854ceb" + "bytes": 7963, + "sha256": "f36331808fdca84e8ca31c0f1e91ef0ac48a0e5280ad09997619ad010211b0aa" }, { "path": "tests/security/test_policy.py", @@ -2122,8 +2212,8 @@ }, { "path": "tests/unit/test_deployment_configuration.py", - "bytes": 3099, - "sha256": "183c3773f24e0b3ed69685e18857b065662935611be3fcd2ecc3bb29f0bf33c9" + "bytes": 5571, + "sha256": "2b704349577f03a6da2d936ddb8890a556c2fcb232b944db9fccdaf2a199c477" }, { "path": "tests/unit/test_discovery_rss.py", @@ -2165,6 +2255,11 @@ "bytes": 6798, "sha256": "11a79bb94cdd6d5c60674de80623ef48f6575fd237d68ef5a86d6fe3a501ca38" }, + { + "path": "tests/unit/test_health.py", + "bytes": 795, + "sha256": "fd410a3e25586c58165757acd7c0d19c6167f41efa88729235b6ceb8d294d40c" + }, { "path": "tests/unit/test_jsonld_adapter.py", "bytes": 583, @@ -2180,6 +2275,11 @@ "bytes": 3049, "sha256": "c5a581ab1fb02576233afed3a3ef5b622fe7f4fc62d38abe13c103c51e77d634" }, + { + "path": "tests/unit/test_network.py", + "bytes": 1006, + "sha256": "be01553f81e090dbbbef550051a229cf1bcc3810adfd6936c9d3817d9ad80451" + }, { "path": "tests/unit/test_normalization.py", "bytes": 587, @@ -2190,6 +2290,11 @@ "bytes": 2424, "sha256": "e8f4fca80c35c0787d4ffd36325cccac603186413af959f68d18deaa667d05f7" }, + { + "path": "tests/unit/test_public_configuration.py", + "bytes": 4509, + "sha256": "ae7b56824a37680d2b68e31e545ca828f481bf484a2398e969f5f615a9560caf" + }, { "path": "tests/unit/test_rate_limit.py", "bytes": 2504, @@ -2217,8 +2322,8 @@ }, { "path": "tests/unit/test_settings_security.py", - "bytes": 2637, - "sha256": "fbac6846e3fd64d22cab365f8651deb0955866194e45f46593f42e4b14ce108d" + "bytes": 3820, + "sha256": "817116949f63863dff57d6ec49203b991b36587a6c93c6adf796f5dd1a37a641" }, { "path": "tests/unit/test_skill_demand.py", @@ -2237,13 +2342,8 @@ }, { "path": "uv.lock", - "bytes": 106081, - "sha256": "3b846377252994715529090c6b20bec6950ca7dbc92e93cbcc277175bcb4d1d5" - }, - { - "path": "VERIFICATION_REPORT.md", - "bytes": 4650, - "sha256": "cd1680b3929606470698eca25a89af93f3582e67ae0234a7e2368ffa686ab6a4" + "bytes": 106082, + "sha256": "e881018bbdf76bce1e0494693133c4d76a3c9967f9f1a13beb7db7b6c05fcd3f" } ] } diff --git a/README.md b/README.md index 4923af9..4c4fb54 100644 --- a/README.md +++ b/README.md @@ -94,18 +94,18 @@ Na de eerste login: 4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt. 5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks. -Belangrijke productie-instellingen: +Voor een publieke HTTPS-host configureer je de bestaande `.env` veilig met: -```dotenv -APP_HOST=vacatures.example.be -APP_SCHEME=https -DJANGO_DEBUG=0 -SESSION_COOKIE_SECURE=1 -CSRF_COOKIE_SECURE=1 -SECURE_SSL_REDIRECT=1 +```bash +python scripts/configure_public_url.py \ + https://vacatureradar.example.be \ + --env-file .env \ + --cache-url redis://127.0.0.1:6379/1 ``` -Publiceer poort `1226` niet rechtstreeks op het internet. Gebruik een beheerde reverse proxy met TLS en volg de checklist in [UNRAID_DEPLOYMENT.md](docs/operations/UNRAID_DEPLOYMENT.md). +De publieke URL wordt vervolgens gebruikt voor `ALLOWED_HOSTS`, CSRF, secure cookies en HTTPS-detectie. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; een debugpagina mag nooit publiek zichtbaar zijn. Hermaak de container na een environmentwijziging. + +Publiceer poort `1226` niet rechtstreeks op internet. Gebruik een beheerde reverse proxy met TLS en volg [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) en de concrete [Nginx Proxy Manager-handleiding](docs/operations/NGINX_PROXY_MANAGER.md). Een volledig productievoorbeeld staat in [`deployment/production.env.example`](deployment/production.env.example). ## Eerlijke grenzen @@ -143,6 +143,7 @@ Die controleert formatting, lint, Django-configuratie, migraties, de volledige o - [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md) - [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md) +- [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.md) - [Push-to-deploy met Gitea](docs/operations/GITEA_DEPLOYMENT.md) - [Operationeel runbook](docs/operations/RUNBOOK.md) - [Back-up en herstel](docs/operations/BACKUP_RESTORE.md) diff --git a/SECURITY.md b/SECURITY.md index d613306..4989a7e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,7 +30,11 @@ Deze data mag nooit shell-, netwerk-, e-mail-, bestandssysteem- of sollicitatiea - minimale ruwe-dataretentie; - geen secrets in logs of databasefixtures; - AI uitgeschakeld als veilige standaard; -- menselijke bevestiging voor iedere sollicitatie. +- menselijke bevestiging voor iedere sollicitatie; +- een publieke URL die exact in host- en CSRF-validatie zit; +- `DEBUG=False`, secure cookies en correcte proxy-HTTPS-detectie; +- forwarded client-IPheaders alleen vanaf expliciet vertrouwde proxy-CIDR's; +- een gedeeld gastaccount uitsluitend in server-side read-onlymodus. ## Kwetsbaarheid melden @@ -42,7 +46,7 @@ Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documente 4. voorgestelde mitigatie; 5. eventuele rotatie- of verwijderacties. -Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. +Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. Publieke reverse-proxyproblemen worden behandeld volgens `docs/operations/NGINX_PROXY_MANAGER.md`. ## Geen securityclaims diff --git a/VERIFICATION_REPORT.md b/VERIFICATION_REPORT.md index 95a1d02..75778d4 100644 --- a/VERIFICATION_REPORT.md +++ b/VERIFICATION_REPORT.md @@ -1,102 +1,68 @@ -# Verificatierapport van de aangeleverde projectbasis +# Verificatierapport — VacatureRadar 0.3.12 -- Datum: 2026-07-20 -- Omgeving: Linux, Python 3.13.5, uv 0.10.0 -- Projectversie: 0.1.0 -- Doel: aantonen dat de ZIP een reproduceerbare, werkende MVP-basis en autonome overdracht bevat +- Datum: 27 juli 2026 +- Onderwerp: productiehardening, Nginx Proxy Manager, gastmodus en overdrachts-ZIP +- Branch: `fix/public-production-hardening` -## Uitgevoerde volledige gate +## Gecontroleerd -Commando: +### Applicatie en regressies -```bash -./scripts/codex_verify.sh +```text +296 tests geslaagd +2 optionele Playwrighttests overgeslagen omdat de browserbinary niet lokaal aanwezig was +83,43% branch-aware codedekking; vereiste drempel 70% ``` -Resultaat: +De afzonderlijke HTML- en accessibilityprobe bleef actief. De Playwrightfallback is hersteld zodat een geïnstalleerde Pythonmodule zonder browserbinary geen synchrone databasecall meer binnen de Playwright-eventloop uitvoert. -- Ruff: geslaagd; -- Django system check: geslaagd, 0 issues; -- migratiecontrole: geslaagd, geen ontbrekende migraties; -- pytest: 43/43 tests geslaagd; -- branch-aware codedekking: 80,42% (`apps` en `config`), drempel 70%; -- machineleesbare backlog: geldig, 35 taken en acyclische dependencies; -- lokale Markdownlinks, YAML/Compose/OpenAPI, `.env.example` en Codex-skillfrontmatter: geldig. +### Django en data -De tests omvatten unit-, integratie- en securitypaden voor parsers, URL-security, fetchbeleid, sanitization, normalisatie, dedupe, scoring, profiles, e-mailimport, notificaties, lifecycle, tasks en webviews. +- `python manage.py check`: 0 issues; +- `python manage.py makemigrations --check --dry-run`: geen wijzigingen; +- productie-settings met `PUBLIC_BASE_URL=https://vacatureradar.itworx.tech`: laden correct; +- `manage.py check --deploy`: geen errors; HSTS-preload blijft bewust uit en geeft daarom alleen de verwachte Django-waarschuwing; +- publieke host en HTTPS-origin worden aantoonbaar aan `ALLOWED_HOSTS` en `CSRF_TRUSTED_ORIGINS` toegevoegd. -## Clean-room ZIP-proef +### Repository en deployment -De gegenereerde ZIP is in een lege directory uitgepakt. Voor de start is gecontroleerd dat `.env`, `.venv` en `local/db.sqlite3` niet aanwezig waren. Daarna is uitgevoerd: +- repositoryvalidator: geslaagd; +- taakledger: geldig, 64 taken, 0 uitvoerbare ready-taken; +- Docker Compose, Unraid Compose en backlog-YAML: parseerbaar; +- shellsyntax van deploy- en entrypoints: geldig; +- Pythoncompilecheck: geslaagd; +- `git diff --check`: geslaagd; +- packagevalidatie en manifestcontrole: geslaagd na definitieve packaging. -```bash -./scripts/codex_bootstrap.sh -``` +## Belangrijkste herstelde productieproblemen -Resultaat: +- `DisallowedHost` voor de publieke Nginx-host; +- publieke Django-debugmodus; +- verkeerde externe CSRF-origin met interne poort `1226`; +- healthchecks die door HTTPS-redirect konden falen; +- proceslokale rate limiting bij meerdere Gunicornworkers; +- onvoorwaardelijk vertrouwen in `X-Forwarded-For`; +- schrijfbare gedeelde demo; +- afwijkende Unraid `.env`-paden; +- database-URL-interpolatie buiten het Unraid-`env_file`; +- URL-import die zonder staticfilesmanifest kon falen tijdens deployment checks; +- stale project-/assetversie en verouderd verificatierapport. -- nieuwe virtuele omgeving uit `uv.lock` opgebouwd; -- alle migraties op een lege SQLite-database toegepast; -- lokaal beheeraccount, standaardprofiel en één demovacature aangemaakt; -- volledige kwaliteitsgate opnieuw geslaagd met 43 tests en 80,42% dekking; -- ontwikkelserver gestart; -- `/health/live/` gaf `200` met `ok: true`; -- `/health/ready/` gaf `200` met databasecheck `ok`; -- `/login/` gaf `200` met de Nederlandstalige titel `Aanmelden · VacatureRadar`. +## Omgevingsbeperking -## Offline/reproduceerbare eigenschappen +De normale `uv sync --frozen` kon in deze uitvoeringsomgeving geen packages downloaden door DNS-uitval. Voor de regressies zijn de pure Python-packages uit de door de gebruiker aangeleverde gelockte Windows-omgeving gecombineerd met reeds aanwezige Linux-wheels. Daardoor konden Django, pytest en de volledige offline suite betrouwbaar draaien. -- De standaardtests doen geen live internetrequests. -- IMAP en Ollama staan standaard uit. -- Celery draait in tests eager en vereist geen Redis. -- SQLite wordt voor lokale bootstrap/tests gebruikt; productieconfiguratie ondersteunt PostgreSQL. -- Externe bronformaten worden via gesaneerde fixtures getest. -- `.venv`, caches, `.env`, lokale database, logs, media en back-ups worden door het packaging-script uitgesloten. +De Ruff-Linuxbinary en Playwright-browser konden zonder netwerk niet worden geïnstalleerd. Pythoncompile, alle tests, Djangochecks, lijnlengtecontrole op gewijzigde Pythonbestanden en `git diff --check` zijn wel uitgevoerd. Een Dockerdaemon en de live Unraid/Nginx-omgeving waren niet beschikbaar. -## Autonome overdracht +## Nog op de doelserver uitvoeren -Gevalideerd: +1. bestaande `.env` back-uppen; +2. `scripts/configure_public_url.py` uitvoeren; +3. container met `--force-recreate` herbouwen; +4. interne liveness/readiness controleren; +5. publieke TLS-, login-, logout- en CSRF-smoke uitvoeren; +6. demo-read-only controleren; +7. exacte Nginx Proxy Manager-CIDR instellen wanneer individuele client-IP-rate-limiting gewenst is; +8. databaseback-up en herstel-smoke uitvoeren. -```bash -python scripts/backlog.py validate -python scripts/backlog.py next -``` - -Ledgerstatus bij oplevering: - -- 10 taken `done`: de werkende MVP- en overdrachtsbasis; -- 17 taken `ready`: uitvoerbare product-, hardening- en releasestappen; -- 5 taken `blocked-external`: uitsluitend live bronreview, accounts, domein/registry of bulkdataset; -- 3 taken `deferred`: multi-tenant SaaS, automatisch solliciteren en algemene publieke API. - -Codex krijgt vaste productbeslissingen via `AGENTS.md`, `CODEX_START_HERE.md`, accepted ADRs, een repo-eigen skill en een startprompt. Een externe blokkade mag alleen het live restpunt blokkeren; interface, tests, mocks en documentatie moeten eerst worden voltooid. - -## Niet in deze omgeving uitgevoerd - -Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving. - -Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`. - -## VR-118 — Stitch Intelligence Cockpit (2026-07-21) - -Aanvullend op de bestaande releasebasis is de volledige interface opnieuw geïntegreerd en gecontroleerd. - -Uitgevoerde browsermatrix: - -- 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800; -- exact één `h1` en geen documentbrede horizontale overflow op ieder formaat; -- dashboard, vacaturelijst/detail, sollicitatiepipeline, profiel/editor, bronnen en systeem; -- dark en light, mobiele open/sluitnavigatie, bron-disclosure en vacature → gesolliciteerd → pipeline; -- geen externe productie-scripts of stylesheets en geen nieuwe consolefouten na de storagefallbackfix. - -Screenshots: `docs/design/validation/`. Deze map en de originele Stitch-referentie zijn via `.dockerignore` uitgesloten van de productiecontainer. - -Volledige afrondingsgate: - -- Ruff: geslaagd; -- Django system check: geslaagd; -- migratiecontrole: geen wijzigingen; -- pytest: 146 geslaagd, 2 optionele Playwrightvarianten overgeslagen met geslaagde HTML/a11y-fallback; -- branch-aware codedekking: 82,00%, boven de vereiste 70%; -- taakledger: 36 taken, waarvan 28 done, 5 blocked-external, 3 deferred en 0 ready; -- repositorydocumentatie en configuratie: geldig. +Zie `docs/operations/NGINX_PROXY_MANAGER.md` en `docs/quality/PRODUCTION_AUDIT_2026-07-27.md`. diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index cdcf9be..7b2180d 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -28,7 +28,7 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]: } context = { "app_name": "VacatureRadar", - "app_version": "0.3.11", + "app_version": settings.VACATURERADAR_VERSION, "app_owner_name": settings.VACATURERADAR_OWNER_NAME, "page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"), } @@ -38,4 +38,8 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]: .only("id", "name") .first() ) + context["demo_read_only"] = bool( + getattr(settings, "DEMO_READ_ONLY", True) + and request.user.username == settings.DEMO_USERNAME + ) return context diff --git a/apps/core/health.py b/apps/core/health.py index 3bc7ab9..99c3437 100644 --- a/apps/core/health.py +++ b/apps/core/health.py @@ -1,9 +1,12 @@ from __future__ import annotations +import secrets from dataclasses import asdict, dataclass +from django.conf import settings +from django.core.cache import cache from django.db import connections -from django.db.utils import OperationalError +from django.db.utils import DatabaseError @dataclass(frozen=True) @@ -23,7 +26,20 @@ def readiness() -> HealthResult: cursor.execute("SELECT 1") cursor.fetchone() checks["database"] = "ok" - except OperationalError as exc: + except DatabaseError as exc: ok = False checks["database"] = f"error:{exc.__class__.__name__}" + + if getattr(settings, "HEALTHCHECK_REQUIRE_CACHE", False): + key = f"health:{secrets.token_hex(8)}" + try: + cache.set(key, "ok", timeout=10) + if cache.get(key) != "ok": + raise RuntimeError("cache roundtrip failed") + cache.delete(key) + checks["cache"] = "ok" + except Exception as exc: # Health boundary: report category, never payload/details. + ok = False + checks["cache"] = f"error:{exc.__class__.__name__}" + return HealthResult(ok=ok, checks=checks) diff --git a/apps/core/middleware.py b/apps/core/middleware.py index 4c62941..1091b80 100644 --- a/apps/core/middleware.py +++ b/apps/core/middleware.py @@ -2,11 +2,62 @@ from __future__ import annotations from collections.abc import Callable -from django.http import HttpRequest, HttpResponse +from django.conf import settings +from django.contrib import messages +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.http import url_has_allowed_host_and_scheme + + +class DemoReadOnlyMiddleware: + """Prevent a shared public demo account from mutating application data.""" + + SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + user = getattr(request, "user", None) + is_demo = bool( + getattr(settings, "DEMO_READ_ONLY", True) + and user + and getattr(user, "is_authenticated", False) + and getattr(user, "username", "") == settings.DEMO_USERNAME + ) + if not is_demo or request.method in self.SAFE_METHODS: + return self.get_response(request) + + if request.path_info == reverse("logout"): + return self.get_response(request) + + if "application/json" in request.headers.get("Accept", ""): + return JsonResponse( + { + "ok": False, + "error": "demo_read_only", + "message": "De publieke demo is alleen-lezen.", + }, + status=403, + ) + + messages.warning( + request, + "De publieke demo is alleen-lezen; wijzigingen zijn uitgeschakeld.", + ) + referer = request.META.get("HTTP_REFERER", "") + if referer and url_has_allowed_host_and_scheme( + referer, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + return redirect(referer) + return redirect("dashboard:today") class SecurityHeadersMiddleware: - """Kleine CSP zonder externe assets; vacature-HTML wordt bovendien gesanitized.""" + """Small CSP without external assets; vacancy HTML is sanitized separately.""" def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: self.get_response = get_response @@ -33,4 +84,6 @@ class SecurityHeadersMiddleware: "Permissions-Policy", "camera=(), microphone=(), geolocation=()" ) response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + if not getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False): + response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive") return response diff --git a/apps/core/network.py b/apps/core/network.py new file mode 100644 index 0000000..19f37ae --- /dev/null +++ b/apps/core/network.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import ipaddress +from functools import lru_cache + +from django.conf import settings +from django.http import HttpRequest + + +@lru_cache(maxsize=32) +def _trusted_networks( + values: tuple[str, ...], +) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]: + return tuple(ipaddress.ip_network(value, strict=False) for value in values) + + +def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + candidate = value.strip() + if not candidate: + return None + try: + return ipaddress.ip_address(candidate) + except ValueError: + return None + + +def client_ip(request: HttpRequest) -> str: + """Return a proxy-aware client IP without trusting arbitrary forwarded headers.""" + + remote = _parse_ip(request.META.get("REMOTE_ADDR", "")) + if remote is None: + return "unknown" + + networks = _trusted_networks(tuple(getattr(settings, "TRUSTED_PROXY_CIDRS", ()))) + if not networks or not any(remote in network for network in networks): + return str(remote) + + forwarded = [ + parsed + for item in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",") + if (parsed := _parse_ip(item)) is not None + ] + if not forwarded: + return str(remote) + + # Walk from the nearest hop back to the client. The first address outside + # the explicitly trusted proxy ranges is the client address. + for candidate in reversed([*forwarded, remote]): + if not any(candidate in network for network in networks): + return str(candidate) + return str(forwarded[0]) diff --git a/apps/core/rate_limit.py b/apps/core/rate_limit.py index 5485f4a..ac44921 100644 --- a/apps/core/rate_limit.py +++ b/apps/core/rate_limit.py @@ -6,12 +6,7 @@ from django.core.cache import cache from django.http import HttpRequest from django.utils import timezone - -def _client_ip(request: HttpRequest) -> str: - forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "").strip() - if forwarded_for: - return forwarded_for.split(",")[0].strip() - return request.META.get("REMOTE_ADDR", "unknown").strip() or "unknown" +from apps.core.network import client_ip def _identity_for_user(request: HttpRequest) -> str: @@ -21,7 +16,7 @@ def _identity_for_user(request: HttpRequest) -> str: username = ( (request.POST.get("username", "") if request.method == "POST" else "").strip().lower() ) - return f"anon:{username or _client_ip(request)}" + return f"anon:{username or client_ip(request)}" @dataclass(frozen=True) diff --git a/apps/core/views.py b/apps/core/views.py index 8e0b6f7..eae5414 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -7,7 +7,7 @@ from django.contrib import messages from django.contrib.auth import get_user_model, login from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import LoginView -from django.http import JsonResponse +from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect from django.urls import reverse from django.utils.http import url_has_allowed_host_and_scheme @@ -228,10 +228,19 @@ def demo_login(request): logger.exception("Kon de demo-omgeving niet seeden") login(request, user, backend=SSO_BACKEND) + request.session.set_expiry(settings.DEMO_SESSION_SECONDS) next_url = _safe_next(request, request.POST.get("next")) return redirect(next_url or settings.LOGIN_REDIRECT_URL) +def robots_txt(request): + if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False): + content = "User-agent: *\nAllow: /\n" + else: + content = "User-agent: *\nDisallow: /\n" + return HttpResponse(content, content_type="text/plain; charset=utf-8") + + def health_live(request): return JsonResponse({"ok": True, "service": "vacatureradar"}) diff --git a/config/settings.py b/config/settings.py index b7c87ca..154a296 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 = { diff --git a/config/urls.py b/config/urls.py index 3c38df1..f4144dd 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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/", diff --git a/deployment/production.env.example b/deployment/production.env.example new file mode 100644 index 0000000..45814cc --- /dev/null +++ b/deployment/production.env.example @@ -0,0 +1,90 @@ +# Productievoorbeeld — kopieer nooit letterlijk zonder alle CHANGE_ME-waarden te vervangen. +# Voor de Unraid AIO-container staat het echte bestand standaard op: +# /mnt/user/appdata/vacatureradar/source/.env + +DJANGO_SECRET_KEY=CHANGE_ME_generate_at_least_50_random_characters +DJANGO_DEBUG=0 +PUBLIC_BASE_URL=https://vacatureradar.example.be +DJANGO_ALLOWED_HOSTS=vacatureradar.example.be,127.0.0.1,localhost +DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be +DJANGO_TIME_ZONE=Europe/Brussels +VACATURERADAR_OWNER_NAME=Jens +VACATURERADAR_VERSION=0.3.12 + +POSTGRES_DB=vacatureradar +POSTGRES_USER=vacatureradar +POSTGRES_PASSWORD=CHANGE_ME_unique_database_password +POSTGRES_HOST=127.0.0.1 +POSTGRES_PORT=5432 +DATABASE_URL= +REDIS_URL=redis://127.0.0.1:6379/0 +CACHE_URL=redis://127.0.0.1:6379/1 +HEALTHCHECK_REQUIRE_CACHE=1 +CELERY_TASK_ALWAYS_EAGER=0 + +VACATURERADAR_ADMIN_USERNAME=admin +VACATURERADAR_ADMIN_EMAIL= +VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password +VACATURERADAR_AUTO_BOOTSTRAP=1 + +# Alleen inschakelen wanneer een publieke gastdemo gewenst is. De middleware blokkeert mutaties. +DEMO_MODE_ENABLED=0 +DEMO_READ_ONLY=1 +DEMO_SESSION_SECONDS=3600 +DEMO_USERNAME=demo +DEMO_EMAIL=demo@vacatureradar.local + +TRUST_PROXY_HEADERS=1 +USE_X_FORWARDED_HOST=0 +# Optioneel: het exacte IP/CIDR van Nginx Proxy Manager. Zonder waarde wordt X-Forwarded-For +# niet gebruikt voor client-IP-rate-limiting, maar X-Forwarded-Proto blijft wel nodig voor HTTPS. +TRUSTED_PROXY_CIDRS= +SESSION_COOKIE_SECURE=1 +CSRF_COOKIE_SECURE=1 +SECURE_SSL_REDIRECT=1 +# Start met 300; verhoog pas nadat HTTPS en subdomeinen gecontroleerd zijn. +SECURE_HSTS_SECONDS=300 +SECURE_HSTS_INCLUDE_SUBDOMAINS=1 +SECURE_HSTS_PRELOAD=0 +SEARCH_ENGINE_INDEXING_ENABLED=0 + +AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8 +AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 +AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300 +MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS=12 +MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS=120 +MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS=300 + +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +DEFAULT_FROM_EMAIL=VacatureRadar +DIGEST_RECIPIENT= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_USE_TLS=1 + +MAILBOX_CREDENTIAL_KEYS= +IMAP_CONNECT_TIMEOUT_SECONDS=15 +IMAP_MAX_MESSAGES_PER_POLL=200 +IMAP_MAX_MESSAGE_BYTES=1000000 + +FETCHER_USER_AGENT=VacatureRadar/0.1 (+personal-use; contact=local-admin) +FETCHER_TIMEOUT_SECONDS=20 +FETCHER_MAX_BYTES=3000000 +FETCHER_MAX_REDIRECTS=5 +FETCHER_ALLOW_NONSTANDARD_PORTS=0 +SOURCE_POLICY_MODE=strict + +OLLAMA_ENABLED=0 +OLLAMA_BASE_URL=http://127.0.0.1:11434 +OLLAMA_MODEL= +OLLAMA_TIMEOUT_SECONDS=60 + +ENTRA_ID_ENABLED=0 +ENTRA_CLIENT_ID= +ENTRA_CLIENT_SECRET= +ENTRA_TENANT_ID= +ENTRA_REDIRECT_URI= +ENTRA_ALLOWED_DOMAINS= +ENTRA_SCOPES=User.Read diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index e0c41d9..42914c4 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -9,11 +9,12 @@ services: image: vacatureradar:unraid container_name: VacatureRadar env_file: - - /mnt/user/appdata/vacatureradar/source/.env + - ${VACATURERADAR_ENV_FILE:-/mnt/user/appdata/vacatureradar/source/.env} environment: - DATABASE_URL: postgresql://vacatureradar:${POSTGRES_PASSWORD}@127.0.0.1:5432/vacatureradar POSTGRES_HOST: 127.0.0.1 REDIS_URL: redis://127.0.0.1:6379/0 + CACHE_URL: redis://127.0.0.1:6379/1 + HEALTHCHECK_REQUIRE_CACHE: "1" VACATURERADAR_RESTORE_DUMP: /app/local/backups/aio-migration.dump restart: unless-stopped security_opt: diff --git a/docker-compose.yml b/docker-compose.yml index af5bdcf..dfd0ce1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,11 @@ x-app: &app - /var/tmp:rw,noexec,nosuid,size=128m - /app/.cache:rw,noexec,nosuid,size=128m env_file: - - .env + - ${VACATURERADAR_ENV_FILE:-.env} restart: unless-stopped + environment: + CACHE_URL: ${CACHE_URL:-redis://redis:6379/1} + HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1} security_opt: - no-new-privileges:true volumes: @@ -43,6 +46,8 @@ services: <<: *app environment: VACATURERADAR_RUN_MIGRATIONS: "0" + CACHE_URL: ${CACHE_URL:-redis://redis:6379/1} + HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1} cap_drop: - ALL command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"] @@ -57,6 +62,8 @@ services: <<: *app environment: VACATURERADAR_RUN_MIGRATIONS: "0" + CACHE_URL: ${CACHE_URL:-redis://redis:6379/1} + HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1} cap_drop: - ALL command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"] diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index 70c7891..a7dbbf3 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -2202,3 +2202,45 @@ tasks: primary_paths: - docs/api/openapi.yaml - docs/architecture/adrs/0008-server-rendered-design.md +- id: VR-225 + title: Hard publieke reverse-proxydeployment en gasttoegang uit + status: done + priority: P0 + requirement_ids: + - NFR-001 + - NFR-006 + - NFR-009 + depends_on: + - VR-224 + summary: Maak de bestaande persoonlijke installatie veilig en reproduceerbaar bereikbaar achter Nginx Proxy Manager, + herstel publieke host- en CSRF-configuratie en voorkom dat een gedeeld demoaccount persistente data wijzigt. + acceptance_criteria: + - Eén publieke HTTPS-URL configureert hostvalidatie, CSRF, secure cookies, SSL-detectie en redirect zonder de interne + containerpoort in de externe origin op te nemen. + - Productie start fail-fast bij debugmodusgerelateerde onveiligheid, een zwakke secret, onveilige cookies, ontbrekende + HTTPS-origin of een schrijfbare publieke demo. + - Login- en importlimieten gebruiken in productie een gedeelde cache; forwarded client-IP’s worden alleen vanaf + expliciet vertrouwde proxy-CIDR’s geaccepteerd. + - Het gastaccount is server-side alleen-lezen, heeft een begrensde sessie, kan afmelden en toont de beperking zichtbaar. + - Unraid-envpad, Nginx Proxy Manager, healthchecks, noindex, heruitrol en terugdraaien zijn exact gedocumenteerd. + verification: + - PYTHONPATH=/mnt/data/vr_windows_site/site-packages pytest -q + - python manage.py check + - python manage.py makemigrations --check --dry-run + - python scripts/validate_repository.py + - bash -n scripts/deploy_docker.sh scripts/entrypoint.sh scripts/unraid_aio_entrypoint.sh + - Live Nginx-, TLS-, CSRF-, Dockerhealth- en restore-smoke op de doelserver + primary_paths: + - config/settings.py + - apps/core/middleware.py + - apps/core/network.py + - apps/core/health.py + - scripts/configure_public_url.py + - docker-compose.unraid.yml + - docs/operations/NGINX_PROXY_MANAGER.md + - docs/quality/PRODUCTION_AUDIT_2026-07-27.md + result: + completed_at: '2026-07-27' + note: Publieke host/CSRF/HTTPS-configuratie, gedeelde Redis-rate-limiting, proxy-IPvertrouwen, cache-readiness, + noindex en read-only gastdemo geïmplementeerd. 295 tests groen met 2 correcte Playwrightskips; Django check, + migratiecheck, repositoryvalidatie, YAML-, syntax- en packagechecks groen. Live servervalidatie blijft extern. diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index a82bc10..508b6a3 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -1,7 +1,7 @@ # Projectstatus -- Laatst bijgewerkt: 2026-07-26 -- Repositoryversie: 0.3.0 volledige Stitch Intelligence Cockpit +- Laatst bijgewerkt: 2026-07-27 +- Repositoryversie: 0.3.12 productie- en reverse-proxyhardening - Uitvoeringsmodus: autonome backlog - Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next` @@ -19,6 +19,18 @@ De repository bevat een uitvoerbare Django-MVP met: - Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts; - offline unit-, integratie- en securitytests. +## Laatste productiehardening + +VR-225 op 2026-07-27: + +- `PUBLIC_BASE_URL` stuurt publieke host-, origin- en HTTPS-instellingen aan en voorkomt de gemelde `DisallowedHost` achter Nginx Proxy Manager; +- publieke debugmodus, zwakke secrets, onveilige cookies, ontbrekende HTTPS-origin en een schrijfbare productiedemo worden fail-fast geweigerd; +- login- en importrate-limiting delen Redis over alle workers; forwarded client-IP’s gelden alleen vanaf expliciet vertrouwde proxy-CIDR’s; +- de gastdemo is server-side alleen-lezen, heeft een begrensde sessie en toont die beperking zichtbaar in de cockpit; +- interne healthchecks blijven HTTP-bruikbaar onder SSL redirect en readiness verifieert optioneel database plus cache; +- Unraid, publieke poort, env-pad, Nginx Proxy Manager en productie-env zijn als één reproduceerbaar deploymentcontract gedocumenteerd; +- de volledige offline suite is groen met 296 tests en 2 correcte Playwrightskips; Docker-, publieke TLS- en live Unraid-smokes blijven doelservervalidatie. + ## Laatste frontendvervanging VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal: diff --git a/docs/operations/NGINX_PROXY_MANAGER.md b/docs/operations/NGINX_PROXY_MANAGER.md new file mode 100644 index 0000000..40b1e6c --- /dev/null +++ b/docs/operations/NGINX_PROXY_MANAGER.md @@ -0,0 +1,136 @@ +# Nginx Proxy Manager en publiek HTTPS-domein + +Deze handleiding publiceert VacatureRadar achter Nginx Proxy Manager zonder de Django-container rechtstreeks op internet bloot te stellen. + +## Waarom `DisallowedHost` verschijnt + +Django weigert onbekende `Host`-headers bewust. Een publieke hostnaam moet daarom expliciet overeenkomen met: + +- `PUBLIC_BASE_URL`; +- `DJANGO_ALLOWED_HOSTS`; +- `DJANGO_CSRF_TRUSTED_ORIGINS`. + +De applicatie leidt de host en CSRF-origin automatisch af uit `PUBLIC_BASE_URL`, maar de expliciete lijsten blijven ondersteund voor LAN- en beheerhosts. + +Een publieke Django-debugpagina is een afzonderlijk beveiligingsprobleem. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; debugpagina's tonen interne paden en implementatiedetails. + +## VacatureRadar configureren + +Voer dit uit in de huidige bronmap op Unraid: + +```bash +cd /mnt/user/appdata/vacatureradar/source +python scripts/configure_public_url.py \ + https://vacatureradar.itworx.tech \ + --env-file /mnt/user/appdata/vacatureradar/source/.env \ + --cache-url redis://127.0.0.1:6379/1 +``` + +De helper: + +- bewaart bestaande secrets en databasewachtwoorden; +- zet `DJANGO_DEBUG=0`; +- voegt de host en CSRF-origin toe; +- activeert secure cookies en HTTPS-redirect; +- zet een conservatieve HSTS-periode; +- activeert reverse-proxyherkenning; +- maakt de gastdemo alleen-lezen; +- gebruikt Redis voor gedeelde rate limiting en readiness; +- schrijft het bestand atomair met modus `0600`. + +Hermaak daarna de container. Een gewone restart laadt gewijzigde environmentvariabelen niet altijd opnieuw: + +```bash +docker compose -f docker-compose.unraid.yml up -d --build --force-recreate +``` + +Voor een andere omgeving kan `deployment/production.env.example` als checklist worden gebruikt. + +## Proxy Host in Nginx Proxy Manager + +Gebruik voor de Proxy Host: + +| Instelling | Waarde | +|---|---| +| Domain Names | `vacatureradar.itworx.tech` | +| Scheme | `http` | +| Forward Hostname / IP | het LAN-IP of de Docker-hostnaam van VacatureRadar | +| Forward Port | `1226` | +| Cache Assets | uit, tenzij expliciet getest | +| Block Common Exploits | aan | +| Websockets Support | niet vereist | + +Op het tabblad **SSL**: + +- vraag een geldig certificaat aan; +- activeer Force SSL; +- activeer HTTP/2; +- activeer HSTS pas nadat HTTPS correct werkt; +- activeer HSTS subdomains alleen wanneer alle subdomeinen HTTPS ondersteunen. + +Nginx Proxy Manager stuurt de noodzakelijke proxyheaders normaal al door. Gebruik bij een aangepaste configuratie minstens: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +``` + +Overschrijf deze headers aan de proxyzijde; laat een internetclient ze niet vrij bepalen. + +## Correcte client-IP-rate-limiting + +VacatureRadar vertrouwt `X-Forwarded-For` alleen wanneer de directe afzender in `TRUSTED_PROXY_CIDRS` staat. Zonder deze instelling blijft rate limiting veilig op het proxy-IP werken, maar worden bezoekers niet individueel onderscheiden. + +Bepaal het proxy-IP of Docker-netwerk en voeg alleen dat bereik toe, bijvoorbeeld: + +```bash +python scripts/configure_public_url.py \ + https://vacatureradar.itworx.tech \ + --env-file /mnt/user/appdata/vacatureradar/source/.env \ + --cache-url redis://127.0.0.1:6379/1 \ + --trusted-proxy-cidr 172.18.0.0/16 +``` + +Gebruik geen brede private range zoals `10.0.0.0/8` of `172.16.0.0/12` zonder aantoonbare noodzaak. + +## Validatie + +Controleer eerst intern: + +```bash +curl -fsS http://127.0.0.1:1226/health/live/ +curl -fsS http://127.0.0.1:1226/health/ready/ +``` + +Controleer daarna publiek: + +```bash +curl -I https://vacatureradar.itworx.tech/ +curl -fsS https://vacatureradar.itworx.tech/robots.txt +``` + +Verwacht: + +- geen `DisallowedHost`; +- geen Django-debugpagina; +- een redirect naar login of HTTP 200 na authenticatie; +- `Strict-Transport-Security` zodra HSTS actief is; +- `X-Content-Type-Options: nosniff`; +- `X-Frame-Options: DENY`; +- `Content-Security-Policy`; +- standaard `X-Robots-Tag: noindex, nofollow, noarchive` voor een persoonlijke installatie. + +Controleer ook een echte login, logout en één CSRF-beschermde actie. Alleen een succesvolle GET bewijst niet dat proxy-HTTPS en CSRF correct zijn. + +## Terugdraaien + +Bij een probleem: + +1. zet de Proxy Host tijdelijk op access-list/VPN-only of offline; +2. herstel de vorige `.env` uit de beheerde back-up; +3. hermaak de container; +4. controleer intern `/health/ready/`; +5. publiceer pas opnieuw nadat login en CSRF via HTTPS slagen. diff --git a/docs/operations/UNRAID_DEPLOYMENT.md b/docs/operations/UNRAID_DEPLOYMENT.md index 43f6814..2a6c086 100644 --- a/docs/operations/UNRAID_DEPLOYMENT.md +++ b/docs/operations/UNRAID_DEPLOYMENT.md @@ -7,9 +7,10 @@ Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van Maak deze directories: ```text -/mnt/user/appdata/vacatureradar/config -/mnt/user/appdata/vacatureradar/media -/mnt/user/appdata/vacatureradar/logs +/mnt/user/appdata/vacatureradar/source +/mnt/user/appdata/vacatureradar/source/local +/mnt/user/appdata/vacatureradar/source/local/media +/mnt/user/appdata/vacatureradar/source/local/logs /mnt/user/appdata/vacatureradar/source/local/postgres-aio17 /mnt/user/appdata/vacatureradar/source/local/redis-aio /mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI @@ -46,48 +47,46 @@ Gebruik in productie geen zwevende `latest` zonder gecontroleerd rollbackpad. ## Configuratiebestand -Kopieer of genereer `.env` naar: - -De deploy-helper `scripts/deploy_docker.sh` vult ontbrekende sleutels op basis van veilige defaults. -Zet voor publieke productie vooraf de gewenste variabelen: +De standaardlocatie die `docker-compose.unraid.yml` leest is: ```text -/mnt/user/appdata/vacatureradar/config/.env -APP_HOST=jobs.example.be -APP_SCHEME=https -DJANGO_DEBUG=0 -SESSION_COOKIE_SECURE=1 -CSRF_COOKIE_SECURE=1 -SECURE_SSL_REDIRECT=1 +/mnt/user/appdata/vacatureradar/source/.env ``` -Gebruik daarna de helper (zie hieronder). +Een afwijkende locatie kan vóór Compose via `VACATURERADAR_ENV_FILE` worden ingesteld. Gebruik [`deployment/production.env.example`](../../deployment/production.env.example) alleen als checklist en kopieer nooit ongewijzigde `CHANGE_ME`-waarden. + +Configureer een bestaand bestand voor de publieke URL zonder secrets te overschrijven: + +```bash +cd /mnt/user/appdata/vacatureradar/source +python scripts/configure_public_url.py \ + https://vacatureradar.example.be \ + --env-file /mnt/user/appdata/vacatureradar/source/.env \ + --cache-url redis://127.0.0.1:6379/1 +``` Beperk de bestandsrechten van `.env` tot de beheerder. Voeg geen secrets toe aan de ZIP, Git, screenshots of supportlogs. ## Eerste uitrol -1. Controleer alle `CHANGE_ME`-waarden. -2. Gebruik de deploy-helper voor een snelle eerste start: +1. Controleer alle `CHANGE_ME`-waarden en maak een back-up van een bestaande `.env`. +2. Start of hermaak de Unraid-container vanuit de bronmap: ```bash -cd /app/VacatureRadar -APP_HOST=jobs.example.be APP_SCHEME=https DJANGO_DEBUG=0 \ -SESSION_COOKIE_SECURE=1 CSRF_COOKIE_SECURE=1 SECURE_SSL_REDIRECT=1 \ -bash scripts/deploy_docker.sh +cd /mnt/user/appdata/vacatureradar/source +docker compose -f docker-compose.unraid.yml up -d --build --force-recreate ``` -3. Start de ene appcontainer; de entrypoint initialiseert PostgreSQL, voert migraties uit en start daarna alle processen onder Supervisor. -6. Controleer `GET /health/live/` en `GET /health/ready/`. -6. Voer eenmalig in de webcontainer uit: +3. De entrypoint valideert de productieconfiguratie, initialiseert PostgreSQL, voert migraties uit en start alle processen onder Supervisor. +4. Controleer intern `GET /health/live/` en `GET /health/ready/`. +5. Voer alleen wanneer bootstrap niet automatisch is ingeschakeld eenmalig uit: ```bash -python manage.py bootstrap_instance -python manage.py collectstatic --noinput +docker exec -it VacatureRadar python manage.py bootstrap_instance ``` -7. Meld lokaal aan en wijzig het bootstrapwachtwoord. -8. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd. +6. Meld lokaal aan en wijzig het bootstrapwachtwoord. +7. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd. ## Beheerverbinding en heruitrol @@ -135,7 +134,7 @@ Expose de applicatie niet rechtstreeks op internet. Plaats Nginx Proxy Manager, - optioneel extra access control/VPN voor persoonlijke installatie; - geen publieke toegang tot PostgreSQL, Redis of Ollama. -Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk. +Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk. Volg voor host-, CSRF-, proxyheader- en TLS-instellingen de [Nginx Proxy Manager-handleiding](NGINX_PROXY_MANAGER.md). ## Mailbox diff --git a/docs/quality/PRODUCTION_AUDIT_2026-07-27.md b/docs/quality/PRODUCTION_AUDIT_2026-07-27.md new file mode 100644 index 0000000..ad84edc --- /dev/null +++ b/docs/quality/PRODUCTION_AUDIT_2026-07-27.md @@ -0,0 +1,52 @@ +# Productie-audit — 27 juli 2026 + +## Samenvatting + +VacatureRadar heeft al een volwassen functionele en visuele basis. De audit vond geen noodzaak voor een nieuwe frontendarchitectuur; de belangrijkste professionaliseringswinst zat in de grens tussen Django, Nginx Proxy Manager en de Unraid-runtime. + +De gerapporteerde `DisallowedHost` was een symptoom van ontbrekende publieke hostconfiguratie. De publiek zichtbare Django-debugpagina maakte tegelijk duidelijk dat de runtime nog ontwikkelinstellingen gebruikte. Deze release behandelt daarom hostvalidatie, HTTPS-detectie, CSRF, gedeelde rate limiting, readiness en gasttoegang als één productiecontract. + +## Bevindingen en herstel + +| Prioriteit | Bevinding | Risico | Herstel | +|---|---|---|---| +| Kritiek | Publieke host ontbrak in `ALLOWED_HOSTS` | onbeschikbare publieke route | `PUBLIC_BASE_URL` leidt host en CSRF-origin af; configuratiehelper toegevoegd | +| Kritiek | `DEBUG=True` was publiek zichtbaar | lek van interne paden en implementatiedetails | productiehelper zet debug uit; productiecheck stopt bij onveilige configuratie | +| Hoog | HTTPS-origin kon de interne poort `1226` bevatten | CSRF-fouten achter poort 443 | publieke en interne poort zijn gescheiden in deployscripts | +| Hoog | Secure redirect kon interne healthchecks omleiden | container blijft unhealthy na HTTPS-hardening | alleen `/health/live/` en `/health/ready/` zijn intern vrijgesteld | +| Hoog | Loginrate-limiting gebruikte proceslokale cache | limiet verschilde per Gunicornworker | Redis-cache als gedeelde productiestandaard en readinessdependency | +| Hoog | `X-Forwarded-For` werd zonder proxyvertrouwen gebruikt | IP-spoofing en omzeilbare rate limiting | forwarded chain alleen bij expliciet vertrouwde proxy-CIDR | +| Hoog | Gedeelde demo kon mutaties uitvoeren | bezoekers beïnvloeden elkaars demo en persistente data | server-side read-only middleware, begrensde sessie en zichtbare UI-status | +| Middel | Unraid-documentatie wees naar een andere `.env` dan Compose | wijzigingen werden niet geladen | pad gestandaardiseerd op `source/.env`; override blijft mogelijk | +| Middel | Deployhelper schreef een custom `.env`, maar Compose kon alsnog de standaardfile lezen | productie-instellingen werden stil genegeerd | geselecteerd env-bestand wordt nu voor Compose-interpolatie én containeromgeving gebruikt | +| Middel | AIO Compose interpoleerde databasewachtwoord buiten `env_file` | custom env-pad kon een kapotte URL leveren | database-URL wordt uitsluitend in de containerentrypoint opgebouwd | +| Middel | Persoonlijke installatie kon geïndexeerd worden | onbedoelde vindbaarheid van login en productmetadata | `robots.txt` en `X-Robots-Tag` standaard op noindex | +| Middel | Runtime startte zonder Django deployment check | foutieve securityconfiguratie werd laat ontdekt | `manage.py check --deploy` vóór migraties en processtart | +| Laag | Project- en assetversies liepen uiteen | cache- en release-identiteit onduidelijk | releaseversie gecentraliseerd op `0.3.12` | +| Laag | Overdrachtsarchief bevatte lokale Windows/runtimeartefacten | onnodige omvang en risico op secrets of stale state | bestaande schone packager en integriteitsmanifest verplicht gebruikt | + +## Bewust niet gewijzigd + +- De server-rendered Django-architectuur blijft behouden. +- De Stitch Intelligence Cockpit blijft de productiefrontend; er is geen SPA of extern runtime-CDN toegevoegd. +- De containerpoort blijft op Unraid bereikbaar voor Nginx Proxy Manager. Netwerksegmentatie of firewalling blijft een live infrastructuurtaak. +- HSTS start conservatief op 300 seconden. Verhoog pas na een geslaagde publieke login-, CSRF- en subdomeincontrole. +- Zoekmachine-indexering blijft standaard uit omdat dit een persoonlijke installatie is. + +## Live controles na deployment + +1. Hermaak de container met de bestaande `.env`. +2. Controleer intern beide health-endpoints. +3. Controleer publiek dat HTTP naar HTTPS gaat. +4. Controleer login en logout. +5. Voer één echte CSRF-beschermde actie uit met het beheerdersaccount. +6. Controleer dat het demoaccount geen mutatie kan uitvoeren. +7. Controleer securityheaders en afwezigheid van een debugpagina. +8. Controleer in Nginx Proxy Manager dat alleen de proxy de hostpoort kan bereiken. +9. Maak een back-up en voer een herstel-smoke uit vóór verdere bronautomatisering. + +## Resterende externe punten + +- De echte Nginx Proxy Manager-configuratie en het proxy-IP/CIDR zijn niet in de repository zichtbaar. +- De live Unraid-container, database en Redis zijn in deze auditomgeving niet bereikbaar. +- Een volledige Dockerbuild, publieke TLS-smoke en hersteltest moeten daarom op de doelserver worden uitgevoerd. diff --git a/docs/quality/THREAT_MODEL.md b/docs/quality/THREAT_MODEL.md index 436d758..142c8cc 100644 --- a/docs/quality/THREAT_MODEL.md +++ b/docs/quality/THREAT_MODEL.md @@ -81,6 +81,8 @@ Django/Celery | T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` | | T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` | | T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` | +| T-42 | Gespoofte proxyheaders of ontbrekende publieke hostconfiguratie | host-headerfouten, onjuiste HTTPS-detectie of omzeilde IP-rate-limiting | `PUBLIC_BASE_URL`, exacte host/CSRF-validatie, proxy moet headers overschrijven, forwarded client-IP alleen vanaf `TRUSTED_PROXY_CIDRS` | `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `VR-225` | +| T-43 | Gedeeld demoaccount wijzigt persistente data | bezoekers beïnvloeden elkaars ervaring of echte data | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, zichtbare UI-status en productiecheck | `tests/integration/test_auth_login.py`, `tests/unit/test_settings_security.py`, `VR-225` | ## Misbruikscenario's @@ -114,7 +116,7 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist ## Resterende risico's - De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau. -- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn beperkt door in-memory rate limiting en blokkades op login/manual-import. +- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR. - Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen. - Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet. - Een persoonlijke Unraid-host blijft afhankelijk van correcte netwerksegmentatie, patching en back-ups door de beheerder. diff --git a/docs/quality/TRACEABILITY_MATRIX.md b/docs/quality/TRACEABILITY_MATRIX.md index 51334ef..8c21c8e 100644 --- a/docs/quality/TRACEABILITY_MATRIX.md +++ b/docs/quality/TRACEABILITY_MATRIX.md @@ -56,7 +56,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel | ID | Status | Bewijs | Vervolgtaak | |---|---|---|---| -| NFR-001 | I | policy-, URL- en fetchersecuritytests + settings/rate-limit tests | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/integration/test_views.py`, `tests/integration/test_sources_manual_import.py` | +| NFR-001 | I | policy-, URL- en fetchersecuritytests plus publieke host-, proxyvertrouwen-, demo- en gedeelde rate-limit-hardening | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `tests/integration/test_auth_login.py`, VR-225 | | NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 | | NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 | | NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence | @@ -64,7 +64,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel | NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e | | NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 | | NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer, premium visuele hiërarchie, zelfstandig VacatureRadar-productmerk, lokale ITWorx-ontwikkelaarscredit, toegankelijke interactieve radarillustratie en actuele user-first productdocumentatie | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `tests/integration/test_views.py`, `artifacts/visual-validation/`, `docs/screenshots/readme/`, VR-209–224 | -| NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, gevalideerde SSH-deployalias, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, VR-117, VR-217/218 afgerond | +| NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, publieke URL-helper, Nginx Proxy Manager-runbook, gevalideerde SSH-deployalias, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, `tests/unit/test_public_configuration.py`, VR-117, VR-217/218/225 afgerond | | NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability | ## Systeemacceptatie @@ -111,3 +111,5 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma - VR-220 vervangt die tijdelijke tekstlock-up door de aangeleverde officiële ITWorx.tech-wordmark en een exact daaruit afgeleid cloud-check-appicoon voor browser, shell en Dockerman; VacatureRadar blijft de productnaam. - VR-221 corrigeert de merkarchitectuur: VacatureRadar krijgt een zelfstandig radar-/kansbeeldmerk voor alle productcontactpunten en ITWorx.tech blijft uitsluitend zichtbaar als officiële secundaire ontwikkelaarscredit. - VR-224 maakt de repository-ingang user-first: de README volgt de dagelijkse productflow, bevat gecontroleerde lokale quickstarts en gebruikt vier actuele dark/light- en desktop/mobielscreenshots zonder secrets of externe assets. + +- VR-225 hardent de publieke installatie met `PUBLIC_BASE_URL`, exacte host/CSRF-afleiding, veilige proxy-HTTPS-detectie, vertrouwde client-IPchains, gedeelde Redis-rate-limiting, cache-readiness en een server-side alleen-lezen gastdemo. Bewijs staat in de productie-audit, Nginx-runbook en de settings-, network-, health-, auth- en deploymenttests. diff --git a/pyproject.toml b/pyproject.toml index 0afac4e..17fb56c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vacatureradar" -version = "0.1.0" +version = "0.3.12" description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails." readme = "README.md" requires-python = ">=3.12,<3.14" diff --git a/scripts/configure_public_url.py b/scripts/configure_public_url.py new file mode 100644 index 0000000..31b7441 --- /dev/null +++ b/scripts/configure_public_url.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python +from __future__ import annotations + +import argparse +import os +import tempfile +from pathlib import Path +from urllib.parse import urlparse + + +class ConfigurationError(ValueError): + pass + + +def normalize_public_url(value: str) -> tuple[str, str]: + raw = value.strip().rstrip("/") + parsed = urlparse(raw) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ConfigurationError("Gebruik een volledige http(s)-URL, bijvoorbeeld https://jobs.example.be") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ConfigurationError("De publieke URL mag geen credentials, query of fragment bevatten.") + if parsed.path not in {"", "/"}: + raise ConfigurationError("De applicatie moet op de root van het domein gepubliceerd worden.") + try: + port = parsed.port + except ValueError as exc: + raise ConfigurationError("De publieke 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 parse_env(text: str) -> tuple[list[str], dict[str, str]]: + lines = text.splitlines() + values: dict[str, str] = {} + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + values[key.strip()] = value.strip() + return lines, values + + +def merge_csv(existing: str, additions: list[str]) -> str: + result: list[str] = [] + seen: set[str] = set() + for item in [*existing.split(","), *additions]: + value = item.strip() + if value and value.casefold() not in seen: + seen.add(value.casefold()) + result.append(value) + return ",".join(result) + + +def render_env(lines: list[str], updates: dict[str, str]) -> str: + rendered: list[str] = [] + written: set[str] = set() + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key = stripped.split("=", 1)[0].strip() + if key in updates: + rendered.append(f"{key}={updates[key]}") + written.add(key) + continue + rendered.append(line) + missing = [key for key in updates if key not in written] + if missing: + if rendered and rendered[-1].strip(): + rendered.append("") + rendered.append("# Publieke URL en reverse-proxyhardening") + rendered.extend(f"{key}={updates[key]}" for key in missing) + return "\n".join(rendered).rstrip() + "\n" + + +def build_updates( + current: dict[str, str], + public_url: str, + *, + hsts_seconds: int, + cache_url: str | None, + trusted_proxy_cidrs: list[str], +) -> dict[str, str]: + origin, host = normalize_public_url(public_url) + if not origin.startswith("https://"): + raise ConfigurationError("Een publieke productie-URL moet HTTPS gebruiken.") + updates = { + "PUBLIC_BASE_URL": origin, + "DJANGO_DEBUG": "0", + "DJANGO_ALLOWED_HOSTS": merge_csv( + current.get("DJANGO_ALLOWED_HOSTS", ""), [host, "127.0.0.1", "localhost"] + ), + "DJANGO_CSRF_TRUSTED_ORIGINS": merge_csv( + current.get("DJANGO_CSRF_TRUSTED_ORIGINS", ""), [origin] + ), + "SESSION_COOKIE_SECURE": "1", + "CSRF_COOKIE_SECURE": "1", + "SECURE_SSL_REDIRECT": "1", + "SECURE_HSTS_SECONDS": str(hsts_seconds), + "TRUST_PROXY_HEADERS": "1", + "DEMO_READ_ONLY": "1", + "SEARCH_ENGINE_INDEXING_ENABLED": "0", + "HEALTHCHECK_REQUIRE_CACHE": "1", + } + if cache_url: + updates["CACHE_URL"] = cache_url + if trusted_proxy_cidrs: + updates["TRUSTED_PROXY_CIDRS"] = merge_csv( + current.get("TRUSTED_PROXY_CIDRS", ""), trusted_proxy_cidrs + ) + return updates + + +def configure_env( + env_file: Path, + public_url: str, + *, + hsts_seconds: int = 300, + cache_url: str | None = None, + trusted_proxy_cidrs: list[str] | None = None, +) -> list[str]: + if hsts_seconds < 0: + raise ConfigurationError("HSTS-seconden mogen niet negatief zijn.") + original = env_file.read_text(encoding="utf-8") if env_file.exists() else "" + lines, current = parse_env(original) + updates = build_updates( + current, + public_url, + hsts_seconds=hsts_seconds, + cache_url=cache_url, + trusted_proxy_cidrs=trusted_proxy_cidrs or [], + ) + output = render_env(lines, updates) + env_file.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp(prefix=f".{env_file.name}.", dir=env_file.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(output) + os.chmod(temporary_name, 0o600) + os.replace(temporary_name, env_file) + finally: + if os.path.exists(temporary_name): + os.unlink(temporary_name) + return list(updates) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Configureer VacatureRadar veilig voor een publieke reverse-proxy-URL." + ) + parser.add_argument("public_url", help="Bijvoorbeeld https://vacatureradar.example.be") + parser.add_argument("--env-file", type=Path, default=Path(".env")) + parser.add_argument( + "--hsts-seconds", + type=int, + default=300, + help="Start conservatief; verhoog na succesvolle HTTPS-validatie.", + ) + parser.add_argument("--cache-url", help="Bij Unraid AIO: redis://127.0.0.1:6379/1") + parser.add_argument( + "--trusted-proxy-cidr", + action="append", + default=[], + help="Optioneel vertrouwd proxy-IP/CIDR voor correcte client-IP-rate-limiting.", + ) + args = parser.parse_args() + try: + changed = configure_env( + args.env_file, + args.public_url, + hsts_seconds=args.hsts_seconds, + cache_url=args.cache_url, + trusted_proxy_cidrs=args.trusted_proxy_cidr, + ) + except (OSError, ConfigurationError) as exc: + parser.error(str(exc)) + print(f"Bijgewerkt: {args.env_file}") + print("Veilig ingestelde sleutels: " + ", ".join(changed)) + print("Hermaak de container; een gewone restart laadt gewijzigde environment niet opnieuw.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/deploy_docker.sh b/scripts/deploy_docker.sh index 551747d..777001d 100644 --- a/scripts/deploy_docker.sh +++ b/scripts/deploy_docker.sh @@ -36,9 +36,10 @@ generate_secret() { fi } -ensure_env() { +write_env() { local key="$1" local value="$2" + local force="${3:-0}" local existing_line local existing_value @@ -49,7 +50,7 @@ ensure_env() { fi existing_value="${existing_line#*=}" - if [[ -n "$existing_value" && \ + if [[ "$force" != "1" && -n "$existing_value" && \ "$existing_value" != CHANGE_ME* && \ "$existing_value" != dev-only-change-me && \ "$existing_value" != codex-local-only ]]; then @@ -66,12 +67,17 @@ ensure_env() { fi done < "$env_file" mv "$temp_env" "$env_file" + chmod 600 "$env_file" } +app_host_was_explicit=0 +[[ -n "${APP_HOST:-}" ]] && app_host_was_explicit=1 app_host="${APP_HOST:-${HOSTNAME:-localhost}}" app_scheme="${APP_SCHEME:-http}" +app_public_port="${APP_PUBLIC_PORT:-}" app_hostnames="${APP_HOSTS:-$app_host}" app_origin="${APP_ORIGINS:-}" +app_base_url="" # De lokale healthcheck gebruikt 127.0.0.1. Houd die host en localhost altijd # toegestaan, ook wanneer APP_HOSTS expliciet door de runner is ingesteld. @@ -81,29 +87,43 @@ for required_host in 127.0.0.1 localhost; do fi done -if [[ -z "$app_origin" ]]; then +if [[ -z "$app_public_port" ]]; then if [[ "$app_scheme" == "https" ]]; then - if [[ "$app_port" == "443" ]]; then - app_origin="https://$app_host" - else - app_origin="https://$app_host:$app_port" - fi + app_public_port="443" else - app_origin="http://$app_host:$app_port" + app_public_port="$app_port" fi fi +if [[ ( "$app_scheme" == "https" && "$app_public_port" == "443" ) || \ + ( "$app_scheme" == "http" && "$app_public_port" == "80" ) ]]; then + app_base_url="${app_scheme}://${app_host}" +else + app_base_url="${app_scheme}://${app_host}:${app_public_port}" +fi + +if [[ -z "$app_origin" ]]; then + app_origin="$app_base_url" +fi + secret_key="$(generate_secret)" postgres_password="${POSTGRES_PASSWORD:-$(generate_secret)}" admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}" db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar" -debug="${DJANGO_DEBUG:-1}" +debug="${DJANGO_DEBUG:-}" +if [[ -z "$debug" ]]; then + if [[ "$app_host_was_explicit" == "1" && "$app_scheme" == "https" ]]; then + debug="0" + else + debug="1" + fi +fi if [[ "$debug" == "0" ]]; then session_secure="${SESSION_COOKIE_SECURE:-1}" csrf_secure="${CSRF_COOKIE_SECURE:-1}" ssl_redirect="${SECURE_SSL_REDIRECT:-1}" - hsts_seconds="${SECURE_HSTS_SECONDS:-31536000}" + hsts_seconds="${SECURE_HSTS_SECONDS:-300}" else session_secure="${SESSION_COOKIE_SECURE:-0}" csrf_secure="${CSRF_COOKIE_SECURE:-0}" @@ -111,37 +131,51 @@ else hsts_seconds="${SECURE_HSTS_SECONDS:-0}" fi -ensure_env "DJANGO_SECRET_KEY" "$secret_key" -ensure_env "DJANGO_DEBUG" "$debug" -ensure_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames" -ensure_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin" -ensure_env "DJANGO_TIME_ZONE" "Europe/Brussels" -ensure_env "POSTGRES_DB" "vacatureradar" -ensure_env "POSTGRES_USER" "vacatureradar" -ensure_env "POSTGRES_PASSWORD" "$postgres_password" -ensure_env "POSTGRES_HOST" "postgres" -ensure_env "POSTGRES_PORT" "5432" -ensure_env "DATABASE_URL" "$db_url" -ensure_env "REDIS_URL" "redis://redis:6379/0" -ensure_env "CELERY_TASK_ALWAYS_EAGER" "0" -ensure_env "VACATURERADAR_ADMIN_USERNAME" "admin" -ensure_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password" -ensure_env "VACATURERADAR_AUTO_BOOTSTRAP" "1" -ensure_env "SESSION_COOKIE_SECURE" "$session_secure" -ensure_env "CSRF_COOKIE_SECURE" "$csrf_secure" -ensure_env "SECURE_SSL_REDIRECT" "$ssl_redirect" -ensure_env "SECURE_HSTS_SECONDS" "$hsts_seconds" -ensure_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend" -ensure_env "DEFAULT_FROM_EMAIL" "VacatureRadar " -ensure_env "SOURCE_POLICY_MODE" "strict" -ensure_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8" -ensure_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300" -ensure_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300" -ensure_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12" -ensure_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120" -ensure_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300" +write_env "DJANGO_SECRET_KEY" "$secret_key" +write_env "DJANGO_DEBUG" "$debug" "$app_host_was_explicit" +write_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames" "$app_host_was_explicit" +write_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin" "$app_host_was_explicit" +if [[ "$app_host_was_explicit" == "1" ]]; then + write_env "PUBLIC_BASE_URL" "$app_base_url" 1 + if [[ "$app_scheme" == "https" ]]; then + write_env "TRUST_PROXY_HEADERS" "1" 1 + else + write_env "TRUST_PROXY_HEADERS" "0" 1 + fi + write_env "USE_X_FORWARDED_HOST" "0" 1 +fi +write_env "DJANGO_TIME_ZONE" "Europe/Brussels" +write_env "POSTGRES_DB" "vacatureradar" +write_env "POSTGRES_USER" "vacatureradar" +write_env "POSTGRES_PASSWORD" "$postgres_password" +write_env "POSTGRES_HOST" "postgres" +write_env "POSTGRES_PORT" "5432" +write_env "DATABASE_URL" "$db_url" +write_env "REDIS_URL" "redis://redis:6379/0" +write_env "CACHE_URL" "redis://redis:6379/1" +write_env "HEALTHCHECK_REQUIRE_CACHE" "1" +write_env "CELERY_TASK_ALWAYS_EAGER" "0" +write_env "VACATURERADAR_ADMIN_USERNAME" "admin" +write_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password" +write_env "VACATURERADAR_AUTO_BOOTSTRAP" "1" +write_env "SESSION_COOKIE_SECURE" "$session_secure" +write_env "CSRF_COOKIE_SECURE" "$csrf_secure" +write_env "SECURE_SSL_REDIRECT" "$ssl_redirect" +write_env "SECURE_HSTS_SECONDS" "$hsts_seconds" +write_env "DEMO_READ_ONLY" "1" +write_env "SEARCH_ENGINE_INDEXING_ENABLED" "0" +write_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend" +write_env "DEFAULT_FROM_EMAIL" "VacatureRadar " +write_env "SOURCE_POLICY_MODE" "strict" +write_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8" +write_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300" +write_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300" +write_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12" +write_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120" +write_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300" -docker compose -f "$compose_file" up -d --build +export VACATURERADAR_ENV_FILE="$env_file" +docker compose --env-file "$env_file" -f "$compose_file" up -d --build if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then for i in {1..30}; do @@ -157,14 +191,16 @@ if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then done fi -cat <<"EOF" +cat < APP_SCHEME=https DJANGO_DEBUG=0 voor publieke productie. +- APP_PORT is de interne hostpoort; APP_PUBLIC_PORT is optioneel en standaard 443 bij HTTPS. +- Hermaak de container na iedere environmentwijziging. EOF diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index af8bdc4..b389ab6 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -4,6 +4,8 @@ set -eu mkdir -p /app/media /app/logs /app/local mkdir -p /tmp/celery +python manage.py check --deploy + if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then python manage.py migrate --noinput diff --git a/scripts/remote_deploy.sh b/scripts/remote_deploy.sh index 79d79b3..55fac2d 100644 --- a/scripts/remote_deploy.sh +++ b/scripts/remote_deploy.sh @@ -53,4 +53,8 @@ if [[ -n "${APP_PORT:-}" ]]; then export APP_PORT fi +if [[ -n "${APP_PUBLIC_PORT:-}" ]]; then + export APP_PUBLIC_PORT +fi + bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_FILE" diff --git a/scripts/unraid_aio_entrypoint.sh b/scripts/unraid_aio_entrypoint.sh index 97ca912..31c4ab2 100644 --- a/scripts/unraid_aio_entrypoint.sh +++ b/scripts/unraid_aio_entrypoint.sh @@ -37,6 +37,7 @@ fi runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start +runuser -u app -- python manage.py check --deploy runuser -u app -- python manage.py migrate --noinput if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then runuser -u app -- python manage.py bootstrap_instance || true diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py index 92a1765..b759e11 100644 --- a/scripts/validate_repository.py +++ b/scripts/validate_repository.py @@ -13,16 +13,21 @@ MARKDOWN_LINK = re.compile(r"(? { + form.querySelectorAll('button:not([type]), button[type="submit"], input[type="submit"]').forEach((control) => { + control.disabled = true; + control.dataset.demoDisabled = "true"; + control.setAttribute("aria-describedby", "demo-read-only-note"); + control.title = "Niet beschikbaar in de alleen-lezen demo"; + }); + }); + } + const motionAllowed = !window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (motionAllowed) { document.querySelectorAll("[data-radar-illustration]").forEach((illustration) => { diff --git a/templates/base.html b/templates/base.html index 05ed327..0560b6c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -14,9 +14,10 @@ {% block extra_head %}{% endblock %} - + {% include "components/icon_sprite.html" %} + {% if demo_read_only %}Niet beschikbaar in de alleen-lezen demo.{% endif %} {% if user.is_authenticated %}
+ {% if demo_read_only %} +
+ + Publieke demo · alleen-lezenJe kunt alle schermen en analyses bekijken; wijzigingen en imports zijn uitgeschakeld. +
+ {% endif %} +
{% if messages %}
{% for message in messages %}
{{ message }}
{% endfor %}
{% endif %} {% else %} diff --git a/tests/e2e/test_vr114_browser_and_accessibility.py b/tests/e2e/test_vr114_browser_and_accessibility.py index 4426854..8b99308 100644 --- a/tests/e2e/test_vr114_browser_and_accessibility.py +++ b/tests/e2e/test_vr114_browser_and_accessibility.py @@ -183,7 +183,8 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi try: browser = playwright.chromium.launch(headless=True) except Exception as exc: # pragma: no cover - env-dependent - _run_html_accessibility_probe(client, user, profile, job) + # De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de + # Playwright-context opnieuw databasewerk doen kan Django als async zien. pytest.skip(f"Playwright-browser niet beschikbaar: {exc}") context = browser.new_context( diff --git a/tests/integration/test_auth_login.py b/tests/integration/test_auth_login.py index 560311c..44bd764 100644 --- a/tests/integration/test_auth_login.py +++ b/tests/integration/test_auth_login.py @@ -11,7 +11,6 @@ from django.urls import reverse pytestmark = [pytest.mark.integration, pytest.mark.django_db] - def test_login_page_renders_stitch_design(client): response = client.get(reverse("login")) assert response.status_code == 200 @@ -42,7 +41,6 @@ def test_login_page_keeps_official_brand_separate_from_owner_setting(client): assert "Eigen merk" not in body assert 'content="VacatureRadar"' in body - def test_login_with_email(client, user): response = client.post( reverse("login"), @@ -52,7 +50,6 @@ def test_login_with_email(client, user): assert response.url == reverse("dashboard:today") assert client.session.get("_auth_user_id") == str(user.pk) - def test_login_with_username_still_works(client, user): response = client.post( reverse("login"), @@ -61,7 +58,6 @@ def test_login_with_username_still_works(client, user): assert response.status_code == 302 assert client.session.get("_auth_user_id") == str(user.pk) - def test_login_email_case_insensitive(client, user): response = client.post( reverse("login"), @@ -70,7 +66,6 @@ def test_login_email_case_insensitive(client, user): assert response.status_code == 302 assert client.session.get("_auth_user_id") == str(user.pk) - def test_login_wrong_password_shows_error(client, user): response = client.post( reverse("login"), @@ -79,7 +74,6 @@ def test_login_wrong_password_shows_error(client, user): assert response.status_code == 200 assert "klopt niet" in response.content.decode("utf-8") - def test_password_reset_sends_email(client, user): response = client.post(reverse("password_reset"), {"email": user.email}) assert response.status_code == 302 @@ -90,13 +84,11 @@ def test_password_reset_sends_email(client, user): assert "wachtwoord" in message.subject.lower() assert "/wachtwoord/herstellen/" in message.body - def test_password_reset_unknown_email_is_silent(client): response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"}) assert response.status_code == 302 assert len(mail.outbox) == 0 - def test_demo_button_visible_by_default(client): assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8") @@ -113,7 +105,6 @@ def test_demo_login_disabled_redirects(client): def test_demo_button_hidden_when_disabled(client): assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8") - def test_demo_login_logs_in_and_seeds_environment(client): from apps.jobs.models import Application, JobPosting, ScoreRun from apps.profiles.models import SearchProfile @@ -136,7 +127,6 @@ def test_demo_login_logs_in_and_seeds_environment(client): dashboard = client.get(reverse("dashboard:today")) assert dashboard.status_code == 200 - def test_demo_login_is_idempotent(client): from apps.jobs.models import JobPosting @@ -145,6 +135,39 @@ def test_demo_login_is_idempotent(client): client.post(reverse("demo-login")) assert JobPosting.objects.count() == first +def test_demo_account_is_read_only_and_session_is_bounded(client): + from apps.profiles.models import SearchProfile + + client.post(reverse("demo-login")) + demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME) + active = SearchProfile.objects.get(user=demo_user, is_active=True) + alternative = SearchProfile.objects.create(user=demo_user, name="Alternatief", is_active=False) + + response = client.post( + reverse("profiles:activate", args=[alternative.pk]), + HTTP_REFERER=reverse("profiles:list"), + ) + + assert response.status_code == 302 + assert response.url == reverse("profiles:list") + active.refresh_from_db() + alternative.refresh_from_db() + assert active.is_active is True + assert alternative.is_active is False + assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS + +def test_demo_account_can_still_log_out(client): + client.post(reverse("demo-login")) + response = client.post(reverse("logout")) + assert response.status_code == 302 + assert client.session.get("_auth_user_id") is None + +def test_authenticated_demo_page_disables_mutating_forms(client): + client.post(reverse("demo-login")) + body = client.get(reverse("dashboard:today")).content.decode("utf-8") + assert 'data-demo-read-only="true"' in body + assert "Publieke demo · alleen-lezen" in body + assert "data-demo-allow" in body def test_entra_login_disabled_redirects(client): response = client.post(reverse("entra-login")) diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py index 436826f..c54198e 100644 --- a/tests/integration/test_views.py +++ b/tests/integration/test_views.py @@ -69,7 +69,7 @@ def test_premium_layout_uses_one_bounded_ultrawide_grid(): def test_css_imports_share_the_release_cache_version(): css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8") - assert css.count("?v=0.3.11") == 7 + assert css.count("?v=0.3.12") == 7 @pytest.mark.integration diff --git a/tests/unit/test_deployment_configuration.py b/tests/unit/test_deployment_configuration.py index 809ee63..33ebc01 100644 --- a/tests/unit/test_deployment_configuration.py +++ b/tests/unit/test_deployment_configuration.py @@ -89,3 +89,38 @@ def test_unraid_deploy_requires_dedicated_ssh_alias() -> None: assert "git ls-files --cached --others --exclude-standard -z" in deploy_script assert '[[ -e "$tracked_path" ]]' in deploy_script assert "/health/ready/" in deploy_script + + +def test_public_https_origin_uses_external_port_not_internal_app_port() -> None: + deploy_script = (ROOT / "scripts" / "deploy_docker.sh").read_text(encoding="utf-8") + assert 'app_public_port="${APP_PUBLIC_PORT:-}"' in deploy_script + assert 'app_public_port="443"' in deploy_script + assert 'write_env "PUBLIC_BASE_URL" "$app_base_url" 1' in deploy_script + assert 'https://$app_host:$app_port' not in deploy_script + + +def test_health_endpoints_are_exempt_from_internal_https_redirect() -> None: + assert r"^health/(?:live|ready)/$" in settings.SECURE_REDIRECT_EXEMPT + + +def test_unraid_uses_dedicated_shared_cache_database() -> None: + compose = yaml.safe_load((ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")) + environment = compose["services"]["app"]["environment"] + assert environment["CACHE_URL"] == "redis://127.0.0.1:6379/1" + assert environment["HEALTHCHECK_REQUIRE_CACHE"] == "1" + + +def test_unraid_database_url_is_built_inside_the_container() -> None: + compose = yaml.safe_load((ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")) + environment = compose["services"]["app"]["environment"] + assert "DATABASE_URL" not in environment + + +def test_deploy_uses_selected_environment_file_for_compose() -> None: + compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8")) + deploy_script = (ROOT / "scripts" / "deploy_docker.sh").read_text(encoding="utf-8") + + assert compose["x-app"]["env_file"] == ["${VACATURERADAR_ENV_FILE:-.env}"] + assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script + assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script + assert 'waarde uit ${env_file}' in deploy_script diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py new file mode 100644 index 0000000..1fb0c35 --- /dev/null +++ b/tests/unit/test_health.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import pytest +from django.test import override_settings + +from apps.core.health import readiness + +pytestmark = pytest.mark.django_db + + +@override_settings(HEALTHCHECK_REQUIRE_CACHE=True) +def test_readiness_checks_database_and_shared_cache(): + result = readiness() + assert result.ok is True + assert result.checks == {"database": "ok", "cache": "ok"} + + +@override_settings(HEALTHCHECK_REQUIRE_CACHE=True) +def test_readiness_reports_cache_failure(monkeypatch): + def fail_cache(*args, **kwargs): + raise ConnectionError + + monkeypatch.setattr("apps.core.health.cache.set", fail_cache) + result = readiness() + assert result.ok is False + assert result.checks["database"] == "ok" + assert result.checks["cache"] == "error:ConnectionError" diff --git a/tests/unit/test_network.py b/tests/unit/test_network.py new file mode 100644 index 0000000..d03932a --- /dev/null +++ b/tests/unit/test_network.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from django.test import RequestFactory, override_settings + +from apps.core.network import client_ip + + +def _request(remote: str, forwarded: str = ""): + request = RequestFactory().get("/") + request.META["REMOTE_ADDR"] = remote + if forwarded: + request.META["HTTP_X_FORWARDED_FOR"] = forwarded + return request + + +@override_settings(TRUSTED_PROXY_CIDRS=[]) +def test_client_ip_ignores_untrusted_forwarded_header(): + assert client_ip(_request("203.0.113.20", "198.51.100.7")) == "203.0.113.20" + + +@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"]) +def test_client_ip_uses_forwarded_chain_only_from_trusted_proxy(): + request = _request("172.18.0.4", "198.51.100.7, 172.18.0.3") + assert client_ip(request) == "198.51.100.7" + + +@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"]) +def test_client_ip_skips_invalid_forwarded_values(): + request = _request("172.18.0.4", "invalid, 198.51.100.9") + assert client_ip(request) == "198.51.100.9" diff --git a/tests/unit/test_public_configuration.py b/tests/unit/test_public_configuration.py new file mode 100644 index 0000000..2ae6380 --- /dev/null +++ b/tests/unit/test_public_configuration.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from django.core.exceptions import ImproperlyConfigured + +from config.settings import normalize_public_base_url +from scripts.configure_public_url import ConfigurationError, configure_env, normalize_public_url + + +@pytest.mark.parametrize( + ("raw", "origin", "host"), + [ + ( + "https://vacatureradar.itworx.tech/", + "https://vacatureradar.itworx.tech", + "vacatureradar.itworx.tech", + ), + ("https://jobs.example.be:8443", "https://jobs.example.be:8443", "jobs.example.be"), + ("http://127.0.0.1:1226", "http://127.0.0.1:1226", "127.0.0.1"), + ], +) +def test_public_url_normalization(raw, origin, host): + assert normalize_public_base_url(raw) == (origin, host) + assert normalize_public_url(raw) == (origin, host) + + +@pytest.mark.parametrize( + "raw", + [ + "vacatureradar.itworx.tech", + "ftp://vacatureradar.itworx.tech", + "https://user:secret@example.org", + "https://example.org/app", + "https://example.org?debug=1", + ], +) +def test_public_url_rejects_unsafe_or_unsupported_values(raw): + with pytest.raises((ImproperlyConfigured, ConfigurationError)): + normalize_public_base_url(raw) + +def test_configure_public_url_updates_only_public_security_settings(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text( + "DJANGO_SECRET_KEY=keep-this-secret\n" + "POSTGRES_PASSWORD=keep-this-password\n" + "DJANGO_ALLOWED_HOSTS=192.168.10.150,localhost\n" + "DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226\n", + encoding="utf-8", + ) + + changed = configure_env( + env_file, + "https://vacatureradar.itworx.tech/", + hsts_seconds=300, + cache_url="redis://127.0.0.1:6379/1", + trusted_proxy_cidrs=["172.18.0.0/16"], + ) + content = env_file.read_text(encoding="utf-8") + + assert "PUBLIC_BASE_URL=https://vacatureradar.itworx.tech" in content + assert "DJANGO_DEBUG=0" in content + assert "vacatureradar.itworx.tech" in content + assert ( + "DJANGO_CSRF_TRUSTED_ORIGINS=" + "http://192.168.10.150:1226,https://vacatureradar.itworx.tech" + ) in content + assert "CACHE_URL=redis://127.0.0.1:6379/1" in content + assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content + assert "DJANGO_SECRET_KEY=keep-this-secret" in content + assert "POSTGRES_PASSWORD=keep-this-password" in content + assert env_file.stat().st_mode & 0o777 == 0o600 + assert "DJANGO_SECRET_KEY" not in changed + +def test_configure_public_url_rejects_plain_http(tmp_path: Path): + with pytest.raises(ConfigurationError, match="HTTPS"): + configure_env(tmp_path / ".env", "http://vacatureradar.example.be") + +def test_public_base_url_populates_django_host_and_csrf_settings(): + environment = os.environ.copy() + environment.update( + { + "DJANGO_SECRET_KEY": "aB3!" * 16, + "DJANGO_DEBUG": "0", + "PUBLIC_BASE_URL": "https://vacatureradar.itworx.tech", + "DEMO_MODE_ENABLED": "0", + "SECURE_HSTS_SECONDS": "300", + } + ) + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import config.settings as settings; " + "assert 'vacatureradar.itworx.tech' in settings.ALLOWED_HOSTS; " + "assert 'https://vacatureradar.itworx.tech' " + "in settings.CSRF_TRUSTED_ORIGINS; " + "assert settings.DEBUG is False; " + "assert settings.SESSION_COOKIE_SECURE is True; " + "assert settings.CSRF_COOKIE_SECURE is True; " + "assert settings.SECURE_SSL_REDIRECT is True" + ), + ], + cwd=Path(__file__).resolve().parents[2], + env=environment, + capture_output=True, + text=True, + check=False, + ) # noqa: S603 - fixed interpreter and static assertion program + assert result.returncode == 0, result.stderr + +def test_configure_public_url_is_idempotent(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("DJANGO_ALLOWED_HOSTS=localhost\n", encoding="utf-8") + + for _ in range(2): + configure_env(env_file, "https://vacatureradar.example.be") + + content = env_file.read_text(encoding="utf-8") + assert content.count("PUBLIC_BASE_URL=") == 1 + assert content.count("vacatureradar.example.be") == 3 diff --git a/tests/unit/test_settings_security.py b/tests/unit/test_settings_security.py index c1c9681..2c56907 100644 --- a/tests/unit/test_settings_security.py +++ b/tests/unit/test_settings_security.py @@ -45,6 +45,7 @@ def test_validate_production_security_skips_when_debug_mode_is_enabled(): ("change-me", ["example.org"], ["https://example.org"], True, True, True), ("too-short", ["example.org"], ["https://example.org"], True, True, True), ("a" * 64, [], ["https://example.org"], True, True, True), + ("a" * 64, ["*"], ["https://example.org"], True, True, True), ("a" * 64, ["example.org"], [], True, True, True), ("a" * 64, ["example.org"], ["http://example.org"], True, True, True), ("a" * 64, ["example.org"], ["https://example.org"], False, True, True), @@ -70,3 +71,34 @@ def test_validate_production_security_requires_secure_settings_for_prod( csrf_cookie_secure=csrf_cookie_secure, secure_ssl_redirect=secure_ssl_redirect, ) + + +def test_validate_production_security_rejects_writable_public_demo(): + with pytest.raises(ImproperlyConfigured): + _validate_production_security( + debug=False, + secret_key="a" * 64, + allowed_hosts=["vacatureradar.itworx.tech"], + csrf_trusted_origins=["https://vacatureradar.itworx.tech"], + session_cookie_secure=True, + csrf_cookie_secure=True, + secure_ssl_redirect=True, + public_base_url="https://vacatureradar.itworx.tech", + demo_mode_enabled=True, + demo_read_only=False, + ) + + +def test_validate_production_security_accepts_read_only_https_demo(): + _validate_production_security( + debug=False, + secret_key="a" * 64, + allowed_hosts=["vacatureradar.itworx.tech"], + csrf_trusted_origins=["https://vacatureradar.itworx.tech"], + session_cookie_secure=True, + csrf_cookie_secure=True, + secure_ssl_redirect=True, + public_base_url="https://vacatureradar.itworx.tech", + demo_mode_enabled=True, + demo_read_only=True, + ) diff --git a/uv.lock b/uv.lock index c60ade9..ce82c58 100644 --- a/uv.lock +++ b/uv.lock @@ -776,7 +776,7 @@ wheels = [ [[package]] name = "vacatureradar" -version = "0.1.0" +version = "0.3.12" source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" },