Lesson 15 / 27
Content Security Policy
Writing the policy that tells the browser which origins it may load from; directives and source expressions, nonce and hash-based allowance, report-only mode and violation reports, and the areas the policy does not cover.
Contents
The previous lesson placed the defense at the output: context-appropriate escaping at every write site. This defense’s weakness is its scope. Being correct requires every path in the application to be correct; a single raw-markup write site, a single third-party component, a single legacy page template is enough. Assuming this scope holds continuously in a large interface is an assumption that is hard to audit.
This lesson builds a second layer. What matters about this layer is that it does not depend on the correctness of the application’s own code. The decision is declared to the browser, and the browser enforces it at load time no matter what the document says. The policy set across the North Slope Measurement Station interface fixes, this way, which scripts the page showing a measurement note may run and whether the embedded map frame is allowed.
What the Policy Does
Content security policy is a declaration carried by a response’s
Content-Security-Policy header. The declaration lists what kind of resource the document
may load from where, and under what condition inline code may run. Before fetching a
subresource or running a script, the browser checks the policy; if there is no match in
the expression list, it does not carry out the action.
This is not a defense that replaces escaping. Escaping stops data from turning into code; the policy narrows what code that runs anyway, past the boundary, can do. The two are independent layers, and because they are independent, they work together.
The policy is sent as an HTTP header. It can also be declared with a meta element in the
document, but this route does not support some directives and only takes effect after the
document has already started parsing. Defining it as a header in server configuration is
the route with the wider reach.
Directives and Source Expressions
The policy consists of directives separated by semicolons. Each directive names a resource type and is followed by a list of source expressions valid for that type.
default-src is the fallback for the other directives: when the policy carries no
directive for a given type, the browser falls back to this list. script-src covers
scripts, style-src covers stylesheets, img-src covers images, connect-src covers
network connections opened from script, and font-src covers fonts. Alongside these are
directives that decide things other than loading: frame-ancestors decides who may frame
the document, form-action decides where a form submission may go, and base-uri decides
whether the document’s base address can be changed.
Source expressions come in a few forms. 'none' allows nothing. 'self' denotes the
document’s own origin. A scheme expression (https:, data:) covers every address from
that scheme. A host expression names a domain, and, with a leading asterisk, also covers
its subdomains. 'nonce-…' and 'sha256-…' allow inline code one at a time.
'unsafe-inline' allows all inline code, and the warning in its name is warranted: this
expression largely cancels out the protection the policy provides against script
execution.
Turning the Policy into a Decision
The most direct way to see how a policy is read is to write the code that reads it. The resolver below splits a policy string into directives, tests a given request against the relevant list, and applies the fallback-to-default behavior.
// csp.mjs — resolves a policy string and decides whether a resource is allowed function resolvePolicy(policy) { const directives = new Map(); for (const part of policy.split(';')) { const words = part.trim().split(/\s+/).filter(Boolean); if (words.length === 0) continue; directives.set(words[0].toLowerCase(), words.slice(1)); } return directives; } function hostMatches(expression, url) { const [schemePart, rest] = expression.includes('://') ? expression.split('://') : [null, expression]; const [host, ...path] = rest.split('/'); if (schemePart && `${schemePart}:` !== url.protocol) return false; if (host.startsWith('*.')) { if (!url.hostname.endsWith(host.slice(1))) return false; } else if (host !== url.host && host !== url.hostname) { return false; } if (path.length > 0 && path[0] !== '') return url.pathname.startsWith('/' + path.join('/')); return true; } function expressionMatches(expression, request, documentOrigin) { if (expression === "'none'") return false; if (expression === "'self'") return request.url !== null && new URL(request.url).origin === documentOrigin; if (expression === "'unsafe-inline'") return request.inline === true && !request.nonce; if (expression.startsWith("'nonce-")) return request.nonce === expression.slice(7, -1); if (expression.startsWith("'sha256-")) return request.hash === expression.slice(1, -1); if (expression.endsWith(':')) return request.url !== null && new URL(request.url).protocol === expression; return request.url !== null && hostMatches(expression, new URL(request.url)); } function decide(policy, request, documentOrigin) { const directives = resolvePolicy(policy); const name = directives.has(request.directive) ? request.directive : 'default-src'; const list = directives.get(name); if (!list) return { allowed: true, directive: null }; const matched = list.find((expression) => expressionMatches(expression, request, documentOrigin)); return { allowed: Boolean(matched), directive: name, matched: matched ?? null }; } function reportEntry(request, result, policy, disposition) { return { 'document-uri': 'https://measurements.example.test/station/north-slope', 'violated-directive': result.directive, 'blocked-uri': request.url ?? 'inline', 'disposition': disposition, 'original-policy': policy, }; } const POLICY = "default-src 'none'; script-src 'self' 'nonce-r4k2' https://measurements-cdn.example.test; " + "img-src 'self' data:; connect-src 'self' https://*.data.example.test; frame-ancestors 'none'"; const ORIGIN = 'https://measurements.example.test'; const requests = [ { name: 'own script', directive: 'script-src', url: 'https://measurements.example.test/js/measurements.js' }, { name: 'distribution script', directive: 'script-src', url: 'https://measurements-cdn.example.test/chart.js' }, { name: 'foreign script', directive: 'script-src', url: 'https://other.example.test/tracking.js' }, { name: 'inline with nonce', directive: 'script-src', url: null, inline: true, nonce: 'r4k2' }, { name: 'inline without nonce', directive: 'script-src', url: null, inline: true }, { name: 'embedded data image', directive: 'img-src', url: 'data:image/png;base64,iVBOR' }, { name: 'subdomain call', directive: 'connect-src', url: 'https://measurements1.data.example.test/record' }, { name: 'font', directive: 'font-src', url: 'https://measurements.example.test/f/a.woff2' }, ]; for (const request of requests) { const result = decide(POLICY, request, ORIGIN); const line = [ (result.allowed ? 'allow' : 'block').padEnd(6), request.name.padEnd(22), (result.directive ?? '-').padEnd(14), result.matched ?? '', ].join(' '); console.log(line.trimEnd()); } console.log('\nreport entry:'); const blocked = requests[2]; console.log(JSON.stringify(reportEntry(blocked, decide(POLICY, blocked, ORIGIN), POLICY, 'enforce'), null, 2));
$ node csp.mjs
allow own script script-src 'self'
allow distribution script script-src https://measurements-cdn.example.test
block foreign script script-src
allow inline with nonce script-src 'nonce-r4k2'
block inline without nonce script-src
allow embedded data image img-src data:
allow subdomain call connect-src https://*.data.example.test
block font default-src
report entry:
{
"document-uri": "https://measurements.example.test/station/north-slope",
"violated-directive": "script-src",
"blocked-uri": "https://other.example.test/tracking.js",
"disposition": "enforce",
"original-policy": "default-src 'none'; script-src 'self' 'nonce-r4k2' https://measurements-cdn.example.test; img-src 'self' data:; connect-src 'self' https://*.data.example.test; frame-ancestors 'none'"
}
The last line shows the policy’s most instructive behavior. The font request falls into
the default-src 'none' list, because there is no font-src directive, and gets blocked.
This is the recommended way to write a strict policy: start with default-src 'none' and
add a directive explicitly for each type the application actually uses. A missing
directive turns into a visible failure instead of silently allowing anything through.
Inline Code with Nonces and Hashes
If inline script is genuinely needed, there are two routes, and neither uses
'unsafe-inline'.
A nonce is an unpredictable value the server generates for each response. It is
declared in the policy as 'nonce-r4k2', and the same value is written on the script
element as the nonce attribute. The browser runs only the matching element. Three rules
are binding: the value is generated with cryptographic-quality randomness, it is renewed
on every response, and it is not reused with a cached page. A fixed nonce reduces the
policy to the level of 'unsafe-inline'.
Hash-based allowance suits inline scripts whose content does not change. A digest of
the script’s body is taken and written into the policy as 'sha256-…'. It requires no
per-response generation, but the policy must be updated the moment one character of the
script text changes, which means the hash has to be computed at build time.
As host lists grow, a policy’s protective value drops, because every domain on the list
becomes a trusted load point. The 'strict-dynamic' expression, defined to reduce this
problem, delegates trust to whatever a nonce-run script loads, making the host list
unnecessary. For an implementation that does not understand this expression to keep
working, it is written alongside the host list: old behavior reads the list, new behavior
ignores it.
Report-Only Mode and Rollout
Applying a strict policy directly to a working application breaks functioning features.
This is why the Content-Security-Policy-Report-Only header exists: the browser evaluates
the policy and reports violations, but blocks nothing. In the report entry above, the
disposition field carries this distinction — enforce under enforce mode, report
under report-only mode.
Violation reports are sent to a reporting endpoint declared in the policy. The entry’s
fields say which document the violation occurred in, which directive was violated, and
what was blocked. For inline code, the blocked-uri field carries the value inline
instead of an address.
The rollout order is this: start with report-only mode, collect incoming reports for a while, add legitimate load points to the policy, and fix remaining violations in code — only then move to enforce mode once the flow of reports has settled. The reporting endpoint itself must be resilient to noise: browser extensions that inject code into the page on the user side produce violations unrelated to the application, and if these reports are not filtered out, genuine findings become invisible.
What the Policy Does Not Cover
The policy governs loading and execution decisions; it does not govern what legitimate code running inside the document does. The DOM-based class introduced in the previous lesson falls into this gap: if the application’s own script writes a value from its own origin as raw markup, the policy does not count that as an external load.
The mechanism defined to narrow this gap makes insertion points that accept raw markup writable only through an audited type, turning a plain string assignment into an error. It attaches to the policy through a directive and is measurable in report-only mode. Because support is not available in every environment, it is tested with feature detection, and where it is absent the application falls back on the escaping layer built in the previous lesson.
Summary
- Content security policy tells the browser which origin may load and which code may run, and makes the decision independent of the application’s own code.
- The policy consists of directives;
default-srcis the fallback for the rest, so starting withdefault-src 'none'and adding each needed type one at a time makes gaps visible. - For inline code, a nonce renewed per response or a hash computed at build time replaces
'unsafe-inline'. - Report-only mode evaluates the policy without blocking; rollout starts in report-only mode and moves to enforce mode once the flow of reports has settled.
- The policy governs external load decisions; the document’s own code writing raw markup is constrained by a separate mechanism.
Next Step
The policy says where a script may load from. Once the script has loaded and sends a request to another origin to read data, a different set of rules takes over: the same-origin policy does not allow the response to that request to be read by default. For the measurement interface to read from a data endpoint at a separate domain, the server has to declare explicit permission. The next lesson builds this permission mechanism, the constraints the browser applies, and the correct server configuration.
To keep your progress and take notes, Log in
My notes
Log in to take notes.