Blog / Webbeveiliging · PHP · Laravel

Content Security Policy headers correct instellen voor PHP-applicaties

P
Pim vd Molen

Sommige beveiligingsmaatregelen vallen op: je ziet ze in audits, pentestrapporten en beveiligingscheckers. Content Security Policy headers zijn zo'n maatregel die iedereen kent maar die in de praktijk zelden goed geconfigureerd is. Op de meeste PHP-projecten die ik tegenkom staat óf geen CSP ingesteld, óf een die zo permissief is dat hij niets tegenhoudt. Dat is zonde, want een correcte CSP voorkomt een hele categorie aanvallen die door andere maatregelen gewoon doorheen glippen.

Wat een CSP eigenlijk doet en waarom de standaardinstelling faalt

Een Content Security Policy vertelt de browser welke bronnen hij mag laden. Scripts, stylesheets, afbeeldingen, fonts, frames: voor elke categorie kun je precies opgeven van welke domeinen content geladen mag worden. De browser hanteert die regels strikt en blokkeert alles wat er buiten valt, zonder dat jouw servercode daarvoor iets hoeft te doen.

Het probleem met de meeste implementaties is dat developers starten met unsafe-inline en unsafe-eval om te voorkomen dat hun eigen scripts breken. Zodra je die toestaat, is de bescherming tegen XSS grotendeels weg. Een aanvaller die een stuk inline JavaScript in je pagina kan injecteren, heeft dan vrij spel. De reden dat developers hiernaar grijpen is begrijpelijk: zonder die toestemmingen breekt vrijwel elk project dat jQuery, Google Analytics of een willekeurige UI-library draait. De uitdaging is dus niet de header zelf instellen, maar doen zonder die uitzonderingen.

Nonces zijn hier de uitweg. Per request genereer je een willekeurige token die je zowel in de header als in je scripttags zet. De browser accepteert dan alleen scripts met die nonce, ook als unsafe-inline uitstaat. Elk request krijgt een andere nonce, dus een aanvaller die een script injecteert zonder die token, wordt geblokkeerd.

Nonces genereren en doorgeven in een Laravel-applicatie

In Laravel zet ik dit op via een middleware die voor elk request een nonce aanmaakt en die doorgeeft aan zowel de response header als de Blade-views. De middleware ziet er in de kern zo uit:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;

class ContentSecurityPolicy
{
    public function handle(Request $request, Closure $next): mixed
    {
        $nonce = base64_encode(random_bytes(16));

        View::share('cspNonce', $nonce);

        $response = $next($request);

        $policy = implode('; ', [
            "default-src 'self'",
            "script-src 'self' 'nonce-{$nonce}'",
            "style-src 'self' 'nonce-{$nonce}' https://fonts.googleapis.com",
            "font-src 'self' https://fonts.gstatic.com",
            "img-src 'self' data: https:",
            "connect-src 'self'",
            "frame-ancestors 'none'",
            "base-uri 'self'",
            "form-action 'self'",
        ]);

        $response->headers->set('Content-Security-Policy', $policy);

        return $response;
    }
}

Daarna registreer je deze middleware in bootstrap/app.php of in de web-groep in je kernel, afhankelijk van welke Laravel-versie je draait. In je Blade-templates voeg je de nonce toe aan elk scripttag en style-element dat inline staat:

<script nonce="{{ $cspNonce }}">
    // jouw inline JavaScript
</script>

Externe scripts die je zelf host hoeven geen nonce: die vallen al onder 'self'. Scripts van een CDN voeg je toe aan de script-src directive als volledig domein, maar wees daar spaarzaam mee. Elk extern domein dat je toestaat vergroot je aanvalsoppervlak.

De header testen zonder je applicatie te breken

Blindelings een strenge CSP in productie zetten is een recept voor kapotte pagina's en boze klanten. De browser biedt hiervoor een vangnet: de Content-Security-Policy-Report-Only header. Die werkt precies hetzelfde als de echte CSP, maar blokkeert niets. In plaats daarvan stuurt de browser een rapport naar een endpoint dat je opgeeft wanneer iets geblokkeerd zou worden.

$response->headers->set(
    'Content-Security-Policy-Report-Only',
    $policy . "; report-uri /csp-report"
);

Dat rapportage-endpoint bouw je als een simpele route die de JSON-payload ontvangt en wegschrijft naar je log:

Route::post('/csp-report', function (Request $request) {
    $report = $request->getContent();
    \Log::channel('csp')->warning('CSP violation', [
        'report' => json_decode($report, true),
    ]);
    return response()->noContent();
})->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Let op de CSRF-uitzondering: de browser stuurt CSP-rapporten als een gewone POST-request zonder CSRF-token, dus zonder die uitzondering ziet Laravel het als een ongeldig verzoek. Na een paar dagen draaien in report-only modus weet je precies welke bronnen je nog aan je policy moet toevoegen voordat je overschakelt naar de echte header.

Valkuilen bij third-party scripts en inline styles

Google Tag Manager is de meest voorkomende boosdoener. GTM injecteert zelf scripts in de pagina op een manier die botst met een strikte CSP. Je kunt het domein van GTM toestaan in je script-src, maar GTM laadt vervolgens scripts van tientallen andere domeinen die je marketingteam heeft ingesteld. Daarvoor bestaat geen nette technische oplossing: je moet ofwel al die domeinen toestaan, ofwel GTM server-side draaien zodat alles via je eigen domein loopt.

Inline styles zijn een vergelijkbaar pijnpunt. Tailwind CSS genereert geen inline styles als je het correct via een CSS-bestand inlaadt, maar veel JavaScript-bibliotheken passen inline styles aan via JavaScript. Dat kan nog steeds via nonces als het via een scripttag gaat, maar sommige libraries schrijven styles direct via de style-attribute op DOM-elementen. Die worden geblokkeerd tenzij je unsafe-inline toestaat voor style-src, wat je eigenlijk wil vermijden.

// Minder veilig, maar soms onvermijdelijk:
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",

// Beter: hashes gebruiken voor bekende inline styles
"style-src 'self' 'sha256-abc123...' https://fonts.googleapis.com",

Hashes werken voor statische inline styles die nooit veranderen. Je berekent de SHA-256 hash van de exacte string, zet die in je policy, en de browser accepteert alleen dat specifieke blok. Voor dynamische styles is dit niet haalbaar, maar voor een paar vaste CSS-regels die een library injecteert werkt het prima. Je berekent de hash eenvoudig via de commandoregel: echo -n 'jouw css hier' | openssl dgst -sha256 -binary | base64.

De frame-ancestors 'none' directive verdient ook aparte aandacht. Die vervangt de X-Frame-Options: DENY header en voorkomt dat jouw pagina in een iframe van een ander domein geladen wordt, wat clickjacking-aanvallen tegenhoudt. Zorg dat je die altijd meeneemt, ook als de rest van je CSP nog niet perfect is. Datzelfde geldt voor base-uri 'self': die voorkomt dat een aanvaller via een geïnjecteerd <base>-element alle relatieve URL's op je pagina kan omleiden naar een domein dat hij beheert.

Na maanden van experimenteren met CSP op projecten van verschillende groottes merk ik dat de grootste winst zit in het consequent doorvoeren, niet in het perfectioneren van elke directive. Een policy die vijftien minuten werk heeft gekost en unsafe-inline vermijdt voor scripts, is al een stuk beter dan geen policy of een policy die zo soepel is dat hij alleen een vals gevoel van veiligheid geeft. Begin met report-only, los de meldingen een voor een op, en schakel daarna over. Dat is de aanpak die ik bij elk nieuw project hanteer, en hij werkt.

// VEELGESTELDE VRAGEN
Wat is een Content Security Policy en waarom heb ik het nodig?
Een Content Security Policy (CSP) is een beveiligingslaag die bepaalt welke bronnen een browser mag laden op jouw website. Het beschermt je applicatie tegen aanvallen zoals Cross-Site Scripting (XSS) en data-injectie. Zonder CSP kunnen kwaadaardige scripts ongemerkt worden uitgevoerd in de browser van je bezoekers.
Hoe test ik of mijn Content Security Policy correct werkt?
Je kunt je CSP testen via de browser-ontwikkelaarstools (tabblad Console en Network), waar overtredingen direct worden gemeld. Tools zoals securityheaders.com of de CSP Evaluator van Google geven een gedetailleerde analyse van je headers. Begin met de 'Content-Security-Policy-Report-Only' modus om problemen te detecteren zonder je site te breken.
Wat doe ik als mijn inline JavaScript of CSS geblokkeerd wordt door de CSP?
Inline scripts en stijlen worden standaard geblokkeerd omdat ze een veiligheidsrisico vormen. De aanbevolen oplossing is om externe bestanden te gebruiken en hun domeinen toe te staan in je CSP-header. Als inline code echt noodzakelijk is, kun je gebruikmaken van een 'nonce' of 'hash' om specifieke blokken veilig toe te staan.
Welke CSP-directives zijn het belangrijkst om in te stellen?
De meest essentiële directives zijn 'default-src' (als fallback voor alle bronnen), 'script-src' (voor JavaScript), 'style-src' (voor CSS) en 'img-src' (voor afbeeldingen). Daarnaast is 'frame-ancestors' belangrijk om clickjacking te voorkomen. Stel altijd minimaal 'default-src' in als basis en verfijn daarna per resourcetype.