Content Security Policy uden panik (fra første header til skarp enforce)

Content Security Policy uden panik (fra første header til skarp enforce)

Har du nogensinde slået en Content Security Policy til og set hele din side dø i én lang rød fejlliste i console?

Mit lille eksperiment der gik galt (og hvad jeg gjorde bagefter)

Jeg ville egentlig bare gøre en af mine små hobby-sider en smule mere sikker. Du ved, lige smide en Content-Security-Policy på, så jeg kunne sige til mig selv: “Nu beskytter jeg mod XSS.”

Så jeg googlede mig til en tilfældig content security policy guide, kopierede en flot header, smed den i min reverse proxy og reloadede siden.

Resultat: tom skærm, 20+ fejl i Chrome DevTools, og et par brugere der skrev “din side virker ikke”. Fair nok, det gjorde den heller ikke.

I stedet for at opgive CSP besluttede jeg at tage den omvendte vej: starte helt forsigtigt med Content-Security-Policy-Report-Only, samle data, justere og først til sidst skifte til rigtig enforce.

Det er den rejse jeg vil gå igennem her: fra første report-only header til en skarp policy, uden at knække din produktion.

Hvad CSP faktisk redder dig fra (og hvad du ikke skal forvente)

CSP er i bund og grund en whitelist over hvad din side må hente og køre.

De to vigtigste ting du får hjælp til:

  • XSS-angreb (Cross-Site Scripting) hvor nogen prøver at køre ondsindet JavaScript i din side.
  • Uventede ressourcer som bliver loadet fra mærkelige domæner, fordi du eller en dependency har sat noget skørt ind.

Helt konkret kan du med CSP sige ting som:

  • “Scripts må kun komme fra mit eget domæne og Google Analytics.”
  • “Styles må ikke indeholde inline CSS, medmindre jeg selv har godkendt det med en hash.”
  • “Billeder må gerne komme fra mit CDN og data-URLs, men ikke fra tilfældige tredjepartsdomæner.”

Men CSP redder dig ikke fra alt. Den gør ikke:

  • Stopper dårlige SQL-queries eller dårligt backend-design.
  • Fikser insecure cookies eller session-håndtering. (Der har du fx helt andre værktøjer).
  • Beskytter dig mod en angriber der allerede har admin-adgang til din kodebase.

Jeg tænker på CSP som et ekstra sikkerhedslag i kategorien it sikkerhed for udviklere. Ikke magi, men noget der gør det markant sværere at få ondsindet script igennem.

Start blødt: CSP i Report-Only uden at ødelægge noget

Første skridt er at slå CSP til på en måde, hvor den kun rapporterer problemer, men ikke blokerer noget. Det er her Content-Security-Policy-Report-Only kommer ind.

Hvis du fx har et klassisk Node/Express setup, kan en minimal report-only header se sådan her:

// Express eksempel
app.use((req, res, next) => {
  res.setHeader(
    "Content-Security-Policy-Report-Only",
    "default-src 'self'; report-uri /csp-report"
  );
  next();
});

To ting sker her:

  • default-src 'self' siger “alt (scripts, styles, billeder, osv.) må kun komme fra samme origin som siden”.
  • report-uri /csp-report siger hvor browseren skal poste CSP-violations hen.

I en moderne verden er report-to egentlig den nye måde, men report-uri er stadig godt til at komme i gang uden at læse en halv RFC.

Hvis du er på Nginx:

add_header Content-Security-Policy-Report-Only 
  "default-src 'self'; report-uri /csp-report";

Pointen er: du vil have en simpel start-policy, der med vilje er for stram. Fordi du ikke enforcement endnu, så går intet i stykker, men du får støj i logs og DevTools, som du kan bruge til at justere.

Sådan ser CSP-fejl ud i DevTools (og hvad de betyder)

Det første sted jeg selv kigger, er console i Chrome DevTools. Hvis du har sat en report-only header på, ser du typisk noget i den her stil:

Refused to load the script 'https://www.google-analytics.com/analytics.js' 
because it violates the following Content Security Policy directive: 
"default-src 'self'". Note that 'script-src' was not explicitly set, 
so 'default-src' is used as a fallback.

Der er tre ting at bide mærke i:

  • Hvilken ressource der blev blokeret (eller ville være blevet blokeret).
  • Hvilken directive der blev ramt (default-src her).
  • Hvilket domæne du potentielt skal whiteliste.

Hvis du også har sat report-uri, kan du modtage JSON-rapporter på serveren. En typisk payload (forkortet) ser sådan ud:

{
  "csp-report": {
    "document-uri": "https://mit-site.dk/",
    "violated-directive": "default-src 'self'",
    "effective-directive": "script-src",
    "blocked-uri": "https://www.google-analytics.com/analytics.js",
    "original-policy": "default-src 'self'; report-uri /csp-report"
  }
}

Jeg plejer at lave et helt simpelt endpoint til at logge de her ting til fil eller stdout:

app.post("/csp-report", express.json({ type: ["json", "application/csp-report"] }), (req, res) => {
  console.log("CSP report", req.body);
  res.status(204).end();
});

Det er nok til at du kan åbne din log, scrolle og begynde at se mønstre. Det er også her du opdager de dele af din applikation, du har glemt at tænke over: gamle tredjeparts scripts, tilfældige cdn’er, inline styles og alt det sjove.

Iterationen: byg din policy én kategori ad gangen

Nu kommer den del, hvor de fleste mister tålmodigheden: du skal trimme policy’en lidt efter lidt, i stedet for at prøve at ramme perfekt i første forsøg.

Jeg starter næsten altid med tre directives:

  • script-src
  • style-src
  • img-src

Og lader resten falde tilbage til default-src til at begynde med.

script-src: den vigtige (og svære) del

En typisk rejse for script-src har set sådan ud for mig:

  1. Start: script-src 'self'
  2. Tilføj analytics: script-src 'self' https://www.google-analytics.com
  3. Tilføj evt. CDN’er: fx https://cdn.jsdelivr.net eller lignende.
  4. Fjerne inline scripts med 'unsafe-inline' og i stedet gå mod nonce eller hash (kommer lige om lidt).

En lidt mere realistisk mellemstation kunne være:

Content-Security-Policy-Report-Only: 
  default-src 'self';
  script-src 'self' https://www.google-analytics.com;
  report-uri /csp-report;

Her bruger du logs og console til at opdage alle scripts der stadig brokker sig, og enten fjerner dem fra din kode eller whitelister dem bevidst.

style-src: inline styles, frameworks og alt det gamle

style-src afslører tit gammel gæld. Inline styles, gamle style tags, et stray style="..." midt i et komponent.

En skarp policy for styles ser typisk sådan ud:

style-src 'self';

Men mange frameworks og gamle projekter lever i praksis i noget der ligner:

style-src 'self' 'unsafe-inline';

Jeg ser 'unsafe-inline' som en midlertidig krykke. Bedre end ingen CSP, men målet er at komme væk fra det og over på hashes eller nonces til det inline der er tilbage.

img-src: her kan du ofte være ret afslappet

For billeder ender jeg tit med noget i den her stil:

img-src 'self' https://mit-cdn.dk data:;

data: tillader små inline billeder (base64), som du typisk får fra fx CSS eller nogle libraries. Hvis du ikke bruger det, kan du droppe det.

Billeder er sjældnere et direkte sikkerhedsproblem end scripts, så jeg prioriterer altid script-src først, style-src som nummer to og img-src som komfort-ting derefter.

Nonce vs hash: sådan vælger du til dine scripts og styles

På et tidspunkt rammer du muren: din policy blokerer alt inline, men dit frontend-setup har stadig et par små inline scripts eller styles, du ikke lige får fjernet.

Her har du to realistiske valg:

  • Nonce: et tilfældigt token per request, du sætter i din CSP-header og samtidig på de scripts/styles du vil tillade.
  • Hash: en hash af indholdet af det inline script/stylesheet, som du skriver direkte ind i din CSP.

Nonce: god til dynamiske inline scripts

Nonce betyder “number used once”. I praksis genererer du en base64-streng på serveren, fx:

const crypto = require("crypto");

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.locals.cspNonce = nonce;

  res.setHeader("Content-Security-Policy-Report-Only",
    `default-src 'self'; script-src 'self' 'nonce-${nonce}'; report-uri /csp-report`
  );

  next();
});

Og så i din template:

<script nonce="<%= cspNonce %>">
  // Lille inline init script
</script>

Fordelen er, at du ikke skal opdatere din CSP hver gang indholdet ændrer sig. Ulempen er, at du skal kunne sætte attributten på dine scripts server-side, så det er lidt sværere at få til at spille med ren statisk hosting.

Hash: god til få, statiske inline klumper

Hvis du har en lille statisk klump inline JavaScript, du gerne vil beholde, kan du lave en SHA-256 hash af indholdet og skrive den ind i din CSP.

Eksempel på inline script:

<script>
  window.appConfig = { env: "prod" };
</script>

Du kan bruge Node til at lave en hash:

const crypto = require("crypto");
const content = "window.appConfig = { env: "prod" };";
const hash = crypto
  .createHash("sha256")
  .update(content)
  .digest("base64");
console.log(`'sha256-${hash}'`);

Og så i CSP:

script-src 'self' 'sha256-...hashen-her...';

Det er lidt manuelt, men hvis du har 1-2 inline scripts der nærmest aldrig ændrer sig, er det overraskende nemt at leve med.

Min tommelfingerregel:

  • Brug nonce, hvis du har dynamiske inline scripts og en server der kan sætte attributter.
  • Brug hash, hvis du har få, statiske klumper og måske statisk hosting.

Tredjeparts scripts: analytics, embeds og alt det grimme

Det sværeste med CSP er sjældent din egen kode. Det er alt det du har hevet ind udefra over de sidste par år: analytics, chat-widgets, embeds, A/B-testværktøjer, “smarte” cookie-bannere.

Her er min brutale, men ærlige proces:

  1. Slå report-only CSP til og lad det køre nogle dage.
  2. Samle alle reports der handler om scripts fra eksterne domæner.
  3. Lave en liste: hvad er det, hvorfor er det der, og bruger vi det stadig?

Alt hvad jeg ikke kan forsvare, ryger ud.

Det gælder fx alt for aggressive cookie-biblitoteker og embeds, som kunne være bygget simplere. I den sammenhæng er det ret befriende at bygge ting ordentligt fra starten, som jeg skrev om i artiklen om at lade være med at fake sit cookie banner.

Når du ved hvilke tredjeparts scripts du vil have, whitelister du domænerne i script-src, img-src, connect-src osv. Alt afhængigt af hvad de laver.

Et eksempel med React/Next.js der bruger Google Analytics og et CDN kunne ende sådan her i report-only:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' https://www.google-analytics.com;
  img-src 'self' https://images.mit-cdn.dk data:;
  connect-src 'self' https://www.google-analytics.com;
  style-src 'self';
  report-uri /csp-report;

Next.js selv er egentlig ret flink at arbejde med her, især hvis du holder dig fra for meget rå dangerouslySetInnerHTML. Hvis du er typen der piller meget med custom scripts og embeds, bliver CSP hurtigt en slags sandhedssonde for hvor rodet din frontend i virkeligheden er.

Fra report-only til enforce: sådan ruller du det ud uden at tage alt ned

På et tidspunkt har du kigget på reports længe nok. Der er ikke flere uventede domæner, dine inline scripts er enten væk, nonce’et eller hashed, og du er nogenlunde tryg.

Nu skal du skifte fra:

Content-Security-Policy-Report-Only: ...

til:

Content-Security-Policy: ...

Jeg gør det aldrig som et hårdt skifte for alle brugere på én gang.

I stedet kan du fx:

  • Enable rigtig Content-Security-Policy for 5-10 % af trafikken først (via feature flag, proxy-regel eller lign.).
  • Beholde Content-Security-Policy-Report-Only parallelt med en lidt strammere version, hvis du vil eksperimentere videre.

Det kræver lidt opsætning, men det gør det meget mindre stressende. Du kan også starte med at enforce på admin-områder eller intern tooling, før du ruller det ud på hele det offentlige site.

En anden ting jeg har været glad for, er at koble CSP-errors på min normale logging/monitorering. Hvis du alligevel har logs og stacktraces som en del af din værktøjskasse (den kategori jeg selv putter under logs og stacktraces), er det oplagt at lade CSP-fejl lande samme sted som resten af dine fejl.

Vedligehold: sådan undgår du at din policy ruster fast

En CSP er ikke noget du sætter én gang og aldrig rører igen. Din kode ændrer sig, dine tredjeparts services ændrer sig, og pludselig står du med en policy der blokerer ting du faktisk har brug for.

Jeg har haft bedst erfaring med at gøre tre ting:

1. Lad report-only leve videre ved siden af enforce

Du kan godt have både en enforce-header og en report-only-header på samme tid. Report-only kan være en lidt strammere version, du eksperimenterer med.

Eksempel:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://www.google-analytics.com;

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';  // Prøver at fjerne GA på sigt
  report-uri /csp-report;

Så kan du se, hvad der ville ske, hvis du strammede skruerne endnu et hak, uden at reelt bryde noget.

2. Gør CSP til en del af din deploy-rutine

Hver gang du tilføjer et nyt tredjeparts script, et nyt CDN eller begynder at bruge en ny API-endpoint, så spørg: “Hvad betyder det for CSP?”

Det kan være så simpelt som at have en linje i din pull request template: “Har du opdateret CSP hvis nødvendigt?”. Ikke fancy, bare en lille påmindelse.

3. Brug DevTools aktivt i hverdagen

Chrome DevTools er din ven her. I samme stil som den dag jeg endelig fik Network-tabben til at give mening, er det ret befriende at kunne se præcist hvilke ressourcer der bliver blokeret af CSP.

Hver gang en kollega siger “siden virker ikke helt i prod”, er det faktisk ikke noget dårligt gæt at starte med console og tjekke for CSP-fejl, før du tør gætter videre.

Hvis jeg skulle starte en ny CSP i morgen

Så ville min plan se sådan her, uanset om det var et klassisk Node-projekt, en Next.js-app på Vercel eller noget helt tredje:

  1. Sætte en simpel report-only header på med default-src 'self'; report-uri /csp-report.
  2. Lade den køre i mindst et par dage og samle rapporter.
  3. Bygge en eksplicit script-src, style-src og img-src baseret på hvad jeg faktisk ser i logs.
  4. Rydde ud i tredjeparts scripts, inden jeg whitelister dem.
  5. Indføre nonce eller hash på det inline jeg ikke lige får fjernet.
  6. Rulle en enforce-udgave ud til en lille del af trafikken først.
  7. Lade en strammere report-only leve videre til nye eksperimenter.

Og vigtigst: jeg ville ikke kopiere en tilfældig CSP fra Stack Overflow og håbe på det bedste. Ikke igen.

Hvis noget er kontroversielt her, så er det måske det her: En halvbeskidt, men gennemtænkt CSP med 'unsafe-inline' som midlertidig krykke er bedre end slet ingen CSP, så længe du bruger den som afsæt til at rydde op, ikke som undskyldning for at lade være.

Lav en endpoint der modtager browserens JSON-rapporter og log dem i en database eller et observability-værktøj. Aggregér på blocked-uri, directive og user-agent, filtrér udvikler-artefakter og bots væk, og prioritér rettelser efter hyppighed og hvor kritisk resource er for siden.
Brug hashes når inline-koden er statisk og uændret over deploys; det kræver ingen runtime-indsættelse og er cache-venligt. Brug nonces når koden genereres per-request af serveren, men husk at du skal injicere en unik nonce i både CSP-header og script/style-tag hver gang.
Kør report-only i en periode (fx en uge) for at indsamle data, ret de mest almindelige og kritiske violationer, test ændringer i staging, og skift så headeren til Content-Security-Policy mens du nøje overvåger nye fejl. Hav en hurtig rollback-plan (fx via deploy eller reverse proxy) hvis noget uventet går i produktion.
CSP kan blokere eval og nye Function via fravær af 'unsafe-eval', hvilket er ønskværdigt for sikkerheden. Hvis et bibliotek kræver det, bør du først prøve at opgradere eller erstatte biblioteket; som sidste udvej kan du midlertidigt tillade 'unsafe-eval' begrænset til specifikke origins, men det øger risikoen markant.

Jonas Kirkeby har skrevet kode siden han som teenager forsøgte at lave en helt simpel hjemmeside til sin fars lille vvs-firma – og endte med at sidde oppe hele natten for at få en knap til at skifte farve. Siden da har han lært sig det meste ved at prøve sig frem, kopiere andres eksempler, ødelægge dem og langsomt forstå, hvorfor tingene virker, som de gør.

Til daglig arbejder han slet ikke med IT, men bruger aftener og morgener på små projekter: en lille side til en forening, et simpelt værktøj til at holde styr på familiens madplan eller et Python-script, der rydder op i rodede filer. Det er den slags konkrete hverdags-behov, der har formet hans måde at tænke kodning på – hvad kan jeg bygge nu, som faktisk hjælper mig eller nogen, jeg kender?

På Coding Class deler Jonas de guides, han selv ville ønske, han havde haft: korte, konkrete forløb, hvor du kan se noget på skærmen efter få minutters læsning. Han viser hele vejen fra idé til færdig løsning, inklusive de typiske fejl og små snubletråde på vejen, så du ikke kun får den pæne, polerede version.

Hans mål er, at du som begynder eller let øvet hurtigt får følelsen af: “Det her kan jeg faktisk selv finde ud af” – uanset om du vil bygge din første lille hjemmeside, forstå JavaScript-funktioner eller bruge Python til at automatisere en kedelig opgave.

Send kommentar

You May Have Missed