DevKitHub

Your CSP is probably not doing what you think

2 min read

Content Security Policy is the header most often shipped in a state that looks protective and is not. Two mistakes account for most of it, and both survive review.

A Content-Security-Policy header is long, full of quoted keywords, and hard to read quickly — which means it tends to be graded on appearance. A policy that fills three lines looks like a policy that is doing something. Two specific mistakes make that impression wrong, and neither produces an error anywhere.

unsafe-inline defeats most of the point

The main thing a CSP is for is stopping injected script from running. 'unsafe-inline' in script-src permits any inline <script> element to execute — which is exactly what an injected script is. With it present, an attacker who can get markup onto your page can run code, policy or no policy.

http
# Looks like a policy. Barely is one.
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'

It is usually there because something on the page needs an inline script and removing it was more work than adding the keyword. The fix is a per-response nonce: generate a random value, put it on the header and on the script tag, and the browser runs that script and no other.

Three directives do not fall back to default-src

Most fetch directives inherit from default-src when unset, which makes default-src feel like a floor. Three do not inherit from anything: base-uri, frame-ancestors and form-action. A policy that sets only default-src reads as complete and leaves all three entirely unrestricted.

  • base-uri unset — an injected <base> tag repoints every relative URL on the page, including script sources, without violating the rest of the policy.
  • frame-ancestors unset — anyone may frame the page. This is the modern replacement for X-Frame-Options, and its absence is a clickjacking opening.
  • form-action unset — an injected form may post anywhere it likes, including a server the attacker controls.
http
Content-Security-Policy:
  default-src 'none';
  script-src 'self';
  style-src 'self';
  img-src 'self';
  connect-src 'self';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self'

Smaller traps worth knowing

A misspelled directive is ignored silently, so a typo leaves whatever it was meant to restrict completely unrestricted — there is no error and no warning. 'none' combined with any other source is meaningless, because 'none' means nothing at all and cannot be combined. A repeated directive is honoured once and ignored thereafter, so the second copy is dead.

'strict-dynamic' is the sharpest of these. It discards every host source in the directive and trusts only script loaded by already-trusted script. Without a nonce or a hash to establish that initial trust, nothing is trusted and nothing loads — which usually gets diagnosed as a broken deploy rather than a policy error.

CSP AnalyzerPaste a policy to get each directive explained and the gaps named — including the three that do not fall back to default-src.

There is no useful score for a policy. It is either appropriate for the page it protects or it is not, and a number invites tuning the number rather than closing the gap. What is worth having is a list of what each directive does and what is missing, which takes about a minute to read and is the whole review.

Tools for this

Next in this path

Nextno-cache does not mean no cache