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'
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'
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
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());
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);
Again an allowlist. A denylist would silently admit whatever scheme the platform adds next.
Host matching is stricter than a glob
-
*.example.commatchesa.example.comanda.b.example.com, notexample.com. The implementation keeps the leading dot when it slices — drop it andnotexample.commatches. - A schemeless source inherits the document's, then http upgrades to https and never the reverse.
-
example.commatches 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": []
};
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)