Web Security
Web Security

Content-Security-Policy: the browser-enforced second line

Full · ~40 min
After this unit you can
  • Read a CSP header directive by directive, including which directives fall back to default-src and which don't
  • Predict which scripts a policy blocks: 'unsafe-inline' is ignored once a nonce or hash is present, and 'strict-dynamic' switches off host lists
  • Generate a nonce that is fresh and unguessable on every response, and build a strict policy around it
  • Roll a policy out with Content-Security-Policy-Report-Only and report-to, and test it in the mode it will run in
  • Choose between nonces (dynamic rendering), a static 'unsafe-inline' policy and SRI hashes in a Next.js 16 app

Read

Simple
1 / 2

CSP is an allow-list the browser enforces

Flag

The xss unit was about keeping attacker text out of HTML and script sinks. Content-Security-Policy (CSP) is the second line: a response header that tells the browser which scripts, styles, images and connections this page is allowed to use. If an injected payload slips through, the browser refuses to run it. MDN calls it "a backup in case the other defenses fail" — the server still sends the bad HTML; the browser just won't execute what the policy doesn't allow.

A policy is a list of directives separated by semicolons. Each directive is a name followed by the sources it allows:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m' https://js.stripe.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
DirectiveReads as
default-src 'self'images, styles, fonts, connections and other fetches not listed below may come only from this page's own origin
script-src 'self' 'nonce-r4nd0m' https://js.stripe.comscripts from our origin, inline scripts carrying nonce="r4nd0m", and scripts from Stripe
object-src 'none'no <object> or <embed> at all
base-uri 'none'the page may not use a <base> element
frame-ancestors 'none'no page, not even one of ours, may put this one in a frame

Two spelling rules: keywords are quoted ('self', 'none', 'nonce-…'), and hosts and schemes are not (https://js.stripe.com, https:). self without quotes would mean a host called "self".

What this buys you against XSS: once a policy has script-src (or default-src) without 'unsafe-inline', the browser blocks inline <script> blocks that don't carry the nonce, inline event handlers like onerror="…" and onclick="…", and javascript: URLs; eval() and its relatives are blocked too. The <img src=x onerror=…> payload from the previous unit lands in the page and does nothing.

Medium
1 / 2

A nonce per response, line by line

Flag

A plain Node server that sends a nonce-based policy. Run it and request the page twice: the two responses carry different nonces.

import { createServer } from "node:http";
import { randomBytes } from "node:crypto";

createServer((req, res) => {
  const nonce = randomBytes(16).toString("base64"); // 128 bits, fresh for this response
  const csp = [
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    "object-src 'none'",
    "base-uri 'none'",
  ].join("; ");

  res.setHeader("Content-Security-Policy", csp);
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  res.end(`<!doctype html><script nonce="${nonce}" src="/app.js"></script>`);
}).listen(3000);
$ curl -si localhost:3000 | grep -i content-security-policy
Content-Security-Policy: script-src 'nonce-bfiayeQQooD+DrlcufrbdA==' 'strict-dynamic'; object-src 'none'; base-uri 'none'
$ curl -si localhost:3000 | grep -i content-security-policy
Content-Security-Policy: script-src 'nonce-DFsSSeZaNYL/9GSWRxdKMg==' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Why each piece is there:

  • randomBytes(16) inside the handler. The nonce is a per-response secret. Generated once at start-up, it would be printed in every page anyone loads — including the attacker's — and they'd simply put it on their injected script. 16 bytes is the spec's recommended 128-bit minimum, from a cryptographically secure source (Math.random() is not one).
  • The same nonce in the header and on our tag, and only on tags we wrote. MDN warns against templating that adds the nonce to every <script> in the output: an injected tag would get one too.
  • 'strict-dynamic'. app.js is trusted because its tag has the nonce. Scripts it adds with document.createElement("script") inherit that trust, so a bundler's chunk loader or a tag manager works without listing every host. The next reading covers the catch.
  • object-src 'none'. Plugin content (<object>, <embed>) is an old route around script restrictions; nothing modern needs it.
  • base-uri 'none'. An injected <base href="https://evil.example"> would make the relative /app.js load from the attacker's host — with our valid nonce on the tag. The spec uses exactly this example. base-uri closes it, and it has to be stated explicitly because it doesn't fall back to default-src.

This is a strict CSP. MDN's version is a nonce- or hash-based script-src plus object-src 'none' and base-uri 'none'; the CSP Level 3 draft's version is nonces or hashes with 'strict-dynamic', plus base-uri 'self' or 'none'. Notice what both leave out: a list of allowed hosts. Host allow-lists are, in MDN's words, "hard to get right" and "can be very large and hard to maintain".

Complex
1 / 2

Rolling a policy out without breaking production

Flag

An enforced policy that's wrong breaks the site: a blocked analytics loader, a payment iframe that never appears, a chunk that won't load. The way in is to report first, enforce second.

Report-Only. The same policy sent under a different header name is evaluated but not enforced — MDN: "The policy is not enforced, but any violations are sent to the reporting endpoint specified in the policy."

Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-R' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpoint

Reporting-Endpoints names an endpoint; report-to in the policy points at it. Browsers POST reports as application/reports+json with "type": "csp-violation". The older report-uri directive is deprecated, but MDN advises declaring both until report-to works everywhere — browsers that support report-to ignore report-uri. (MDN's compat data lists report-to support in Firefox only from version 149.)

Both headers can be sent at once, and both are honoured: keep enforcing the policy you trust while the stricter one only reports. Fix or allow each legitimate violation the reports show; when only violations you intend to block are left, move the strict policy to the enforcing header.

More than one policy only ever tightens. If a response carries two enforced CSP headers, a resource must pass both. MDN: adding policies "can only further restrict the capabilities of the protected resource". A CDN or platform that adds its own CSP header on top of yours can block what your policy allows.

The <meta> tag is a partial substitute. The CSP spec: Report-Only isn't supported in a meta element, and "Neither are the report-uri, frame-ancestors, and sandbox directives". A frame-ancestors 'none' in a meta tag protects against nothing.

Test the policy where it actually runs. The most common way to ship a broken policy is to set it only in production:

if (process.env.NODE_ENV === "production") res.setHeader("Content-Security-Policy", csp);

Every page then works in development, because no policy exists there; the inline script someone added last week is only blocked for real users. Frameworks also differ between modes — Next.js's development server needs 'unsafe-eval' (React uses eval for debugging information) while its production build doesn't. Run the policy in development too (adding only what dev genuinely needs), and click through a local production build with the policy on before you ship it.

Quiz

Which statement about Content-Security-Policy is accurate?

Flag

The policy is default-src 'none'; script-src 'self'; style-src 'self'. Which of these is still unrestricted?

Flag

To save work, a teammate generates the CSP nonce once when the server starts and reuses it for every response. What's wrong with that?

Flag

To make the policy "just work", a post-processing step adds the response's nonce to every <script> tag in the outgoing HTML. Why does that defeat CSP?

Flag

You're adding a strict CSP to a production app that has a tag manager and a payment widget. What's the safe first step?

Flag

Your app sets CSP only if (process.env.NODE_ENV === "production"). Every page works under next dev. What's the likely outcome?

Flag

In a Next.js 16 app you add a nonce-based CSP in proxy.ts. What happens to pages that used to be statically generated?

Flag

A Next.js 16 marketing site must stay statically generated and CDN-cached, and security won't accept 'unsafe-inline' in script-src. Which approach in the Next.js CSP guide fits?

Flag

Fill in the blank

Write the nonce-based strict policy: trust propagates from nonced scripts, plugins are off, and <base> is locked.

Flag
Content-Security-Policy: script-src 'nonce-{RANDOM}' ''; object-src ''; base-uri 'none'

Generate a 128-bit nonce for this response in Node, base64-encoded for the header.

Flag
import { randomBytes } from "node:crypto";

const nonce = (16).toString("");

Trial a policy without enforcing it, sending violation reports to a named endpoint.

Flag
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-: script-src 'nonce-R' 'strict-dynamic';  csp-endpoint

In a Next.js 16 Server Component, read the nonce that proxy.ts put on the request, for a third-party script.

Flag
const nonce = (await headers()).get("");
return <Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" nonce={nonce} />;

A Next.js 16 page reads no request data, but it must render per request so a fresh nonce can be applied. Opt it into dynamic rendering.

Flag
import { connection } from "next/server";

export default async function Page() {
  await ();
  // …
}

Predict the output

Content-Security-Policy: default-src 'self'; script-src https://js.stripe.com

<script src="/app.js"></script>                     <!-- same origin -->
<script src="https://js.stripe.com/v3/"></script>
Flag
Content-Security-Policy: script-src 'self' 'unsafe-inline' 'nonce-R'

<script nonce="R">boot()</script>
<script>steal()</script>                 <!-- injected by an attacker -->
<button onclick="buy()">Buy</button>    <!-- clicked -->
Flag
Content-Security-Policy: script-src 'nonce-R' 'strict-dynamic' https://js.stripe.com

<script src="https://js.stripe.com/v3/"></script>   <!-- no nonce -->
<script nonce="R" src="/loader.js"></script>

// inside /loader.js:
const s = document.createElement("script");
s.src = "https://cdn.example.net/widget.js";
document.head.append(s);
Flag

Challenge

Fix the per-request CSP builder

Flag

csp.ts builds the Content-Security-Policy header for one HTML response. The app's proxy calls cspForRequest() on every page request, sends header as the response header, and puts nonce on the page's own <script> tags. csp-parse.ts splits a header into its directives the way the CSP spec does; the preview uses it to show three requests side by side.

The policy is sent and the site works, but it would barely slow down an XSS payload:

  1. 'unsafe-inline' lets every inline script run, the attacker's included. The page's own inline scripts should be allowed by the response's nonce instead (and the nonce isn't in the header at all yet).
  2. The nonce is created once, when the module loads, so every response carries the same one. Anyone can load a page, read it, and put it on their injected script.
  3. No object-src or base-uri. Through the default-src 'self' fallback, <object> and <embed> may still load anything from our own origin (user uploads included). base-uri doesn't fall back to default-src at all, so an injected <base href="https://evil.example"> could re-point the page's nonced relative script URLs.
  4. 'unsafe-eval' in production. React's development build needs it; production doesn't, and a nonce doesn't switch 'unsafe-eval' off the way it switches 'unsafe-inline' off.

Your task

Edit csp.ts so the tests pass:

  • script-src allows inline script only through 'nonce-…' with this response's nonce, and contains no 'unsafe-inline'
  • Two calls to cspForRequest return two different nonces
  • The policy has object-src 'none' and an explicit base-uri ('none' or 'self')
  • 'unsafe-eval' is in script-src only when dev is true
  • The configured script and connect origins, frame-ancestors 'none' and report-to are still there
  • Nonces are base64 and carry at least 128 bits

Loading the in-browser editor…

Further reading