DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

strict-dynamic Discards 'self' Too, and My CSP Evaluator Did Not Know That

A Content-Security-Policy looks like a firewall rule and mostly behaves like one. The gap between "I wrote a policy" and "I have a policy" is almost always one clause in the spec — and I found out I had misread one of them by writing 60 reference cases and running them.

A working evaluator, answering the only question that matters — does the browser fetch this?: https://dev48.infy.uk/solve/day65-csp-evaluator.html

The two clauses that decide whether a policy is real

1. 'unsafe-inline' is ignored when a nonce or hash is present.

script-src 'self' 'nonce-r4nd0m' 'unsafe-inline'
Enter fullscreen mode Exit fullscreen mode

Read as a human that says "allow inline scripts". Read as a browser, the 'unsafe-inline' is discarded. Only nonced scripts run.

This is the best-designed clause in the spec: it lets you ship 'unsafe-inline' as a fallback for browsers too old to understand nonces without weakening the policy for browsers that do. The failure mode is the reverse — somebody adds 'unsafe-inline' to "fix" a broken widget, sees no change, and concludes CSP is not working. It is working. The fix they needed was to nonce the widget.

2. 'strict-dynamic' discards your host allowlist.

script-src 'nonce-abc' 'strict-dynamic' https://cdn.example.com 'self'
Enter fullscreen mode Exit fullscreen mode

Every host in that line is ignored. Trust propagates by provenance instead.

And that is where I was wrong

My filter was this:

if (strictDynamic) out = out.filter(s => isKeyword(s));   // hosts discarded
Enter fullscreen mode Exit fullscreen mode

Reasonable-looking. It drops hosts and schemes — and keeps 'self', because 'self' is a keyword.

CSP3 says the browser ignores host-source, scheme-source, 'self' and 'unsafe-inline' under 'strict-dynamic'. So my evaluator would have reported a policy as protected by an origin the browser was never consulting. Rewritten as an explicit allowlist of survivors:

const survivesStrictDynamic = s =>
  isNonce(s) || isHash(s) ||
  ["'strict-dynamic'","'unsafe-eval'","'wasm-unsafe-eval'",
   "'unsafe-hashes'","'report-sample'","'none'"].includes(s.toLowerCase());
Enter fullscreen mode Exit fullscreen mode

An allowlist, for the same reason the scheme list is one: the failure mode of a denylist in a security control is silently trusting something.

The second bug was duller and just as real — a port wildcard https://cdn.example.com:* parsed :* as a path and matched nothing at all.

The wildcard has holes on purpose

img-src * does not mean "any image". * matches network schemes only — it deliberately excludes data:, blob: and filesystem:, because those are the ones an attacker can construct without controlling a host.

const NETWORK_SCHEMES = ["http", "https", "ws", "wss", "ftp"];
if (src.raw === "*") return NETWORK_SCHEMES.includes(url.scheme);
Enter fullscreen mode Exit fullscreen mode

Again an allowlist. A denylist would silently admit whatever scheme the platform adds next.

Host matching is stricter than a glob

  • *.example.com matches a.example.com and a.b.example.com, not example.com. The implementation keeps the leading dot when it slices — drop it and notexample.com matches.
  • A schemeless source inherits the document's, then http upgrades to https and never the reverse.
  • example.com matches any port and path but no subdomain.

Fallback is a chain, and it stops

Written as data so the empty arrays are visible:

const FALLBACK = {
  "img-src":         ["default-src"],
  "worker-src":      ["child-src", "script-src", "default-src"],
  "frame-ancestors": [],      // does NOT fall back
  "base-uri":        [],
  "form-action":     []
};
Enter fullscreen mode Exit fullscreen mode

A policy with only default-src 'self' does not restrict framing at all. As an if-chain the missing cases look like omissions; as a table they look like what they are.

One more: first occurrence wins. Repeat a directive and the browser keeps the first. "Fixing" a directive by appending a looser copy at the end changes nothing.

The bypass that ships most often

script-src 'self' https://some-cdn.example reads as tight. If that CDN hosts user uploads, an old AngularJS, or a JSONP endpoint, the policy is over — an attacker points a script tag at the allowed host and CSP approves. The page's audit flags exactly that shape, because it is the most common way a real policy turns out to be decorative.

Sixty reference cases, zero failures — after two of my own bugs were found by them.

Part of a from-scratch series — one tool a day, all client-side: https://dev48.infy.uk/solvefromzero.php

Top comments (0)