Naar de inhoud
CodeAuditAgent
Alle artikelen

Security headers uitgelegd: CSP, HSTS en de rest

Een praktische gids voor CSP met nonces en strict-dynamic, HSTS en preload, frame-ancestors, Referrer-Policy, Permissions-Policy en COOP, met Next.js-config.

· 7 min. leestijd · Lina Source LLC

Security headers zijn instructies die je server aan de browser geeft: voer alleen deze scripts uit, praat alleen via HTTPS met me, laat andere sites deze pagina niet in een frame zetten. Ze repareren geen bugs in je code, maar ze beperken wat een aanvaller ermee kan. Een cross-site scripting-gat achter een strikte Content Security Policy is een veel kleiner probleem dan hetzelfde gat zonder.

De meeste van deze headers zijn één regel configuratie. De uitzondering is CSP, die planning vraagt. Deze gids behandelt wat elke header doet, verstandige waarden voor een doorsnee webapp, en hoe je ze uitrolt zonder productie te breken.

Content-Security-Policy

CSP vertelt de browser welke bronnen van scripts, stijlen, afbeeldingen, frames en verbindingen zijn toegestaan. Haar belangrijkste taak is voorkomen dat geïnjecteerde scripts draaien. Een allowlist van domeinen klinkt als de voor de hand liggende aanpak, maar heeft een lange geschiedenis van omzeilingen: elke toegestane CDN die gebruikerscontent of oude bibliotheekversies host, kan worden gebruikt om script onder controle van de aanvaller te laden.

Nonces en strict-dynamic

De aanpak die standhoudt is een beleid op basis van nonces. De server genereert per response een willekeurige waarde, zet die in de CSP-header en voegt hem als nonce-attribuut toe aan elke scripttag die hij rendert. Geïnjecteerde scripttags kennen de nonce niet en worden geblokkeerd. Met 'strict-dynamic' erbij mogen scripts die door een vertrouwd script zijn geladen zelf verdere scripts laden, zodat bundlers en tagmanagers blijven werken zonder dat je elk domein hoeft op te sommen.

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic';
  style-src 'self' 'nonce-4AEemGb0xJptoIGFP3Nd';
  img-src 'self' data: https:;
  connect-src 'self';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  upgrade-insecure-requests

De header wordt op één regel verstuurd; hier is hij voor de leesbaarheid afgebroken. Een paar directives in dat beleid doen meer dan ze lijken. object-src 'none' blokkeert verouderde plug-incontent. base-uri 'none' voorkomt dat een geïnjecteerde base-tag relatieve script-URL’s omleidt. form-action 'self' verhindert dat geïnjecteerde formulieren credentials elders posten. De nonce moet onvoorspelbaar zijn en bij elke response nieuw, wat betekent dat pagina’s die hem gebruiken niet uit een statische cache kunnen komen.

Rol uit met Report-Only

Een strikte CSP die je blind uitrolt, breekt gegarandeerd iets: een analytics-snippet, een inline event handler, een widget van derden. Stuur het beleid eerst als Content-Security-Policy-Report-Only. De browser dwingt dan niets af, maar rapporteert elke overtreding aan het endpoint uit de report-to- of report-uri-directive. Verzamel een tijdje rapportages, herstel of sta toe wat legitiem is, en wijzig dan de headernaam om af te dwingen. Houd het rapporteren daarna aan, want nieuwe overtredingen zijn ofwel regressies ofwel aanvallen. Verwacht ruis in de rapportages van browserextensies, die hun eigen scripts in pagina’s injecteren; filter die op bron uit voordat je bepaalt wat je toestaat.

Strict-Transport-Security

HSTS vertelt de browser om je domein gedurende een bepaalde periode via HTTPS te benaderen, ook als een gebruiker http:// typt of op een oude link klikt. Dat sluit het venster waarin een netwerkaanvaller het eerste gewone HTTP-request kon onderscheppen en de redirect naar HTTPS kon strippen. Browsers honoreren de header alleen als hij via HTTPS binnenkomt, en ze laten gebruikers ook niet door certificaatfouten heen klikken op een HSTS-domein, wat precies de bedoeling is maar ook het risico als een certificaat verloopt.

Begin met een korte max-age, bijvoorbeeld een dag, bevestig dat er niets breekt en verhoog hem daarna naar één of twee jaar. includeSubDomains breidt de regel uit naar elk subdomein, dus controleer eerst of ze allemaal geldige HTTPS serveren, inclusief oude marketingsites en interne tools op hetzelfde domein.

De preload-directive, samen met een aanmelding bij de preload-lijst van de browsers, bakt je domein in browsers in, zodat zelfs het allereerste bezoek via HTTPS gaat. Het vereist een max-age van minstens één jaar en includeSubDomains. Beschouw het als een deur die één kant op gaat: verwijdering van de lijst kan, maar duurt lang voordat het gebruikers bereikt, en elk subdomein dat geen HTTPS aankan, is ondertussen onbereikbaar.

De headers van één regel

  • X-Content-Type-Options: nosniff. Voorkomt dat browsers een ander contenttype raden dan jij hebt opgegeven, waardoor een geüpload bestand dat als tekst wordt geserveerd niet als script kan worden uitgevoerd.
  • frame-ancestors (in de CSP) en X-Frame-Options: DENY. Bepalen wie je pagina’s in een frame mag insluiten, de verdediging tegen clickjacking. Moderne browsers gebruiken frame-ancestors en negeren X-Frame-Options als beide zijn gezet; houd X-Frame-Options aan voor oudere clients. Gebruik 'self' of SAMEORIGIN als je je eigen pagina’s framet.
  • Referrer-Policy: strict-origin-when-cross-origin. Stuurt alleen je origin naar andere sites, niet het volledige pad en de querystring. Zo lekken tokens en ID’s in URL’s niet naar derden. Gebruik no-referrer voor bijzonder gevoelige pagina’s.
  • Permissions-Policy. Zet browserfuncties uit die je niet gebruikt, zoals camera=(), microphone=(), geolocation=() en payment=(), zodat geïnjecteerde of ingesloten code ze niet kan opvragen.
  • Cross-Origin-Opener-Policy: same-origin. Plaatst je pagina in een eigen browsing context group, zodat een venster dat door een andere site is geopend geen referentie naar je pagina kan vasthouden. Gebruik same-origin-allow-popups als je op OAuth- of betaalpop-ups leunt.
  • Cross-Origin-Resource-Policy: same-origin of same-site. Vertelt browsers dat andere origins je responses niet als afbeelding, script of andere subresource mogen laden. Gebruik same-site als je assets vanaf een zusterssubdomein serveert, en cross-origin voor assets die elders ingesloten horen te worden.

X-XSS-Protection kun je laten vallen. Het filter dat hij aanstuurde is uit moderne browsers verwijderd, en CSP is de vervanger. Dringt een scanner erop aan, zet hem dan op 0. Vermijd verder headers die alleen informatie voor een aanvaller toevoegen: X-Powered-By en uitgebreide Server-banners verklappen je framework en versie voor niets. Zet in Next.js poweredByHeader: false in de configuratie om de eerste te verwijderen.

Een Next.js-configuratie

Statische headers horen in next.config.ts, waar de functie headers() ze op elke route toepast. De waarden hieronder zijn een verstandige standaard voor een app die zichzelf niet in frames insluit, geen camera of locatie gebruikt en zijn eigen assets serveert. Pas ze aan op wat je app werkelijk doet in plaats van ze blind over te nemen.

// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=(), payment=()",
  },
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  { key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

export default nextConfig;

De CSP heeft per request een nieuwe nonce nodig, dus die wordt in plaats daarvan in middleware gezet. Next.js leest de nonce uit de Content-Security-Policy-header van het request en past hem tijdens het renderen toe op de eigen scripts van het framework. In een servercomponent lees je hem met headers().get("x-nonce") voor je eigen scripttags.

// middleware.ts (in Next.js 16 heet het bestand proxy.ts en de functie proxy)
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    "upgrade-insecure-requests",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Pagina’s die met een nonce worden gerenderd, moeten dynamisch worden gerenderd, omdat een statische pagina één nonce voor elke bezoeker zou hergebruiken. Wijzig tijdens de uitrol de headernaam op beide plekken naar Content-Security-Policy-Report-Only en voeg een rapportage-endpoint toe. In ontwikkeling heeft React mogelijk 'unsafe-eval' in script-src nodig voor zijn debugfuncties; voeg dat alleen toe als NODE_ENV development is.

Hoe je het controleert

  • Draai curl -sI https://jouw-domein.example/ tegen de homepage, een dynamische pagina, een API-route en een statische asset, en lees bij elk de headers. Headers die alleen op HTML-pagina’s staan zijn een veelvoorkomend gat.
  • Open de developer tools van de browser. CSP-overtredingen verschijnen in de console met de directive en de geblokkeerde resource, en het tabblad Network toont de headers die daadwerkelijk zijn geserveerd.
  • Controleer zowel achter je CDN of reverse proxy als bij de origin. Proxy’s strippen, dupliceren of overschrijven headers soms, en twee conflicterende CSP-headers worden allebei afgedwongen, waardoor het effectieve beleid strikter is dan elk van beide.
  • Gebruik een openbare checker zoals de Mozilla HTTP Observatory voor een snelle tweede mening, en Google’s CSP Evaluator om zwakke CSP-directives te vinden.
  • Voeg een test toe in CI die belangrijke routes opvraagt en controleert dat de headers aanwezig zijn, zodat een refactor van de configuratie ze niet stilletjes kan laten vallen.

Headers zijn configuratie, en juist daarom lopen ze uit de pas: een nieuwe route handler die zijn eigen response opbouwt, een proxyregel die de standaarden overschrijft, een CSP die met 'unsafe-inline' is versoepeld om een release los te trekken. Review ze als code. CodeAuditAgent leest de configuratie in een openbare repository of een geplakt codefragment samen met de rest van de code, en rapporteert ontbrekende of verzwakte headers met ernst, de geciteerde configuratie en een voorgestelde fix.

Een redelijke volgorde van werken: vandaag de headers van één regel, deze week HSTS met een korte max-age, en een CSP op basis van nonces in Report-Only-modus zodra je de rapportages kunt verzamelen.