Skip to content
academia.sh

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-src is the fallback for the rest, so starting with default-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.

Start typing to search.

↑↓ Esc navigate · open · close