Harden live mail OAuth and immutable releases
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-30 02:00:09 +02:00
parent 1397f085bd
commit 67be350283
23 changed files with 429 additions and 20 deletions
+4
View File
@@ -36,6 +36,10 @@ SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_USE_TLS=1 SMTP_USE_TLS=1
M365_TENANT_ID=
M365_CLIENT_ID=
M365_CLIENT_SECRET=
M365_MAILBOX_USER=
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst. # Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+4
View File
@@ -74,6 +74,10 @@ SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_USE_TLS=1 SMTP_USE_TLS=1
M365_TENANT_ID=
M365_CLIENT_ID=
M365_CLIENT_SECRET=
M365_MAILBOX_USER=
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MAILBOX_CREDENTIAL_KEYS= MAILBOX_CREDENTIAL_KEYS=
+47
View File
@@ -0,0 +1,47 @@
name: release
on:
push:
tags:
- "v*.*.*"
jobs:
verify-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify release
run: ./scripts/codex_verify.sh
- name: Build immutable image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
registry="gitea.itworx.tech"
image="${registry}/jens/vacatureradar:${version}"
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
docker build --file Dockerfile.unraid \
--build-arg "VCS_REF=${GITHUB_SHA}" \
--build-arg "APP_VERSION=${version}" \
--tag "$image" .
docker push "$image"
docker image inspect "$image" --format '{{index .RepoDigests 0}}' > image-digest.txt
docker sbom "$image" --format spdx-json > sbom.spdx.json
cat > RELEASE_NOTES.md <<EOF
# VacatureRadar ${version}
Immutable release image built from commit ${GITHUB_SHA}.
The accompanying digest, SPDX SBOM and checksums are release evidence.
EOF
sha256sum image-digest.txt sbom.spdx.json RELEASE_NOTES.md > SHA256SUMS
- name: Publish release evidence
uses: actions/upload-artifact@v4
with:
name: release-evidence-${{ github.ref_name }}
path: |
image-digest.txt
sbom.spdx.json
SHA256SUMS
RELEASE_NOTES.md
+6
View File
@@ -1,6 +1,12 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
FROM python:3.13-slim-trixie FROM python:3.13-slim-trixie
ARG VCS_REF=unknown
ARG APP_VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.itworx.tech/Jens/VacatureRadar" \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.version=$APP_VERSION
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \ UV_COMPILE_BYTECODE=1 \
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import base64
from django.conf import settings
class Microsoft365OAuthError(RuntimeError):
"""Safe OAuth boundary error that never includes tokens or client secrets."""
def acquire_exchange_access_token(*, client_factory=None) -> str:
tenant_id = settings.M365_TENANT_ID.strip()
client_id = settings.M365_CLIENT_ID.strip()
client_secret = settings.M365_CLIENT_SECRET.strip()
if not all((tenant_id, client_id, client_secret)):
raise Microsoft365OAuthError("Microsoft 365 OAuth is niet volledig geconfigureerd.")
if client_factory is None:
import msal
client_factory = msal.ConfidentialClientApplication
factory = client_factory
client = factory(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=client_secret,
)
result = client.acquire_token_for_client(scopes=[settings.M365_EXCHANGE_SCOPE])
token = result.get("access_token") if isinstance(result, dict) else None
if not isinstance(token, str) or not token:
category = "unknown"
if isinstance(result, dict):
category = str(result.get("error") or "unknown")[:80]
raise Microsoft365OAuthError(
f"Microsoft 365 OAuth-token kon niet worden verkregen ({category})."
)
return token
def xoauth2_bytes(username: str, access_token: str) -> bytes:
if not username or "\x00" in username or "\r" in username or "\n" in username:
raise Microsoft365OAuthError("Ongeldig Microsoft 365-mailboxadres.")
return f"user={username}\x01auth=Bearer {access_token}\x01\x01".encode()
def xoauth2_b64(username: str, access_token: str) -> str:
return base64.b64encode(xoauth2_bytes(username, access_token)).decode("ascii")
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import smtplib
from email.utils import parseaddr
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.sources.services.tls import trusted_tls_context
class Microsoft365OAuthEmailBackend(BaseEmailBackend):
"""Send mail through Exchange Online with app-only SASL XOAUTH2."""
def send_messages(self, email_messages) -> int:
if not email_messages:
return 0
sent = 0
try:
token = acquire_exchange_access_token()
username = settings.M365_MAILBOX_USER.strip()
if not username:
raise Microsoft365OAuthError(
"M365_MAILBOX_USER is verplicht voor Microsoft 365-mailuitvoer."
)
with smtplib.SMTP(
settings.EMAIL_HOST or "smtp.office365.com",
settings.EMAIL_PORT,
timeout=settings.M365_SMTP_TIMEOUT_SECONDS,
) as client:
client.ehlo()
client.starttls(context=trusted_tls_context())
client.ehlo()
code, _ = client.docmd("AUTH", f"XOAUTH2 {xoauth2_b64(username, token)}")
if code != 235:
raise Microsoft365OAuthError("Microsoft 365 SMTP OAuth geweigerd.")
for message in email_messages:
recipients = message.recipients()
if not recipients:
continue
sender = parseaddr(message.from_email or settings.DEFAULT_FROM_EMAIL)[1]
client.sendmail(
sender or username,
recipients,
message.message().as_bytes(linesep="\r\n"),
)
sent += 1
except Exception:
if self.fail_silently:
return sent
raise
return sent
+6 -2
View File
@@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm):
password = forms.CharField( password = forms.CharField(
required=False, required=False,
label="App-wachtwoord", label="App-wachtwoord",
help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.", help_text=(
"Alleen voor Gmail of een aangepaste provider. Microsoft 365 gebruikt "
"de centrale OAuth-configuratie."
),
widget=forms.PasswordInput( widget=forms.PasswordInput(
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"}, attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
render_value=False, render_value=False,
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
data = super().clean() data = super().clean()
if data.get("provider") != MailboxConnection.Provider.CUSTOM: if data.get("provider") != MailboxConnection.Provider.CUSTOM:
data["custom_host"] = "" data["custom_host"] = ""
if not self.instance.pk and not data.get("password"): uses_oauth = data.get("provider") == MailboxConnection.Provider.OUTLOOK
if not self.instance.pk and not uses_oauth and not data.get("password"):
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.") self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
return data return data
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sources", "0007_alter_mailboxconnection_platform")]
operations = [
migrations.AlterField(
model_name="mailboxconnection",
name="encrypted_password",
field=models.TextField(blank=True),
)
]
+1 -1
View File
@@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel):
custom_host = models.CharField(max_length=255, blank=True) custom_host = models.CharField(max_length=255, blank=True)
port = models.PositiveIntegerField(default=993) port = models.PositiveIntegerField(default=993)
username = models.CharField(max_length=320) username = models.CharField(max_length=320)
encrypted_password = models.TextField() encrypted_password = models.TextField(blank=True)
mailbox = models.CharField(max_length=255, default="INBOX") mailbox = models.CharField(max_length=255, default="INBOX")
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
poll_interval_minutes = models.PositiveIntegerField( poll_interval_minutes = models.PositiveIntegerField(
+7 -1
View File
@@ -74,14 +74,19 @@ def _validate_connected_peer(
*, *,
before: ValidatedUrl, before: ValidatedUrl,
after: ValidatedUrl, after: ValidatedUrl,
require_peer: bool,
) -> None: ) -> None:
peer_ip = _connected_peer_ip(response) peer_ip = _connected_peer_ip(response)
if peer_ip is not None: if peer_ip is not None:
if not peer_ip.is_global: if not peer_ip.is_global:
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}") raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
return return
if require_peer:
raise FetchError(
"Verbonden IP-adres kon niet veilig worden vastgesteld; aanvraag geblokkeerd."
)
# Mock/custom transports do not always expose the peer socket. Keep the conservative # Mock/custom transports do not always expose the peer socket. Keep the conservative
# DNS-overlap fallback for those transports. # DNS-overlap fallback only for explicitly injected test/custom clients.
if not set(before.addresses).intersection(after.addresses): if not set(before.addresses).intersection(after.addresses):
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.") raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
@@ -175,6 +180,7 @@ def fetch_url(
response, response,
before=validation, before=validation,
after=post_validation, after=post_validation,
require_peer=own_client,
) )
if response.status_code in {301, 302, 303, 307, 308}: if response.status_code in {301, 302, 303, 307, 308}:
location = response.headers.get("location") location = response.headers.get("location")
+13 -2
View File
@@ -7,6 +7,7 @@ from typing import Any
from django.conf import settings from django.conf import settings
from apps.core.services.microsoft365 import acquire_exchange_access_token, xoauth2_bytes
from apps.sources.models import EmailMessageRecord, MailboxConnection from apps.sources.models import EmailMessageRecord, MailboxConnection
from apps.sources.services.email_import import ingest_email, message_identity from apps.sources.services.email_import import ingest_email, message_identity
from apps.sources.services.mailbox_connections import decrypt_mailbox_password from apps.sources.services.mailbox_connections import decrypt_mailbox_password
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
if client_factory is None: if client_factory is None:
client_factory = imaplib.IMAP4_SSL client_factory = imaplib.IMAP4_SSL
host_validator(f"https://{connection.imap_host}") host_validator(f"https://{connection.imap_host}")
password = decrypt_mailbox_password(connection) password = (
""
if connection.provider == MailboxConnection.Provider.OUTLOOK
else decrypt_mailbox_password(connection)
)
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS} client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
if client_factory is imaplib.IMAP4_SSL: if client_factory is imaplib.IMAP4_SSL:
client_options["ssl_context"] = trusted_tls_context() client_options["ssl_context"] = trusted_tls_context()
@@ -42,7 +47,13 @@ def poll_imap_mailbox(
"failed": 0, "failed": 0,
} }
try: try:
client.login(connection.username, password) if connection.provider == MailboxConnection.Provider.OUTLOOK:
access_token = acquire_exchange_access_token()
client.authenticate(
"XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token)
)
else:
client.login(connection.username, password)
status, _ = client.select(connection.mailbox, readonly=True) status, _ = client.select(connection.mailbox, readonly=True)
if status != "OK": if status != "OK":
raise RuntimeError("IMAP-mailbox kon niet worden geopend") raise RuntimeError("IMAP-mailbox kon niet worden geopend")
+7 -2
View File
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
def rotate_mailbox_credentials() -> int: def rotate_mailbox_credentials() -> int:
"""Re-encrypt every mailbox password with the first configured key.""" """Re-encrypt every mailbox password with the first configured key."""
rotated = 0 rotated = 0
for connection in MailboxConnection.objects.select_for_update().all(): for connection in MailboxConnection.objects.select_for_update().exclude(
provider=MailboxConnection.Provider.OUTLOOK
):
password = decrypt_mailbox_password(connection) password = decrypt_mailbox_password(connection)
connection.encrypted_password = encrypt_mailbox_password(password) connection.encrypted_password = encrypt_mailbox_password(password)
connection.save(update_fields=["encrypted_password", "updated_at"]) connection.save(update_fields=["encrypted_password", "updated_at"])
@@ -79,7 +81,10 @@ def save_mailbox_connection(
"poll_interval_minutes", "poll_interval_minutes",
): ):
setattr(connection, field, cleaned_data[field]) setattr(connection, field, cleaned_data[field])
if password: uses_oauth = connection.provider == MailboxConnection.Provider.OUTLOOK
if uses_oauth:
connection.encrypted_password = ""
elif password:
connection.encrypted_password = encrypt_mailbox_password(password) connection.encrypted_password = encrypt_mailbox_password(password)
elif not connection.encrypted_password: elif not connection.encrypted_password:
raise MailboxCredentialError("Een app-wachtwoord is verplicht.") raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
+6
View File
@@ -298,6 +298,12 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587"))
EMAIL_HOST_USER = os.getenv("SMTP_USER", "") EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "") EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True) EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
M365_TENANT_ID = os.getenv("M365_TENANT_ID", "")
M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "")
M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "")
M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER)
M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default"
M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120)
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SAMESITE = "Lax"
+1 -1
View File
@@ -12,7 +12,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.unraid dockerfile: Dockerfile.unraid
image: vacatureradar:unraid image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
container_name: VacatureRadar container_name: VacatureRadar
env_file: env_file:
- ${VACATURERADAR_ENV_FILE:-.env} - ${VACATURERADAR_ENV_FILE:-.env}
+13 -6
View File
@@ -1356,22 +1356,25 @@ tasks:
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs. digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
acceptance_criteria: acceptance_criteria:
- Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie. - Iedere platformmailbox gebruikt TLS, een dedicated alias en read-only mailboxselectie; Microsoft 365 gebruikt
app-only OAuth/XOAUTH2 en geen Basic Auth of app-wachtwoord.
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de - Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
scheduler gerespecteerd. scheduler gerespecteerd.
- SMTP verstuurt één testdigest en outboxstatus klopt. - SMTP verstuurt één testdigest en outboxstatus klopt.
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de - OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele
database aanwezig en sleutelrotatie is live getest. Gmail/custom-app-wachtwoorden zijn als ciphertext opgeslagen en sleutelrotatie is live getest.
verification: verification:
- Volg Unraid mailboxchecklist - Volg Unraid mailboxchecklist
- Inspecteer logs op afwezigheid van secret/payload - Inspecteer logs op afwezigheid van secret/payload
primary_paths: primary_paths:
- /mnt/user/appdata/vacatureradar/config/.env - /mnt/user/appdata/vacatureradar/config/.env
- Bronnen -> Platformmailboxen - Bronnen -> Platformmailboxen
external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker. external_blocker: De dedicated productie-alias, Entra-appregistratie, admin consent en Exchange-mailboxscope zijn
op 2026-07-30 live ingericht; resterend zijn de productie-uitrol en echte platformalertmails voor de
import-/digestproef.
- id: VR-203 - id: VR-203
title: Productiedomein, TLS en reverse proxy activeren title: Productiedomein, TLS en reverse proxy activeren
status: blocked-external status: done
priority: P0 priority: P0
requirement_ids: requirement_ids:
- NFR-001 - NFR-001
@@ -1393,7 +1396,11 @@ tasks:
primary_paths: primary_paths:
- Unraid reverse proxy - Unraid reverse proxy
- /mnt/user/appdata/vacatureradar/config/.env - /mnt/user/appdata/vacatureradar/config/.env
external_blocker: Vereist gekozen domein/DNS en toegang tot de Unraid reverse proxy. result:
completed_at: '2026-07-29'
note: Publiek domein, HTTP-naar-HTTPS, TLS/securityheaders, DEBUG=False, secure cookies, CSRF-gastlogin, kernroutes
en readiness live bewezen in docs/audit/FINAL_ACCEPTANCE.md op 2026-07-29; interne diensten zijn uitsluitend
containerintern bereikbaar.
- id: VR-204 - id: VR-204
title: Immutable containerimage publiceren title: Immutable containerimage publiceren
status: blocked-external status: blocked-external
+38 -3
View File
@@ -30,7 +30,37 @@ Productieoplevering 0.3.14 op 2026-07-29:
- de pre-releasecustomdump, media en composeconfig staan checksum-geldig onder `/mnt/user/appdata/VacatureRadar/backups/release-0.3.14-20260729T164500Z`; rollbackimage `vacatureradar:rollback-0.3.13` wijst naar `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`; - de pre-releasecustomdump, media en composeconfig staan checksum-geldig onder `/mnt/user/appdata/VacatureRadar/backups/release-0.3.14-20260729T164500Z`; rollbackimage `vacatureradar:rollback-0.3.13` wijst naar `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
- productie is healthy zonder restarts, versie 0.3.14 is publiek zichtbaar, alle gastkernroutes antwoorden 200 en een echte importsmoke creëerde één job binnen een transactie waarna rollback expliciet bevestigd werd. - productie is healthy zonder restarts, versie 0.3.14 is publiek zichtbaar, alle gastkernroutes antwoorden 200 en een echte importsmoke creëerde één job binnen een transactie waarna rollback expliciet bevestigd werd.
Volgende uitvoerbare taak: geen na afronding van VR-228. Alleen de bestaande optionele externe identity- en mailboxacties blijven open. Volgende uitvoerbare taak: geen na afronding van VR-228. Alleen de optionele
mailbox-/identityconfiguratie (`VR-202`) en publicatie naar een externe private
registry (`VR-204`) blijven extern geblokkeerd.
## Laatste risicohardening
Live mailboxvoorbereiding op 2026-07-30:
- Exchange Online bevestigde dat de dedicated VacatureRadar-alias aan de productie-mailbox is toegevoegd;
- de alias is niet primair gemaakt en bestaande adressen zijn ongewijzigd gebleven;
- de Entra-appregistratie, admin consent en Exchange-mailboxscope zijn live ingericht
met app-only OAuth; tijdelijke bootstraprechten en de bootstrap-app zijn na afloop verwijderd;
- `VR-202` resteert alleen nog op de productie-uitrol en echte platformalert-/digestproeven;
Basic Auth wordt niet geactiveerd;
- de volledige lokale gate is groen met 324 tests, 0 failures, 0 skips en 84,05%
branch-aware dekking.
Risicohardening op 2026-07-29:
- de productiefetcher blokkeert nu fail-closed wanneer de HTTP-transportlaag het
werkelijk verbonden peer-IP niet beschikbaar stelt; ieder niet-publiek peer-IP
bleef al geblokkeerd;
- alleen expliciet geïnjecteerde test/custom clients mogen nog de begrensde
DNS-overlapfallback gebruiken; een negatieve regressietest dekt de
productiegrens;
- `VR-203` is administratief afgerond op basis van het bestaande live bewijs in
`docs/audit/FINAL_ACCEPTANCE.md`: publieke HTTP→HTTPS, TLS/securityheaders,
veilige cookies, CSRF-gastlogin, kernroutes en readiness zijn aantoonbaar
geslaagd;
- verificatie: Ruff groen, 8 gerichte fetchertests geslaagd en de volledige gate
groen met 319 tests, 0 failures, 0 skips en 84,21% branch-aware dekking.
Productieoplevering 0.3.13 op 2026-07-29: Productieoplevering 0.3.13 op 2026-07-29:
@@ -554,13 +584,18 @@ Deze blokkeren onafhankelijke code niet:
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus; - verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
- live IMAP/SMTP-account en app-password; - live IMAP/SMTP-account en app-password;
- Unraid-domein, DNS, TLS en reverse proxy;
- containerregistry en credentials; - containerregistry en credentials;
- actieve/voor deze repository geregistreerde Gitea Actions-runner; - actieve/voor deze repository geregistreerde Gitea Actions-runner;
## Bekende verificatiebeperking van de aangeleverde basis ## Bekende verificatiebeperking van de aangeleverde basis
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie. De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn
geverifieerd. De verplichte Playwright-gate draait lokaal met Chromium; VR-119 is
aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op
het begeleide profiel en de live vacaturefeed en VR-124 op de acht
platformmailboxen en alertkaarten. Publieke HTTPS, proxyheaders en de gastflow
zijn op 2026-07-29 live geaccepteerd en vastgelegd in
`docs/audit/FINAL_ACCEPTANCE.md`.
## Hervatten ## Hervatten
+5
View File
@@ -77,6 +77,11 @@ celery -A config inspect reserved
## Digest ontbreekt of dubbel ## Digest ontbreekt of dubbel
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`. - Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
- Microsoft 365 gebruikt uitsluitend app-only OAuth. Configureer `M365_TENANT_ID`,
`M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_MAILBOX_USER` en
`EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`.
Verleen alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`, admin consent en mailboxscope;
Basic Auth en Microsoft 365-app-wachtwoorden worden niet ondersteund.
- Controleer `DigestOutbox` op datum en status. - Controleer `DigestOutbox` op datum en status.
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd. - Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière. - Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
+3 -1
View File
@@ -164,7 +164,9 @@ IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000 IMAP_MAX_MESSAGE_BYTES=1000000
``` ```
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is. Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Gmail en aangepaste publieke IMAP-hosts gebruiken een app-wachtwoord. Outlook / Microsoft 365 gebruikt centraal app-only OAuth via de vier `M365_*`-waarden en `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`; geef de Entra-app alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp` en beperk de Exchange-serviceprincipal tot de bedoelde mailbox. Laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
Voor een immutable registryrelease zet je `VACATURERADAR_IMAGE=gitea.itworx.tech/jens/vacatureradar:<versie>@sha256:<digest>` en start je met `docker compose -f docker-compose.unraid.yml up -d --no-build`.
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je: Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
+9 -1
View File
@@ -115,7 +115,10 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
## Resterende risico's ## 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. - De productiefetcher blokkeert fail-closed wanneer httpcore het werkelijk verbonden
peer-IP niet exposeert en weigert ieder niet-publiek peer-IP. Alleen een expliciet
geïnjecteerde test/custom client mag terugvallen op DNS-set-overlap; die clientgrens
mag daarom niet voor productie-fetches worden gebruikt.
- 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. - 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. - 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. - Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
@@ -150,3 +153,8 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting - Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger - Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
- Verificatie: `tests/integration/test_source_health.py` - Verificatie: `tests/integration/test_source_health.py`
# Microsoft 365 OAuth
- Exchange Online gebruikt app-only XOAUTH2; Basic Auth en app-wachtwoorden zijn uitgesloten.
- Client secrets leven alleen in de productie-secretstore en tokenfouten bevatten geen token of secret.
- De serviceprincipal krijgt uitsluitend IMAP/SMTP-toegang tot de bedoelde mailbox; IMAP-selectie blijft read-only.
@@ -61,6 +61,7 @@ def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, u
) )
assert response.status_code == 302 assert response.status_code == 302
indeed = MailboxConnection.objects.get(user=user, platform="indeed") indeed = MailboxConnection.objects.get(user=user, platform="indeed")
assert indeed.encrypted_password == ""
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk])) edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
assert edit_response.status_code == 200 assert edit_response.status_code == 200
+19
View File
@@ -8,6 +8,7 @@ from apps.sources.services.fetcher import (
FetchTimeoutError, FetchTimeoutError,
PolicyBlockedError, PolicyBlockedError,
RateLimitedError, RateLimitedError,
_validate_connected_peer,
fetch_url, fetch_url,
) )
from apps.sources.services.url_security import ValidatedUrl from apps.sources.services.url_security import ValidatedUrl
@@ -100,6 +101,24 @@ def test_fetcher_rejects_dns_rebinding(monkeypatch):
client.close() client.close()
def test_production_transport_fails_closed_without_connected_peer():
validated = ValidatedUrl(
url="https://example.org/jobs",
hostname="example.org",
port=443,
addresses=("93.184.216.34",),
)
response = httpx.Response(200)
with pytest.raises(FetchError, match="Verbonden IP-adres"):
_validate_connected_peer(
response,
before=validated,
after=validated,
require_peer=True,
)
class _PeerStream: class _PeerStream:
def __init__(self, address: str) -> None: def __init__(self, address: str) -> None:
self.address = address self.address = address
+13
View File
@@ -89,6 +89,19 @@ def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
assert decrypt_mailbox_password(connection) == "rotate-me" assert decrypt_mailbox_password(connection) == "rotate-me"
@pytest.mark.django_db
def test_rotation_skips_outlook_oauth_connections(user):
MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.INDEED,
provider=MailboxConnection.Provider.OUTLOOK,
username="vacatureradar@example.invalid",
encrypted_password="",
)
assert rotate_mailbox_credentials() == 0
@pytest.mark.django_db @pytest.mark.django_db
def test_private_imap_host_is_blocked_before_connection(user): def test_private_imap_host_is_blocked_before_connection(user):
key = Fernet.generate_key().decode("ascii") key = Fernet.generate_key().decode("ascii")
+108
View File
@@ -0,0 +1,108 @@
import base64
import pytest
from django.test import override_settings
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.notifications.backends import Microsoft365OAuthEmailBackend
@override_settings(
M365_TENANT_ID="tenant-id",
M365_CLIENT_ID="client-id",
M365_CLIENT_SECRET="client-secret",
M365_EXCHANGE_SCOPE="https://outlook.office365.com/.default",
)
def test_app_only_token_uses_tenant_authority_and_exchange_scope():
calls = {}
class Client:
def acquire_token_for_client(self, *, scopes):
calls["scopes"] = scopes
return {"access_token": "access-token"}
def factory(client_id, *, authority, client_credential):
calls.update(
client_id=client_id,
authority=authority,
client_credential=client_credential,
)
return Client()
assert acquire_exchange_access_token(client_factory=factory) == "access-token"
assert calls == {
"client_id": "client-id",
"authority": "https://login.microsoftonline.com/tenant-id",
"client_credential": "client-secret",
"scopes": ["https://outlook.office365.com/.default"],
}
@override_settings(M365_TENANT_ID="", M365_CLIENT_ID="", M365_CLIENT_SECRET="")
def test_missing_oauth_configuration_fails_without_secret_material():
with pytest.raises(Microsoft365OAuthError, match="niet volledig") as error:
acquire_exchange_access_token()
assert "secret" not in str(error.value).lower()
def test_xoauth2_payload_is_encoded_exactly():
encoded = xoauth2_b64("mailbox@example.invalid", "token-value")
assert base64.b64decode(encoded) == (
b"user=mailbox@example.invalid\x01auth=Bearer token-value\x01\x01"
)
@override_settings(
M365_MAILBOX_USER="vacatureradar@example.invalid",
EMAIL_HOST="smtp.office365.com",
EMAIL_PORT=587,
DEFAULT_FROM_EMAIL="VacatureRadar <vacatureradar@example.invalid>",
M365_SMTP_TIMEOUT_SECONDS=10,
)
def test_oauth_smtp_backend_authenticates_and_sends(monkeypatch):
calls = []
class SMTP:
def __init__(self, host, port, *, timeout):
calls.append(("connect", host, port, timeout))
def __enter__(self):
return self
def __exit__(self, *args):
return None
def ehlo(self):
calls.append(("ehlo",))
def starttls(self, *, context):
calls.append(("starttls", bool(context)))
def docmd(self, command, argument):
calls.append((command, argument))
return 235, b"ok"
def sendmail(self, sender, recipients, message):
calls.append(("sendmail", sender, recipients, message))
monkeypatch.setattr("apps.notifications.backends.smtplib.SMTP", SMTP)
monkeypatch.setattr(
"apps.notifications.backends.acquire_exchange_access_token",
lambda: "token-value",
)
from django.core.mail import EmailMessage as DjangoEmailMessage
django_message = DjangoEmailMessage(
subject="Test",
body="test",
from_email="vacatureradar@example.invalid",
to=["recipient@example.invalid"],
)
assert Microsoft365OAuthEmailBackend().send_messages([django_message]) == 1
assert calls[0] == ("connect", "smtp.office365.com", 587, 10)
assert any(call[0] == "AUTH" and call[1].startswith("XOAUTH2 ") for call in calls)
assert any(call[0] == "sendmail" for call in calls)