Ga naar inhoud

Security audit (8 september 2026)

Statische code-audit van de hele monorepo op stand c1ae059 (main). Alleen gelezen, niets gewijzigd. Dit document is de vindingenlijst; werk de statuskolom bij als een punt is opgelost en verwijs naar de commit.

  • Geen kritieke vindingen. Geen secrets in de repo of in de git-historie, .env-bestanden zijn genegeerd en nooit gecommit, repo is privé.
  • 2 hoog, 10 midden, 14 laag plus een reeks informatieve punten.
  • De drie belangrijkste: de rate-limiter telt achter Traefik iedereen als één IP (SA-01), een kwetsbare image-size in het upload-pad (SA-02) en een open redirect na inloggen (SA-03). Alle drie op 8 september 2026 aangepakt, zie de statusregels; SA-02 is gemitigeerd omdat er geen gepatchte image-size bestaat.
Onderdeel Wat is gelezen
apps/site-template middleware, auth, CSRF, cache, security headers, alle 26 CMS-acties, alle admin-pagina’s, publieke pagina’s, contactformulier, media-upload, platform-API, outbox
apps/master-dashboard alle server-routes en utils (auth, webhooks, secrets, provisioning, Coolify, Cloudflare, custom domains), Nuxt-config, page middleware
packages/* blocks (markdown, renderers), contract (Zod-schema’s), db (client, seed-guard, migraties), ops (backup, restore, provisioning, user-scripts)
scripts/, docker-compose.yml, Dockerfiles shell-scripts, ops-runner image, lokale infra
Repo-breed secret-scan op tracked files en volledige git-historie, pnpm audit, GitHub-instellingen

Claims over runtime-gedrag (Astro clientAddress, adapter body-limiet, open redirect) zijn geverifieerd in de geïnstalleerde node_modules en met Node. Vindingen zijn gemarkeerd Bevestigd (code gelezen, gedrag gecontroleerd) of Aannemelijk (afhankelijk van deployment of niet-geverifieerd extern gedrag).

Ernst Betekenis
Hoog Zonder account misbruikbaar, of volledige uitval van een tenant; nu oplossen
Midden Vereist een account, een tweede fout, of raakt alleen operator-paden; binnen de volgende taak oplossen
Laag Beperkte impact of alleen defense-in-depth; meenemen wanneer het bestand toch wordt aangeraakt
Info Geen direct risico; bewuste keuze of hygiëne

SA-01 — Rate-limiter is één gedeelde emmer achter de reverse proxy

Section titled “SA-01 — Rate-limiter is één gedeelde emmer achter de reverse proxy”

Status: opgelost 8 september 2026 (PR #27) · Bevestigd

apps/site-template/astro.config.mjs zet geen security.allowedDomains. Astro 7.2 (core/app/node.js) honoreert x-forwarded-for alleen wanneer de host is gevalideerd tegen allowedDomains; anders is context.clientAddress het socket.remoteAddress, achter Coolify/Traefik dus altijd het Traefik-IP.

Getroffen limieten:

  • src/pages/api/auth/[...all].ts:29 — sign-in 10 en reset 5 per 15 minuten, gekeyed op auth:${pathname}:${ip}
  • src/actions/index.ts:78 — contactformulier 5 per uur, gekeyed op contact:${clientKey}

Gevolg. Tien POSTs op /api/auth/sign-in/email geven alle CMS-gebruikers van die tenant vijftien minuten een 429, onbeperkt herhaalbaar en zonder account. Vijf requests blokkeren wachtwoord-reset voor iedereen. De zesde bezoeker per uur kan het contactformulier niet insturen. De per-IP bruteforce-bescherming die de code suggereert bestaat feitelijk niet.

Bijkomend: better-auth’s eigen limiter (src/lib/auth.ts:53, storage: "memory") valt terug op één gedeelde sleutel zodra x-forwarded-for meer dan één hop bevat. Achter Cloudflare for SaaS (custom domains) voegt Traefik het edge-IP toe, dus daar geldt hetzelfde lockout-scenario met 3 requests per 10 seconden.

Fix.

  1. security.allowedDomains configureren met de tenant-hosts, zodat Astro de proxy-header vertrouwt. Alternatief: het client-IP zelf uit een header lezen die Traefik zet en overschrijft.
  2. better-auth advanced.ipAddress.trustedProxies (Traefik plus Cloudflare-ranges) of ipAddressHeaders: ["cf-connecting-ip"].
  3. src/lib/rate-limit.ts:9 — de Map wordt nooit opgeruimd; zodra er per echt IP wordt gekeyed groeit hij onbegrensd. Verlopen buckets verwijderen of een LRU.

Oplossing. security.allowedDomains: [{}] in astro.config.mjs: Astro vertrouwt nu de proxy-headers (tenant-hosts zijn dynamisch, dus geen hostlijst). src/lib/client-ip.ts zet Astro’s clientAddress in een eigen header die Better Auth via advanced.ipAddress.ipAddressHeaders leest, zodat beide limiters dezelfde bron gebruiken en Better Auth nooit op een gedeelde sleutel terugvalt. rate-limit.ts ruimt verlopen buckets op (sweep per minuut of bij 10.000 entries). Tests: client-ip.test.ts, rate-limit.test.ts.

Restrisico. Achter Cloudflare for SaaS overschrijft Traefik x-forwarded-for met het Cloudflare-edge-IP zolang Traefik de Cloudflare-ranges niet als forwardedHeaders.trustedIPs vertrouwt. Bezoekers via een custom domein delen dan een emmer per edge-IP in plaats van één tenant-brede emmer. Dat is Traefik-configuratie op de Coolify-server, geen repo-wijziging; nog in te richten.

SA-02 — Kwetsbare image-size 2.0.2 in het upload-pad; MIME-type van de client vertrouwd

Section titled “SA-02 — Kwetsbare image-size 2.0.2 in het upload-pad; MIME-type van de client vertrouwd”

Status: gemitigeerd 8 september 2026 (PR #27) · Bevestigd

src/actions/cms-media.ts:88 controleert file.type (het multipart-header dat de browser opgeeft) tegen de allowlist, en laat daarna imageSize(buffer) op de echte bytes los. image-size 2.0.2 heeft oneindige lussen in de ICNS-, JXL- en HEIF-parsers (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq; gefixt in 2.0.3). Het gedetecteerde type wordt nooit vergeleken met het opgegeven type.

Gevolg. Een editor-account uploadt een geprepareerd bestand met Content-Type: image/png; de event loop hangt en de site van die tenant is down tot de container herstart. Daarnaast kan een SVG als image/png worden opgeslagen; van S3 geserveerd is dat inert (geen XSS), maar via /_image gaat het door sharp/librsvg, dus door de aanvaller bepaalde SVG-rasterisatie server-side. Er wordt geen Content-Disposition gezet.

Fix.

  1. image-size naar >=2.0.3 (apps/site-template/package.json).
  2. Het gesniffede type uit imageSize() als bron van waarheid: alleen jpg, png, webp, gif; ContentType en extensie daaruit afleiden, niet uit file.type.
  3. Content-Disposition: inline; filename=<uuid>.<ext> op het object.

Oplossing. Er is geen gepatchte image-size gepubliceerd: het register kent op 8 september 2026 alleen 2.0.2 (april 2025), terwijl de advisories >=2.0.3 noemen. Daarom mitigeert de code: src/lib/image-sniff.ts bepaalt het type op magic bytes (alleen JPEG, PNG, WebP, GIF), cms-media.ts roept image-size pas daarna aan, zet met disableTypes() alle andere parsers uit en eist dat image-size hetzelfde type ziet als de sniff. MIME-type, extensie en Content-Disposition komen uit de gesniffte inhoud. Astro gebruikt een eigen vendored kopie van image-size, dus disableTypes raakt alleen de upload. pnpm audit blijft 2.0.2 melden tot er een release is; bump zodra die verschijnt. Tests: image-sniff.test.ts.


SA-03 — Open redirect na login via tab of newline in ?redirect=

Section titled “SA-03 — Open redirect na login via tab of newline in ?redirect=”

Status: opgelost 8 september 2026 (PR #27) · Bevestigd

src/lib/safe-redirect.ts:21-22 controleert alleen op een leidende / en weigert // en /\. ?redirect=/%09/evil.com decodeert naar /\t/evil.com, passeert beide checks, en de WHATWG URL-parser strijpt tab en newline vóór het parsen: new URL("/\t/evil.com", "https://admin.tenant.example/login").href is https://evil.com/. Gebruikt in src/pages/login.astro:9 (server-redirect bij bestaande sessie) en :83/:106 (window.location.href na inloggen).

Gevolg. Phishing-link https://admin.<tenant>/login?redirect=/%09/evil.com; na (of bij al) ingelogd zijn landt de gebruiker op een pagina van de aanvaller, bijvoorbeeld een nep-“sessie verlopen”-login.

Fix. new URL(value, adminOrigin) opbouwen en url.origin === adminOrigin eisen, of elke control character (< 0x20) weigeren. Beide paden (server en client) gebruiken dezelfde functie, dus één fix.

Oplossing. safeRedirectPath weigert nu elk control character en resolvet het pad tegen een vaste dummy-origin; alleen als de origin gelijk blijft wordt het pad teruggegeven. Regressietests in src/lib/safe-redirect.test.ts (tab, newline, CR, backslash, percent-encoded varianten).

SA-04 — Body-limiet staat op 1 GiB, geen proxy-limiet

Section titled “SA-04 — Body-limiet staat op 1 GiB, geen proxy-limiet”

Status: open · Bevestigd (default) · Aannemelijk (geen limiet stroomopwaarts)

astro.config.mjs:30 gebruikt node({ mode: "standalone" }) zonder bodySizeLimit; @astrojs/node 11.1.0 valt terug op 1024 * 1024 * 1024. Astro buffert de volledige multipart body vóór de handler draait, dus vóór requireCmsUser en de 8 MB-check in cms-media.ts:69-86. De repo en docs bevatten geen Traefik-bufferinglimiet.

Gevolg. Anonieme POSTs van ~900 MB naar /_actions/uploadMedia (met een passende Origin), een paar parallel, geven OOM op de container.

Fix. node({ mode: "standalone", bodySizeLimit: 10 * 1024 * 1024 }) en een Traefik-middleware buffering.maxRequestBodyBytes als tweede laag.

SA-05 — Interne foutmeldingen op het publieke contactformulier

Section titled “SA-05 — Interne foutmeldingen op het publieke contactformulier”

Status: open · Bevestigd

src/components/ContactForm.astro:7-8, 19-23 rendert result.error.message wanneer het geen input-fout is. Astro’s callSafely wrapt elke niet-ActionError als INTERNAL_SERVER_ERROR met de originele message, zonder productie-guard. De DB-calls in src/actions/index.ts:92-127 zijn niet gewrapt.

Gevolg. Bezoekers zien letterlijk connect ECONNREFUSED 10.x.x.x:5432, password authentication failed for user "…" of relation "…" does not exist.

Fix. In de handler vangen en een generieke ActionError gooien, of in het component een vaste tekst tonen voor code INTERNAL_SERVER_ERROR.

SA-06 — Middleware-shortcut voor statische bestanden omzeilt de CMS-auth-gate

Section titled “SA-06 — Middleware-shortcut voor statische bestanden omzeilt de CMS-auth-gate”

Status: open · Bevestigd (gate-bypass) · Aannemelijk (impact nu latent)

src/middleware.ts:46-54 markeert elk pad dat matcht op /\.[a-zA-Z0-9]+$/ als statisch bestand; :72-76 zet locals.user = null en roept next() aan vóór de host-, /admin- en rolcheck. /admin/media/x.png of /admin/pages/<uuid>/blocks/x.js bereikt zo de dynamische admin-routes zonder sessie, op beide hosts. Alleen admin/instellingen/index.astro:21 controleert zelf; AdminLayout.astro niet.

Gevolg. Vandaag geeft elke zo’n pagina een 500 omdat alle id’s uuid-kolommen zijn en Postgres de cast weigert. Wel draaien loadPublicSite() en de lookup zonder authenticatie, en elke toekomstige admin-route met een slug- of tekstparameter (export.csv, .json) erft de bypass.

Fix. Shortcut beperken tot /_astro/, /_image en een expliciete lijst publieke bestanden (favicon.ico, robots.txt). Daarnaast hasCmsAccess in AdminLayout.astro als defense-in-depth.

SA-07 — Dashboard: geen CSRF-bescherming op state-changing API-routes

Section titled “SA-07 — Dashboard: geen CSRF-bescherming op state-changing API-routes”

Status: open · Aannemelijk

apps/master-dashboard/server/utils/session.ts:16-22 controleert alleen de sessiecookie; geen Origin- of Sec-Fetch-Site-check, geen token. h3 parseert application/x-www-form-urlencoded bodies, dus een gewoon HTML-formulier met slug=…&name=… of hostname=… passeert de Zod-schema’s van server/api/tenants/index.post.ts, [slug]/domains/index.post.ts en [slug]/provision.post.ts (POST zonder body). De better-auth-cookie is SameSite=Lax; dat blokkeert cross-site formulieren maar geen same-site.

Gevolg. Preview-sites draaien op {slug}.preview.okhema.studio. Staat het dashboard ook onder okhema.studio, dan is elke tenant-container same-site met het dashboard, en kan een XSS of compromittering van één tenant een ingelogde admin tenants laten aanmaken, provisioneren of hostnames registreren (zie SA-09). PATCH/DELETE zijn toevallig beschermd omdat ze een CORS-preflight vereisen. better-auth’s eigen endpoints zijn niet getroffen (Origin-check tegen trustedOrigins).

Fix. In requireAdminSession niet-GET requests weigeren waarvan Origin (of Sec-Fetch-Site) niet de dashboard-origin is; Content-Type: application/json eisen via readValidatedBody; het dashboard op een eigen registrable domein hosten.

SA-08 — Dashboard: gedeeld fallback-webhooksecret laat cross-tenant vervalsing toe

Section titled “SA-08 — Dashboard: gedeeld fallback-webhooksecret laat cross-tenant vervalsing toe”

Status: open · Bevestigd

server/utils/webhooks.ts:103-106 accepteert het gedeelde NUXT_PLATFORM_WEBHOOK_SECRET voor onbekende slugs en voor tenants zonder webhook_secret_encrypted. De slug komt uit de header x-platform-tenant (receive-site-webhook.ts:58-62); de enige kruiscontrole is dat de body-tenantId gelijk is, ook door de afzender bepaald. receive-site-webhook.ts:152 vertrouwt occurredAt uit de body voor site_stats.last_activity_at. Bij aanmaken van een tenant adopteert tenants/index.post.ts:55-91 bestaande activity op payload->>'tenantSlug'.

Gevolg. Wie het gedeelde secret heeft (elke handmatig gedeployde site, of iemand met de .env.example-placeholder, zie SA-22) schrijft activity voor elke nog niet geprovisioneerde en elke toekomstige tenant, en zet lastActivityAt ver in de toekomst zodat de “quiet”-vlag (tenant-attention.ts) nooit meer afgaat. Alleen integriteit van de ops-feed; geen secrets, geen routing. De integratietest “unknown tenant with a shared secret uses shared” documenteert dit als bedoeld.

Fix. Onbekende slugs weigeren; het gedeelde pad uitschakelen zodra een tenant ciphertext heeft; occurredAt binnen het HMAC-tijdvenster eisen.

SA-09 — Dashboard: custom-domein validatie sluit platform-hostnames niet uit; routing vóór verificatie

Section titled “SA-09 — Dashboard: custom-domein validatie sluit platform-hostnames niet uit; routing vóór verificatie”

Status: open · Aannemelijk

packages/contract/src/platform.ts:27-35 (hostnameSchema) accepteert elke dotted lowercase name, inclusief IP-literals. server/utils/custom-domains.ts:481-527 controleert conflicten alleen tegen rijen in domains; de afgeleide CMS-hosts admin.<preview-host>, het cfCnameTarget, de dashboard-host en alles onder previewBaseHost staan daar niet in. :219, 529-553 pusht direct na insert naar Coolify (https://<hostname> in de domains-PATCH), los van de Cloudflare-status.

Gevolg. Een admin (of SA-07) registreert admin.acme.preview.okhema.studio (de CMS-loginhost van tenant A) als custom domein van tenant B. Traefik krijgt twee routers op dezelfde Host; afhankelijk van prioriteit kaapt of breekt dat de CMS-login van A (credential capture). De Cloudflare-TXT-check bewaakt alleen edge-TLS, niet de Coolify-routing. Traefik-gedrag bij dubbele Host is niet geverifieerd.

Fix. Denylist: *.{previewBaseHost}, cfCnameTarget, de dashboard-host, admin.* van elke preview-rij, IP-literals. Een custom host pas naar Coolify pushen als Cloudflare active meldt.

SA-10 — Ops: restore --allow-nonempty kan de verkeerde tenant-database overschrijven

Section titled “SA-10 — Ops: restore --allow-nonempty kan de verkeerde tenant-database overschrijven”

Status: open · Bevestigd

packages/ops/src/restore.ts:373-397 weigert een niet-lege database tenzij --allow-nonempty. Met die vlag wordt site.tenant_id nooit vergeleken met meta.tenantId, en de doel-databasenaam niet met meta.dbName, tot verifyTenant draait (:412-417), ná restorePostgres (pg_restore --clean of DROP SCHEMA public CASCADE). backup.ts:92 doet de spiegelcheck wel op de weg naar buiten.

Gevolg. Een operator met tenant A’s DATABASE_URL in de omgeving draait restore --from backups/tenant-b/… --yes --allow-nonempty; A’s data is weg en vervangen door B’s (inclusief B’s form_submissions en gebruikers op A’s site). Verify faalt te laat.

Fix. Als meta aanwezig is en er een site-rij bestaat: eis existing.tenantId === meta.tenantId en parsePgUrl(url).database === meta.dbName vóór de schema wordt aangeraakt, met een aparte expliciete override-vlag voor bewuste cross-restores.

SA-11 — Ops: backups bevatten live sessietokens, reset-tokens, wachtwoordhashes en PII, onversleuteld

Section titled “SA-11 — Ops: backups bevatten live sessietokens, reset-tokens, wachtwoordhashes en PII, onversleuteld”

Status: open · Bevestigd

packages/ops/src/backup.ts:103-122 dumpt de hele database (pg_dump -Fc); :220-237 en lib/run-command.ts:42 schrijven zonder mode, dus met de umask van het proces (meestal 0644/0755) naar een vrij te kiezen --out. De dump bevat sessions.token (bearer tokens, geldig tot expiry), verifications.value (reset-tokens), accounts.password en form_submissions.payload (naam, e-mail, bericht). Geen encryptie. .gitignore sluit backups wel uit.

Gevolg. Wie de backup-map, de /root/ops-backups-mount of een offsite-kopie kan lezen, speelt een sessietoken af en zit als admin in het CMS van die tenant, of gebruikt een nog geldige reset-token.

Fix. --exclude-table-data=sessions --exclude-table-data=verifications (en compareCounts daarop aanpassen); bestanden 0o600, mappen 0o700; versleutelen (age/gpg) voordat het archief de host verlaat.

SA-12 — Geen lengtelimieten op thema, blokteksten en webhook-payloads

Section titled “SA-12 — Geen lengtelimieten op thema, blokteksten en webhook-payloads”

Status: open · Bevestigd (schema) · Aannemelijk (Astro-limiet als mitigatie)

  • packages/contract/src/theme.ts:15, 18, 76-77cssColor, cssLength, fontHeading zijn z.string().min(1) zonder .max() of formaat-regex.
  • packages/contract/src/blocks.tsheading, body, label, caption, quote, attribution, role, title onbegrensd.
  • packages/contract/src/events.ts:46-51site.error message en context (z.record(z.string(), z.unknown())) onbegrensd; opgeslagen in activity.payload.
  • apps/master-dashboard/server/api/webhooks/site.post.ts:13readRawBody vóór enige check, zonder grootte-limiet.

Gevolg. Een editor (blokken) of admin (thema) slaat multi-MB strings op. De themawaarden staan in <html style> van elke publieke én admin-response (Layout.astro:30, AdminLayout.astro, AdminAuthLayout.astro), dus elke response groeit mee en een admin kan zichzelf buitensluiten van de instellingenpagina die nodig is om het te herstellen. Op de webhook: geheugendruk door iedereen (body wordt volledig gebufferd vóór HMAC-check) en DB-groei door elke tenant met een geldig secret. Niet cross-tenant.

Fix. .max() overal (richtwaarden: 64 voor kleuren en lengtes met een patroon, 300 voor fonts, 200 voor headings, ~20k voor body); Content-Length-cap vóór readRawBody en op de proxy; context niet persisteren of begrenzen.


Section titled “SA-13 — Markdown laat protocol-relatieve links toe”

Status: open · Bevestigd

packages/blocks/src/markdown.ts:30 zet allowedSchemes maar niet allowProtocolRelative: false; sanitize-html 2.17.6 staat //host standaard toe, terwijl href.ts:45 dat bewust weigert. [Inloggen](//evil.example/login) in een Text-blok wordt een intern ogende phishinglink. Geen XSS (schemes blijven afgedwongen). Fix: allowProtocolRelative: false.

SA-14 — Tenant-admin kan een willekeurige https-stylesheet laden in publieke site én CMS-shell

Section titled “SA-14 — Tenant-admin kan een willekeurige https-stylesheet laden in publieke site én CMS-shell”

Status: open · Bevestigd

packages/contract/src/href.ts:102-121 accepteert elke https-host; theme.ts:78-87 neemt dat over; Layout.astro:72 en AdminLayout.astro renderen <link rel="stylesheet">; security-headers.ts:12-14 staat style-src https: en img-src https: toe. Een (gecompromitteerde) admin restylet het CMS voor andere gebruikers en beacont elke paginaweergave. Exfiltratie van secrets via CSS is beperkt omdat CSRF origin-gebaseerd is zonder DOM-token. Fix: allowlist van fonthosts of self-hosten; minimaal geen tenant-stylesheets in AdminLayout.

SA-15 — Postgres-TLS wordt niet geverifieerd bij sslmode=require

Section titled “SA-15 — Postgres-TLS wordt niet geverifieerd bij sslmode=require”

Status: open · Aannemelijk (deployment-afhankelijk)

packages/db/src/client.ts:28 en master/client.ts:33 geven alleen de URL door; postgres.js 3.4.9 zet bij require|prefer|allow rejectUnauthorized = false (versleuteld, niet geauthenticeerd). Alleen verify-full of sslrootcert=system verifieert. Geen connect_timeout/idle_timeout. De DB zit nu op een intern Docker-netwerk, dus exposure is laag. Fix: sslmode=verify-full documenteren en afdwingen voor elke niet-interne DSN.

SA-16 — Wachtwoorden op de commandline en plaintext in CREATE ROLE

Section titled “SA-16 — Wachtwoorden op de commandline en plaintext in CREATE ROLE”

Status: open · Bevestigd (argv) · Aannemelijk (pg-log)

  • scripts/provision-master-db.sh:22, 33-37PASSWORD="${1:-…}" gaat via psql -v pass= op argv (zichtbaar in ps en shell-history).
  • packages/ops/src/provision-tenant-db.cli.ts:28-29--password <value>.
  • packages/ops/src/provision-tenant-db.ts:93CREATE ROLE … PASSWORD '<plaintext>' via sql.unsafe; met log_statement = 'ddl' logt Postgres het wachtwoord. Het faalpad wist error.query (:177), het succespad niet.

Fix. Alleen env of stdin (--password-stdin bestaat al voor user-creatie); een vooraf berekende SCRAM-SHA-256-verifier sturen in plaats van plaintext.

SA-17 — Path traversal via S3-keys bij media-backup en -restore

Section titled “SA-17 — Path traversal via S3-keys bij media-backup en -restore”

Status: open · Bevestigd (code) · Aannemelijk (voorwaarde)

packages/ops/src/backup.ts:195-197 doet path.join(directory, "media", obj.key) en schrijft na mkdir -p; restore.ts:311 idem, met alleen een startsWith(\${tenantId}/`)-check (:298). Een key acme/../../../root/.ssh/authorized_keyspasseert de prefix en schrijft buiten de basismap, als root in de ops-runner. De app maakt zelf alleen${TENANT_ID}/${uuid}${ext}, dus dit vereist S3-schrijfrechten of een bucket-misconfiguratie. **Fix:** keys weigeren waarvan path.relative(base, dest)met..` begint.

SA-18 — Dev-compose bindt Postgres en MinIO op alle interfaces met default credentials

Section titled “SA-18 — Dev-compose bindt Postgres en MinIO op alle interfaces met default credentials”

Status: open · Bevestigd (alleen lokaal)

docker-compose.yml:8-13 (5432:5432, platform/platform) en :29-34 (9000, 9001, minioadmin/minioadmin); :50 zet anonieme download op de bucket. Iedereen op het LAN van de developer is compose-superuser of MinIO-root. Wordt midden zodra er een productiedump in wordt geladen; de werkwijze “dumps verifiëren in een wegwerpcontainer” is de juiste mitigatie. Fix: poorten prefixen met 127.0.0.1:.

SA-19 — Dashboard: één platte admin-rol, geen MFA

Section titled “SA-19 — Dashboard: één platte admin-rol, geen MFA”

Status: open · Bevestigd (bewuste keuze)

server/utils/auth.ts:14-17 — elk account in de master-DB is volledig admin; packages/db/src/master/auth-schema.ts heeft geen rolkolom; geen MFA-plugin. Eén gephisht wachtwoord geeft provisioning, Coolify, Cloudflare en tenant-secrets voor alle tenants. Sub-punt: packages/ops/src/create-admin-user.ts:143-144 hasht via --password-stdin elke lengte, buiten better-auth’s minimum van 8. Fix: twee-factor (better-auth twoFactor-plugin) voor het dashboard; minimale wachtwoordlengte ook in de CLI.

SA-20 — Dashboard: geen security headers

Section titled “SA-20 — Dashboard: geen security headers”

Status: open · Bevestigd

nuxt.config.ts heeft geen routeRules-headers; Nitro zet alleen op foutpagina’s CSP/X-Frame-Options. Geen HSTS, geen frame-ancestors, geen Referrer-Policy. Clickjacking is cross-site geblokkeerd door de Lax-cookie, maar niet vanaf same-site hosts (SA-07). Fix: routeRules: { "/**": { headers: {…} } } met minimaal HSTS, X-Frame-Options: DENY, Referrer-Policy, X-Content-Type-Options.

SA-21 — Dashboard: provisioning is niet concurrency-safe

Section titled “SA-21 — Dashboard: provisioning is niet concurrency-safe”

Status: open · Aannemelijk

server/utils/provision-tenant.ts:261-273 leest status en handelt zonder SELECT … FOR UPDATE of advisory lock; :360-371 maakt bij twee parallelle runs twee Coolify-apps waarvan één UUID wordt bewaard; :288-307 genereert per run een eigen wachtwoord. De knop is alleen client-side disabled (app/pages/tenants/[slug].vue:393). Gevolg: een wees-Coolify-app met DATABASE_URL, BETTER_AUTH_SECRET en PLATFORM_WEBHOOK_SECRET in zijn env. Fix: pg_advisory_xact_lock(hashtext(slug)) rond de hele run.

SA-22 — Placeholder-secrets uit .env.example passeren de validatie

Section titled “SA-22 — Placeholder-secrets uit .env.example passeren de validatie”

Status: open · Bevestigd

apps/master-dashboard/.env.example:4, 11 en apps/site-template/.env.example:8 gebruiken dezelfde string dev-only-change-me-to-a-long-random-string. server/utils/auth.ts:29-31 checkt alleen truthiness, webhooks.ts:104 alleen !== "". Een gekopieerde example geeft vervalsbare sessies en een publiek bekend webhooksecret (SA-08). secretsKey valideert wél op lengte. Fix: minimale lengte (32) en een denylist van bekende placeholders bij opstarten.

SA-23 — CSP blokkeert de inline onclick-confirm; verwijderen in het CMS is één klik

Section titled “SA-23 — CSP blokkeert de inline onclick-confirm; verwijderen in het CMS is één klik”

Status: open · Bevestigd (veiligheid, geen beveiliging)

src/lib/security-headers.ts:10 zet script-src 'self' zonder 'unsafe-hashes'; de onclick="return confirm(…)" in admin/media/index.astro:93, media/[id].astro:157, pages/index.astro:64, pages/[id].astro:209 en de diensten/nieuws/navigatie/referenties-pagina’s wordt geweigerd, waarna de klik doorvalt naar de form-POST. De enige bescherming tegen per ongeluk verwijderen ontbreekt, en het laat zien dat de CSP niet in QA wordt getest. Fix: een gehoist <script> met data-confirm, of 'unsafe-hashes' met hashes.

SA-24 — Contact-name mag CR/LF bevatten en gaat in het e-mail-Subject

Section titled “SA-24 — Contact-name mag CR/LF bevatten en gaat in het e-mail-Subject”

Status: open · Aannemelijk

packages/contract/src/forms.ts:9name: z.string().trim().min(1).max(120) laat inwendige \r\n toe; src/lib/mail.ts:112 zet dat in subject. Levering gaat via Resend’s JSON-API, dus klassieke SMTP-header-injectie is aan Resend om te neutraliseren; hier wordt niets geweigerd. Body is text, replyTo is een gevalideerd adres. Fix: .regex(/^[^\r\n]*$/) op name.

SA-25 — Outbox wordt in het request-pad gedraind

Section titled “SA-25 — Outbox wordt in het request-pad gedraind”

Status: open · Bevestigd

src/lib/platform/send-event.ts:59-64 roept na enqueuePlatformEvent direct drainWebhookOutbox aan vanuit actions/index.ts:129; tot OUTBOX_BATCH = 20 rijen × WEBHOOK_TIMEOUT_MS = 2000 geeft tot ~40 s vertraging op een contact-POST als het dashboard down is. De 15-secondenloop (outbox.ts:112-126) draait parallel zonder FOR UPDATE SKIP LOCKED, dus dezelfde rij kan twee keer worden gepost (de ontvanger dedupet op eventId). Fix: in het request alleen enqueuen, drainen alleen vanuit de loop, rijen claimen.

SA-26 — .dockerignore sluit backups/ niet uit

Section titled “SA-26 — .dockerignore sluit backups/ niet uit”

Status: open · Bevestigd

apps/site-template/Dockerfile:24 doet COPY . .; .dockerignore sluit **/.env* uit maar niet backups/, dat volgens .gitignore form_submissions bevat. Bij een lokale build landen die dumps in de build-stage en layer cache (niet in de runtime-image). Coolify bouwt vanuit een git-clone, dus daar speelt het niet. Fix: backups toevoegen aan .dockerignore.


Dependencies (pnpm audit, 8 september 2026)

Section titled “Dependencies (pnpm audit, 8 september 2026)”
Pakket Versie Ernst Via Relevantie
image-size 2.0.2 (geen gepatchte release gepubliceerd) high (DoS) apps/site-template direct Gemitigeerd in code, zie SA-02; bump zodra ≥2.0.3 verschijnt
sanitize-html 2.17.6 → ≥2.17.7 moderate (stored XSS via SVG SMIL) packages/blocks direct Niet misbruikbaar: allowlist bevat geen svg/animate; wel upgraden
fast-uri <3.1.6 high (SSRF/host confusion) @astrojs/check → yaml-language-server Dev-only (typecheck)
esbuild ≤0.24.2 moderate (dev-server CORS) better-authdrizzle-kit@esbuild-kit Dev-only

Overige kernversies zijn actueel: astro 7.2.0, better-auth 1.6.26, nuxt 4.5.2, drizzle-orm 0.45.2, postgres 3.4.9, marked 18.0.9, @aws-sdk/client-s3 3.1106.0. pnpm-workspace.yaml beperkt lifecycle-scripts tot esbuild (allowBuilds); geen postinstall-hooks in eigen packages.


Geen direct risico, wel het vermelden waard:

  • Repo-instellingen. Geen Dependabot-alerts (API geeft 404), geen CI, geen branch protection (free plan, privé repo). minimumReleaseAgeExclude in pnpm-workspace.yaml doet niets zonder minimumReleaseAge.
  • ops-runner image (scripts/ops-runner.Dockerfile): base op tag in plaats van digest, PGDG-sleutel zonder fingerprint-check, geen USER (root met /root/okhema en /root/ops-backups gemount).
  • Eenmalige credentials naar stdout (provision-tenant-db.cli.ts:59-60 volledige DSN, create-admin-user.ts:282, create-cms-user.ts:253, seed.ts:518-519); Docker’s json-file logdriver en cron-redirects bewaren die. Overweeg --json > file met 0600.
  • PII-retentie. form_submissions heeft geen retentie of opschoning; mail.ts:67, 86, 92, 99, 145 logt ontvangeradressen naar de containerlogs.
  • suspended ([slug].patch.ts:50-57) wijzigt alleen de rij; de Coolify-app blijft serveren en de webhook-ontvanger kijkt niet naar status.
  • UUID-routeparams ongevalideerd (domains/[id].delete.ts:15, [id]/verify.post.ts:15): niet-UUID geeft een Postgres-castfout en 500 in plaats van 404.
  • Slug-hergebruik (provision-tenant-db.ts:96-101, 137-139): een bestaande tenant_<slug>-database wordt hergebruikt en de nieuwe rol wordt eigenaar van oude data. Alleen na handmatig verwijderen van een tenant-rij.
  • Restore herstelt GRANT ALL ON SCHEMA public TO public (restore.ts:184), wat de PG15+ default ongedaan maakt. De database-level REVOKE uit provisioning beperkt de impact.
  • Route-cache thrash via querystrings: memoryCache({ max: 500 }) begrenst geheugen, maar /?x=<n> verdringt legitieme entries.
  • Platform-API (/api/platform/*) is bereikbaar op publieke én admin-host en revalidate heeft geen rate-limit; alleen met een gelekte key is dat een goedkope cache-purge-DoS.
  • /logout op GET (logout.astro:13-20): elke top-level navigatie logt uit.
  • Geen reserved-slug denylist (content.ts:48-55): admin, login, api worden geaccepteerd; statische routes winnen, dus alleen verwarring.
  • Upstream foutbodies (Coolify, Cloudflare) worden opgeslagen en getoond (provision.post.ts:90-100); admin-only en door Vue ge-escaped.
  • Docs bevatten het VPS-IP, DNS-details en Coolify-versie (Preview-domeinen); acceptabel zolang de repo privé is.
  • tenants/rb-media.json bevat een klant-e-mailadres; tenants/README.md benoemt de gitignore-optie al.

Deze controles zijn gelezen en correct bevonden; niet opnieuw auditen tenzij de betreffende code verandert.

Secrets en build. Geen secrets in tracked files of in de volledige git-historie (alleen placeholders en testwaarden). .env en backups in .gitignore, **/.env* in .dockerignore. Beide app-Dockerfiles zijn multi-stage met USER node; build-placeholders blijven in de build-stage, runtime is een verse FROM. astro:env met access: "secret" voor alle gevoelige waarden; runtimeConfig zonder public-blok en zonder hardcoded defaults.

Authenticatie en autorisatie. Alle 26 CMS-acties beginnen met requireCmsUser of requireCmsAdmin; alle dashboard-routes met requireAdminSession, server-side. Rol en banned worden per request uit de DB gelezen. Signup dicht (disableSignUp: true), admin-plugin endpoints achter hasPermission, editor-rol heeft geen statements. Cookies httpOnly, secure en __Secure--prefix onder https, SameSite=Lax, host-only, 7 dagen met dagelijkse refresh, DB-backed. Reset-flow: identiek antwoord en dummy-lookup voor onbekende adressen, token ~143 bits, 1 uur, single-use, replaceState en Referrer-Policy: no-referrer. baseURL, adminOrigin() en alle absolute URL’s komen uit SITE_DOMAIN, nooit uit de Host-header.

CSRF. isForbiddenCrossOriginRequest op elke niet-veilige request met form-achtige of ontbrekende Content-Type, op beide hosts en op /_actions; form-action 'self' in de CSP; better-auth valideert Origin/Referer tegen trustedOrigins. checkOrigin: false is een gelijkwaardige vervanging met x-forwarded-proto.

Webhooks en platform-API. HMAC-SHA256 over ${timestamp}.${body}, timingSafeEqual met lengtecheck, venster 300 s terug / 60 s vooruit, raw body geverifieerd vóór JSON.parse, per-tenant secret wint, eventId-dedupe via PK in een transactie, uniforme foutbodies. PLATFORM_API_KEY timing-safe, fail-closed bij ontbreken; health en stats lekken alleen versie, ok-vlag en tellingen. PLATFORM_WEBHOOK_URL alleen uit env (geen SSRF), backoff gecapt, secrets nooit in logs.

Secrets-encryptie. AES-256-GCM, verse 12-byte IV per call, auth tag geverifieerd, 32-byte sleutel afgedwongen, geversioneerd formaat. *_encrypted-kolommen worden nooit geselecteerd voor API-responses; tests asserten dat.

XSS en injectie. Eén set:html in de hele repo (Text.astro:33), gevoed door marked + sanitize-html met 11 tags, a beperkt tot href|title|rel|target, schemes http|https|mailto|tel, rel="noopener noreferrer" afgedwongen. Alle bewerkbare hrefs door safeHrefSchema (weigert javascript:, data:, //, /\, percent-encoded varianten), opnieuw gecheckt bij render. Themawaarden gestript van ;{}<> en commentaar-delimiters, als ge-escaped attribuut in custom properties zonder url()-sink. UnknownBlock dumpt geen JSON. validate.ts checkt een Set vóór registry-lookup (geen __proto__).

SQL en DB. Geen sql.raw; alleen Drizzle-builders en tagged templates. De enige dynamische DDL gebruikt quoteIdentifier (verdubbelt ") en quoteLiteral (E-string) op een slug-gevalideerde naam, met tests. Database per tenant, eigen rol, REVOKE ALL ON DATABASE … FROM PUBLIC. assertMasterDatabase voorkomt admins in een tenant-DB. Seed-guard eist ALLOW_DESTRUCTIVE_SEED=1, allowlisted db-naam, lokale host en inhoudschecks; geen realistische bypass. DSN’s alleen via redactPgUrl in logs.

Ops-scripts. run-command.ts gebruikt spawn(bin, args) zonder shell; wachtwoorden via PGPASSWORD-env; --yes op bootstrap/restore/user-creatie; backup weigert --tenantsite.tenant_id; restore weigert cluster-dumps en media in *_verify. Shell-scripts met set -euo pipefail, gequote variabelen, geen eval of curl | sh; scratch-verify-db.sh valideert tenant_id en asserteert de _verify-suffix vóór DROP DATABASE.

Route-cache. cachePublicRoute alleen in de 8 publieke pagina’s en sitemap.xml.ts, na 404- en redirect-checks; geen routeRules; alleen GET; responses met Set-Cookie worden niet opgeslagen; key bevat origin; security headers zitten in de opgeslagen entry. Let op: de provider negeert Vary: Cookie, dus een toekomstige publieke pagina met per-gebruiker content zou cross-user gecached worden.

Security headers (site-template). nosniff, X-Frame-Options: DENY, CSP met frame-ancestors 'none', base-uri 'none', object-src 'none', script-src 'self', Referrer-Policy, HSTS met includeSubDomains onder https; op elke response inclusief redirects, 403 en foutpagina’s.

Media. S3-key is ${TENANT_ID}/${uuid}${ext} (server-gegenereerd, geen clientbestandsnaam); 8 MB-cap in de applicatie; delete via de DB storageKey, geblokkeerd zolang media in gebruik is (FK + findMediaUsage); alle media-acties achter een CMS-sessie en in de audit-log; S3-credentials bereiken de client nooit; geen publieke ACL’s op puts.


  1. Gedaan (8 september 2026): SA-02 (sniff + disableTypes; upgrade niet beschikbaar), SA-01 (allowedDomains + client-IP-header), SA-03 (safeRedirectPath op origin).
  2. Deze week: SA-04, SA-05, SA-06, SA-12, SA-22 — allemaal kleine wijzigingen in bestaande bestanden.
  3. Volgende dashboard-taak: SA-07, SA-08, SA-09, SA-20, SA-21.
  4. Volgende ops-taak: SA-10, SA-11, SA-16, SA-17, SA-26.
  5. Bij gelegenheid: de overige lage punten en de info-lijst.

Herhaal pnpm audit na elke dependency-bump en zet Dependabot-alerts aan op de GitHub-repo.