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.
Samenvatting
Section titled “Samenvatting”- 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-sizein 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 gepatchteimage-sizebestaat.
Scope en methode
Section titled “Scope en methode”| 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 opauth:${pathname}:${ip}src/actions/index.ts:78— contactformulier 5 per uur, gekeyed opcontact:${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.
security.allowedDomainsconfigureren met de tenant-hosts, zodat Astro de proxy-header vertrouwt. Alternatief: het client-IP zelf uit een header lezen die Traefik zet en overschrijft.- better-auth
advanced.ipAddress.trustedProxies(Traefik plus Cloudflare-ranges) ofipAddressHeaders: ["cf-connecting-ip"]. src/lib/rate-limit.ts:9— deMapwordt 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.
image-sizenaar>=2.0.3(apps/site-template/package.json).- Het gesniffede
typeuitimageSize()als bron van waarheid: alleenjpg,png,webp,gif;ContentTypeen extensie daaruit afleiden, niet uitfile.type. 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.
Midden
Section titled “Midden”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-77—cssColor,cssLength,fontHeadingzijnz.string().min(1)zonder.max()of formaat-regex.packages/contract/src/blocks.ts—heading,body,label,caption,quote,attribution,role,titleonbegrensd.packages/contract/src/events.ts:46-51—site.errormessageencontext(z.record(z.string(), z.unknown())) onbegrensd; opgeslagen inactivity.payload.apps/master-dashboard/server/api/webhooks/site.post.ts:13—readRawBodyvóó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.
SA-13 — Markdown laat protocol-relatieve links toe
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-37—PASSWORD="${1:-…}"gaat viapsql -v pass=op argv (zichtbaar inpsen shell-history).packages/ops/src/provision-tenant-db.cli.ts:28-29—--password <value>.packages/ops/src/provision-tenant-db.ts:93—CREATE ROLE … PASSWORD '<plaintext>'viasql.unsafe; metlog_statement = 'ddl'logt Postgres het wachtwoord. Het faalpad wisterror.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:9 — name: 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-auth → drizzle-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).
minimumReleaseAgeExcludeinpnpm-workspace.yamldoet niets zonderminimumReleaseAge. - ops-runner image (
scripts/ops-runner.Dockerfile): base op tag in plaats van digest, PGDG-sleutel zonder fingerprint-check, geenUSER(root met/root/okhemaen/root/ops-backupsgemount). - Eenmalige credentials naar stdout (
provision-tenant-db.cli.ts:59-60volledige 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 > filemet 0600. - PII-retentie.
form_submissionsheeft geen retentie of opschoning;mail.ts:67, 86, 92, 99, 145logt 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 bestaandetenant_<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 enrevalidateheeft geen rate-limit; alleen met een gelekte key is dat een goedkope cache-purge-DoS. /logoutop GET (logout.astro:13-20): elke top-level navigatie logt uit.- Geen reserved-slug denylist (
content.ts:48-55):admin,login,apiworden 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.jsonbevat een klant-e-mailadres;tenants/README.mdbenoemt de gitignore-optie al.
Gecontroleerd en in orde
Section titled “Gecontroleerd en in orde”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 --tenant ≠ site.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.
Aanbevolen volgorde
Section titled “Aanbevolen volgorde”- Gedaan (8 september 2026): SA-02 (sniff +
disableTypes; upgrade niet beschikbaar), SA-01 (allowedDomains+ client-IP-header), SA-03 (safeRedirectPathop origin). - Deze week: SA-04, SA-05, SA-06, SA-12, SA-22 — allemaal kleine wijzigingen in bestaande bestanden.
- Volgende dashboard-taak: SA-07, SA-08, SA-09, SA-20, SA-21.
- Volgende ops-taak: SA-10, SA-11, SA-16, SA-17, SA-26.
- 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.