UnveilTech

UnveilScan Blog

← All articles

Try UnveilScan free

Reading a Content-Security-Policy header without crying

Posted 2026-04-29 · 8 min read · WEBCSPXSS

A typical Content-Security-Policy header looks like a UN treaty: one long line of semicolon-separated terms, half of which look like options and half like flags. Most engineers either copy a template from MDN once and never touch it again, or write default-src *; script-src 'unsafe-inline' 'unsafe-eval' * and call it a day.

Both reactions are wrong. CSP done badly is worse than no CSP — it gives a false sense of defence. CSP done well stops a real chunk of XSS bugs from ever shipping data out. The gap between the two is small in practice. Here's the field guide.

What CSP actually does

A CSP header tells the browser which sources of code, styles, images, frames, etc. it is allowed to load and execute. If a directive lists a source, that source is allowed. Anything else is blocked. There's no magic. The work is in the policy, not in the browser.

The most important directive is script-src, because that's where injected JS tries to land. Everything else is supporting cast.

The seven directives that actually matter

DirectiveWhat it controlsDefault fallback
default-srcFallback for unspecified directivesnone
script-srcJS execution: tags, eval, inlinedefault-src
style-srcCSS: tags, inline, attributedefault-src
img-srcImages: tags, CSS backgroundsdefault-src
connect-srcfetch/XHR/WebSocket/EventSourcedefault-src
frame-ancestorsWho can frame your page (clickjacking)
base-uriAllowed values for <base>

There are more (object-src, font-src, media-src, worker-src, form-action, upgrade-insecure-requests), but if you nail those seven, you've covered ~95% of real attack surface.

Source expressions: the seven "shapes"

Each directive accepts a list of source expressions. There are seven shapes:

  1. Schemeshttps:, data:, blob:. Allows everything from that scheme.
  2. Hostscdn.example.com, *.example.com. Allows that origin.
  3. Keywords'self', 'none', 'unsafe-inline', 'unsafe-eval', 'strict-dynamic', 'wasm-unsafe-eval'.
  4. Nonces'nonce-rAnd0m=='. Allows scripts/styles tagged with nonce="rAnd0m==".
  5. Hashes'sha256-base64encodedhash='. Allows scripts whose body hashes match.
  6. Reporting endpointsreport-uri /csp-report (deprecated) or report-to group-name.
  7. The wildcard*. Don't.

Three policies, ranked by safety

The "I just want it green" anti-policy

Content-Security-Policy: default-src *; script-src * 'unsafe-inline' 'unsafe-eval'

Allows literally everything. The header is present, so naïve scanners give it a checkmark. But it offers zero protection — an attacker who injects a <script> tag can fetch from anywhere and execute anything. This is XSS-as-a-service.

The "modern browsers, allow inline by hash" policy

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'sha256-iOO4O/cDJF6W5Sv6EElsY0ZtG2Ln3K8pQUnsvq6yw0s=';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;
  connect-src 'self' wss:;
  frame-ancestors 'none';
  base-uri 'self';
  report-uri /csp-report

This is what UnveilScan actually serves. Real protection: only same-origin scripts run, plus one inline script identified by SHA-256 hash (the SvelteKit bootstrap). XSS injecting any other inline script gets blocked. frame-ancestors 'none' kills clickjacking. base-uri 'self' kills <base>-based redirect tricks. The report-uri sends violations to an endpoint we log.

The strict-dynamic policy (best of both worlds)

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-rAnd0mBase64==' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  base-uri 'self';
  frame-ancestors 'none';
  report-to csp-endpoint

A nonce is generated per request and added to every legitimate <script> tag. 'strict-dynamic' tells the browser: "if a nonced script dynamically loads more scripts, trust those too." This lets bundlers like webpack code-split freely while rejecting anything an XSS payload would try to inject. The "host whitelist" (cdn.foo.com) disappears entirely — easier to maintain, more secure.

The mistakes we see all the time

1. 'unsafe-inline' on script-src

This single keyword removes 80% of CSP's protection. It allows any inline script the page contains, which means an attacker who injects <script>steal()</script> anywhere in your HTML wins. Replace with nonces or hashes.

2. Wildcard hosts on script-src

script-src *.googleapis.com looks tight until someone realises Google hosts a JSONP endpoint that returns attacker-controlled JS. *.cloudfront.net is even worse — anyone can rent a CloudFront distribution. Pin to the exact host.

3. Missing frame-ancestors

X-Frame-Options: DENY covers older browsers, but the modern equivalent is frame-ancestors 'none'. If you don't ship one, your login page can be framed by an attacker site and clickjacked.

4. Forgetting base-uri

Without base-uri, an XSS payload that injects <base href="//evil.com/"> redirects every relative resource on the page through the attacker. Always pin base-uri 'self'.

5. report-uri without an endpoint that 200s on HEAD

Some scanners (including ours) probe the report endpoint to confirm it's reachable. A handler that 405s on HEAD looks misconfigured. Make HEAD/GET return 204; only POST needs to do work.

How UnveilScan grades CSP

Our csp checker evaluates ~12 indicators inspired by Google's CSP Evaluator methodology, including:

Each finding includes a copy-paste fix snippet for nginx, Apache, Caddy and Cloudflare Workers. Run an Extended scan to see them.

How does your CSP grade?

Free Basic scan covers headers + CSP shape. Extended runs the full evaluator.

Run a scan