Web Platform · Web Security Model

Content Security Policy: Source Lists, Nonces, and Trust Propagation

How CSP directives fall back to default-src, why host allowlists leak through JSONP and open redirects, and how nonces plus strict-dynamic move script trust to runtime provenance.

Content Security Policy is a response header that tells the browser which resources a document may load and execute. Unlike the same-origin policy, which is enforced by comparing origins at the point of access, CSP is enforced during resource selection: the browser checks a URL, or a script’s bytes, against a policy before fetching or running it. That makes CSP the last line of defence against injection — the attacker’s <script> element still gets inserted into the DOM, but it does not execute.

The mechanism is a set of directives, each with a source list. Getting CSP right is mostly a matter of understanding two things: which directive governs which kind of load, and how the trust granted to a script propagates to the scripts it creates.

Directives, fallbacks, and the intersection rule

A policy is a semicolon-separated list of directives. Each directive names a class of resource and supplies a source list:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0mB64'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'

Only script-src and the directives that fall back to it constrain script execution; the rest govern other resource classes. The fallback chain matters because a policy that omits a directive inherits from default-src, and a policy that omits default-src and a specific directive leaves that class unrestricted. script-src falls back to default-src and nothing else. worker-src falls back to child-src and then to script-src. style-src, img-src, font-src, media-src, connect-src, and manifest-src fall back to default-src directly. Four directives have no fallback at all and must be stated explicitly if they are wanted: base-uri, form-action, frame-ancestors, and report-uri.

base-uri and form-action are the ones most often forgotten, and both are load-bearing. base-uri 'none' prevents an injected <base href="..."> from rerouting every relative script and link URL to an attacker’s host; without it, a policy that allows script-src 'self' still executes attacker-hosted code when the page uses relative script paths and the attacker can inject a base element. form-action restricts where a document’s forms may submit, which blocks the exfiltration step of many injection payloads.

Multiple policies combine by intersection. If a response carries two Content-Security-Policy headers, a resource must be permitted by both. There is no way to merge or override; each is enforced independently, and the most restrictive wins on every directive. This is what makes CSP friendly to layered deployment: a platform can add a baseline policy without knowing what the application adds, and neither can weaken the other. A policy delivered in a <meta http-equiv="Content-Security-Policy"> element is also intersected, but frame-ancestors, report-uri, and sandbox are ignored in that delivery mode.

Host allowlists and why they leak

A source expression of the form https://cdn.example.com allows any script from that host, at any path unless a path is appended, over any URL that host serves. A bare * allows any host over any scheme permitted by the document. 'self' means the document’s own origin — scheme, host, and port — not its site, so a page on https://app.example.com cannot load from https://static.example.com under 'self'.

The weakness of host allowlists is that hosts serve more than the application expects. A JSONP endpoint on an allowlisted analytics domain returns attacker-controlled JavaScript when the callback parameter is attacker-controlled: the URL is on the allowlist, the response is script, and the script runs with the page’s privileges. An open redirect on an allowlisted host has the same effect in one hop. A CDN that hosts user-uploaded files, or a shared JavaScript host that accepts a package name in the path, gives the attacker a URL on an allowlisted host with attacker content. AngularJS-style template injection and any library that evaluates strings reach the same outcome through 'unsafe-eval'.

This is the structural argument for nonces: a URL allowlist expresses trust in a host, while injection attacks almost always find a way to place content on a trusted host or to make the document request a URL the host will answer with attacker bytes. Trusting the host is only as strong as the least careful thing the host serves.

'unsafe-inline' is the other common weakening. It permits inline <script> elements, javascript: URLs, and inline event handler attributes. When a policy contains a nonce or a hash source, browsers ignore 'unsafe-inline' entirely, so adding both does not create a fallback — the nonce wins. A policy that needs 'unsafe-inline' for a legitimate inline bootstrap is better served by a nonce on that one element.

Nonces and hashes

A nonce source is 'nonce-<base64-value>', and the matching element carries nonce="<base64-value>". The value must be unpredictable and unique per response, because its entire security value is that an attacker who can inject markup cannot guess it. The specification recommends at least 128 bits of entropy; 16 random bytes encoded in base64 is the common shape. A nonce that is reused across responses, derived from a counter, or embedded in a cached HTML page is equivalent to 'unsafe-inline'.

<script nonce="b7Rk2pQ9vX1sT4mZ">
  boot({ endpoint: "/api/session" });
</script>

Hashes work differently: 'sha256-<base64>' (or 384, 512) lists the digest of the exact script bytes. An inline script whose content hashes to a listed value runs; any modification, including a whitespace change or a differing line ending, changes the digest and blocks it. Hashes need no per-response state and survive caching, which makes them attractive for static pages, but they are unusable for content that varies per request, and inline handlers need the separate 'unsafe-hashes' token to be matched at all. For external scripts, a hash source matches only when the element’s integrity attribute carries the same digest, which ties CSP hashing to subresource integrity as described in subresource integrity and third-party scripts.

The one place a nonce is hard to apply is the HTML itself. If the page is served from a cache shared across users, the nonce would be shared too. The usual answer is to make the HTML uncacheable or edge-rendered per response, or to accept hashes for the static bootstrap and nonces only for server-rendered dynamic markup.

strict-dynamic moves trust to provenance

Host allowlists decide trust by destination URL. 'strict-dynamic' decides it by who created the script. When a policy contains a nonce or hash source plus 'strict-dynamic', a script that was allowed by its nonce may insert further <script> elements, and those are allowed regardless of their URL. In the same policy, host sources, scheme sources, 'self', and 'unsafe-inline' are ignored by browsers that support 'strict-dynamic' — the directive intentionally overrides them so that the policy does not silently degrade to the old allowlist behaviour.

The resulting policy is short:

Content-Security-Policy: script-src 'nonce-b7Rk2pQ9vX1sT4mZ' 'strict-dynamic'; object-src 'none'; base-uri 'none'

This handles the loader problem that host allowlists cannot. A tag manager, an AMD loader, or a bundle that injects chunks at runtime would need every destination host allowlisted, which reintroduces the JSONP problem. Under 'strict-dynamic', only the loader needs a nonce; everything it inserts inherits trust. The trade is that the policy now trusts the loader’s judgement and protects nothing if the loader itself can be induced to load attacker content — for example, a tag manager configured to execute a user-supplied custom HTML tag. Trust has moved from a list of hosts to a small set of first-party entry points, and those entry points become the security perimeter.

Browsers that do not implement 'strict-dynamic' ignore it and fall back to the rest of the source list, which is why the older host entries remain in deployed policies as a compatibility tail. Once support is universal for the target audience, those entries can go, and the policy becomes strictly nonce-based.

Report-only mode and the shape of a violation

Content-Security-Policy-Report-Only delivers a policy that is evaluated but not enforced. It exists for staged rollout: a site can collect violations from real traffic before turning the policy on. The same fallback and intersection rules apply, and both enforced and report-only policies can be present simultaneously, each generating reports.

Reports are JSON documents posted to the endpoint named by report-uri (the older mechanism) or report-to (the Reporting API, which names a group defined in a Reporting-Endpoints header). A report names the document-uri, the blocked-uri or blockedURL, the violated-directive or effective-directive, and the disposition — enforce or report. The endpoint must accept cross-origin POSTs with Content-Type: application/csp-report or application/reports+json, and it must not require credentials, because reports are sent without them.

Two limits constrain what reports can establish. A violation report says a load was blocked, not that an attack occurred; browser extensions that inject script into pages, stale cached HTML carrying an old nonce, and third-party widgets that were not accounted for all produce reports without any attacker being involved. And reports are not sent for every violation: a blocked-uri of about:blank or a missing frame context produces little usable detail, and browsers may drop reports under load. Report volume is not a security signal on its own, and an endpoint that stores unbounded report payloads is itself an availability risk.

CSP governs which script elements execute and which URLs may be loaded; it does not govern what trusted code does with a string it assigns to a sink. That gap is what Trusted Types and the typing of DOM XSS sinks closes, and the two mechanisms are usually deployed as one policy.

The end state of a mature policy is a short enforced header: a nonce or hash for scripts, 'strict-dynamic', object-src 'none', base-uri 'none', frame-ancestors 'none', and explicit connect-src and form-action lists. Everything else is defence in depth around those five decisions.

References

  1. Content Security Policy Level 3 — W3C
  2. Content Security Policy Level 2 — W3C
  3. MDN — Content-Security-Policy — MDN Web Docs