In het kort
- Een Content-Security-Policy (CSP) die begint met
default-src 'self' en verder niets toelaat, breekt elke echte WordPress-site met marketing- en CRM-tooling binnen één pageview.
- Wij draaien op cobytes.com een CSP die wél werkt mét Google Tag Manager, reCAPTCHA, Cookiebot, Leadinfo, LinkedIn, Pipedrive en YouTube — en die toch
object-src 'none', frame-ancestors 'self' en HSTS-preload afdwingt.
- Het interessante deel: onze
script-src draait inmiddels volledig strict — 'nonce-…' 'strict-dynamic', zónder 'unsafe-inline' en zónder 'unsafe-eval'. Veel “veilige” WordPress-sites krijgen dat niet voor elkaar.
- We laten zien hoe we daar kwamen: via report-only, nonces en
strict-dynamic — zonder de site voor bezoekers te slopen.
- Resultaat: een internet.nl-score van 100% en een plek in hun Hall of Fame. Onderaan: het stappenplan dat wij bij elke klant aanhouden.
Het probleem met de meeste CSP-uitleg
Zoek op “Content-Security-Policy WordPress” en je vindt tien keer dezelfde policy:
Content-Security-Policy: default-src 'self';
Netjes in theorie. In de praktijk dooft daarmee je Google Tag Manager, je cookiebanner, je reCAPTCHA, je ingesloten YouTube-video en je leadtracking — allemaal tegelijk. Het resultaat: de gemiddelde sitebeheerder zet de header na een halve dag debuggen weer uit. Dat is geen security, dat is een placebo die je weer weggooit.
Een CSP is pas waardevol als hij overleeft op een échte site: een site met marketing-pixels, een CRM-integratie en een editor die WordPress-redacteuren elke dag gebruiken. Daarom delen we hier niet de schoolboek-versie, maar de policy die op dit moment voor jou werd meegestuurd toen je deze pagina opende.
Onze echte productie-CSP
Dit is — ingekort op de lange allowlists — de policy die cobytes.com vandaag afdwingt:
Content-Security-Policy:
default-src 'self' https://*.cobytes.com;
base-uri 'self' https://*.cobytes.com;
object-src 'none';
upgrade-insecure-requests;
script-src 'nonce-<per-request-nonce>' 'strict-dynamic';
style-src 'self' 'unsafe-inline' https://use.typekit.net https://fonts.googleapis.com;
img-src 'self' data: ...(GTM, Analytics, Google Ads, LinkedIn, Gravatar)...;
connect-src 'self' ...(Analytics, GTM, Cookiebot, Leadinfo, intodns.ai)...;
font-src 'self' data: https://use.typekit.net https://fonts.gstatic.com;
media-src 'self' https://www.youtube.com https://www.youtube-nocookie.com;
frame-src 'self' blob:
https://www.google.com/recaptcha/ https://consentcdn.cookiebot.com
https://www.googletagmanager.com https://api.wp-rocket.me
https://cobytes.pipedrive.com https://www.youtube.com;
frame-ancestors 'self';
form-action 'self' https://*.cobytes.com;
worker-src 'self' blob:;
report-to csp-endpoint;
Wat hier goed staat, en waarom het ertoe doet:
script-src 'nonce-…' 'strict-dynamic' — het hart van een moderne CSP. Geen 'unsafe-inline', geen 'unsafe-eval', geen eindeloze allowlist van Google- en LinkedIn-domeinen. Daarover zo meer.
object-src 'none' — blokkeert Flash/<object>-injectie. Geen reden om dit ooit open te zetten.
base-uri 'self' — voorkomt dat een geïnjecteerde <base>-tag al je relatieve URL’s naar een aanvallersdomein omleidt. Wordt bijna altijd vergeten.
frame-ancestors 'self' — clickjacking-bescherming, de moderne vervanger van X-Frame-Options.
form-action 'self' — een geïnjecteerd formulier kan niet stilletjes naar een extern domein posten.
upgrade-insecure-requests — restjes http://-links worden automatisch over HTTPS gehaald.
Hoe we unsafe-inline én unsafe-eval eruit kregen
Hier zit het echte verhaal. De meeste WordPress-sites die “een CSP hebben” draaien een script-src met 'unsafe-inline' 'unsafe-eval' erin — en dat ondermijnt precies de bescherming waarvoor een CSP bedoeld is. Met 'unsafe-inline' mag elk inline <script> draaien, ook een geïnjecteerd script. Dan kun je net zo goed geen CSP hebben.
Wij hadden die twee er aanvankelijk ook in staan — niet uit luiheid, maar omdat een volwassen WordPress-stack vol zit met tooling (GTM, Cookiebot, formulieren, performance-plugins) die inline scripts injecteert. De vraag was niet *of* het erin stond, maar *of we een plan hadden om het eruit te halen*. Dat plan draaide in twee stappen:
1. Nonces in plaats van 'unsafe-inline'. Elk legitiem script krijgt per request een uniek, onvoorspelbaar token (de *nonce*). Inline scripts zonder dat token draaien niet meer. Met 'strict-dynamic' erbij propageert dat vertrouwen: een al-vertrouwd script mag zelf scripts laden, zodat je niet langer elk los Google-/LinkedIn-domein hoeft te onderhouden. 2. 'unsafe-eval' testen in plaats van aannemen. De aanname was dat we 'unsafe-eval' nodig hadden voor Gravity Forms en de “Delay JavaScript” van WP Rocket. We hebben het er in report-only uitgehaald en alles getest: GTM, Cookiebot, jQuery, Gravity Forms — het bleek niet nodig. Nul violations. Dus ging het eruit.
De sleutel om dit te kunnen doen zonder iets te slopen, is report-only. Naast de enforce-policy stuurden we maandenlang een tweede, strengere policy mee als Content-Security-Policy-Report-Only. Die blokkeert niets — hij rapporteert alleen wat eronder zou sneuvelen, naar een report-to-endpoint. Zo zagen we exact wat er zou breken vóórdat één bezoeker er last van had. Pas toen de ruis nul was, zetten we de strenge policy om naar enforce.
Het eerlijke restant: in style-src staat nog 'unsafe-inline'. Dat komt doordat page builders inline style=""-attributen genereren, en dat is in WordPress niet realistisch weg te krijgen. Voor *scripts* is 'unsafe-inline' een groot risico; voor *styles* is het effect verwaarloosbaar. We zijn er liever eerlijk over dan dat we een perfecte score faken.
frame-src, blob: en de Gutenberg-realiteit
Eén regel die vaak voor verwarring zorgt: frame-src 'self' blob: .... Die blob: staat er niet voor de sier — de Gutenberg-editor van WordPress laadt previews via blob-URL’s. Zonder blob: in frame-src (en worker-src) kan een redacteur geen blok-preview meer zien en lijkt de editor “kapot”. Dat is precies het soort regressie dat je niet op je productiesite wilt ontdekken.
Verder zie je in frame-src de echte gereedschapskist van een werkende B2B-site: api.wp-rocket.me (cache-plugin), cobytes.pipedrive.com (CRM), reCAPTCHA, Cookiebot en YouTube. Elk daarvan is een bewuste, gedocumenteerde uitzondering — geen wildcard.
Wat wij elke klant adviseren
1. Begin nooit in enforce. Zet je gewenste policy eerst als Content-Security-Policy-Report-Only en verzamel rapporten. 2. Beheer de header buiten WordPress. Wij zetten ‘m in nginx (via Ansible), niet in een plugin — dan overleeft hij plugin-updates en caching. 3. Inventariseer je third parties eerlijk. GTM, cookiebanner, CRM, ads-pixels, fonts: elk domein is een regel. Onbekende domeinen in je rapport = onderzoeken, niet blind toevoegen. 4. Werk toe naar nonce + strict-dynamic. Dat is de uitweg uit unsafe-inline. En test unsafe-eval apart — vaak blijkt het, net als bij ons, niet nodig. 5. Zet object-src 'none', base-uri 'self', frame-ancestors 'self' en form-action 'self' er meteen in. Die breken zelden iets en sluiten hele aanvalsklassen uit.
Veelgestelde vragen
Breekt een CSP mijn Google Tag Manager of cookiebanner? Ja, als je GTM/Cookiebot niet correct toelaat. Met een nonce-based policy en 'strict-dynamic' werkt het juist soepel — maar je verzamelt eerst report-only-data om te zien wat je mist.
Heb ik unsafe-eval echt nodig op WordPress? Vaak minder dan je denkt. Wij gingen er ook van uit, en het bleek na testen niet nodig — zelfs niet met Gravity Forms en een caching/performance-plugin. Haal het in report-only weg en meet het, in plaats van het uit voorzorg te laten staan.
Plugin of nginx voor mijn CSP-header? Buiten WordPress (nginx/webserver). Een plugin-header sneuvelt bij caching en plugin-conflicten, en is lastiger consistent te houden over meerdere sites.
Loopt jouw CSP nog op default-src 'self', met unsafe-inline erin, of helemaal niet? Bij security en managed hosting van Cobytes richten we dit in als onderdeel van de hardening — report-only eerst, dan enforce. Meer context: de 7 security headers die er echt toe doen en onze pillar over website hardening.