Docs / Application security

Field guide · Application security

Application security

What a Content-Security-Policy actually stops

By Abhimanyu Gupta, Founder & Principal Operator

On this page

A Content-Security-Policy does one thing well: it turns an injected script into a blocked request and a report. It does not fix the injection, and the way most policies are written, it does not block much either. This is what a policy decides, why a list of allowed hosts usually leaks, the shape that holds up, and how to get one enforcing without taking the site down on a Friday.

Read with a purpose. Prefer a per-response nonce to a host allowlist, set base-uri and object-src while you are there, and roll out in report-only until the reports are quiet.

At a glance

What it is
A header telling the browser which sources of script, style and other content to allow
What it buys
An injected script that is blocked rather than executed, and a report saying it happened
The shape that works
A per-response nonce with strict-dynamic, not a list of allowed hosts
Why host lists fail
One host on the list that serves arbitrary code is the whole policy gone
What it is not
A fix for the injection. The bug is still there; the policy decides what it costs

What a policy actually decides

A Content-Security-Policy is a response header listing, per resource type, where the browser may load that resource from. The browser enforces it. Nothing in your application has to cooperate, which is the reason it works at all: the code that would run the injected script is the browser's, and the policy is instruction to the browser rather than to your framework.

A policy, read aloud
Content-Security-Policy: default-src 'self'; script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'self'

That says: by default load things from this origin only; run a script only if it carries this response's nonce or was loaded by a script that did; never load a plugin object; and never let an injected <base> tag change where relative URLs point.

The bug it turns into a blocked request

Cross-site scripting has a shape: attacker text reaches the page, the browser decides it is code, and it runs with the page's origin, its cookies and its session. A policy interrupts the third step. The text still arrives, the markup is still wrong, and the script does not execute.

That is worth being precise about, because it sets the expectation correctly:

  • It does not fix the injection. The template that concatenated untrusted text into HTML is still doing that, and the next thing it feeds might not be a script.
  • It does not make a vulnerability disappear. An assessment will still report the injection, and it should.
  • It changes what the bug is worth. An injection behind a strict policy needs a second bug to be useful, and it generates a violation report saying exactly where the first one is.

The shape that works

There are two ways to write a script policy, and only one of them holds up.

The first lists hosts: script-src 'self' https://cdn.example.com https://analytics.example. It is easy to write, it matches how people think about their dependencies, and it is usually bypassable, for reasons in the next section.

The second names the individual responses you trust. The server generates a random value per response, puts it in the header and on every script tag it meant to include, and adds 'strict-dynamic' so that scripts loaded by those trusted scripts are trusted too.

Nonce based, with a fallback for old browsers
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none'; base-uri 'none'

Those last two entries are not a mistake. A browser that understands 'strict-dynamic' ignores https: and 'unsafe-inline' entirely. A browser that does not gets a policy it can still act on. Writing both is how one header serves both, and it is why our CSP evaluator says the ignored keyword is ignored rather than flagging it.

The nonce has to be unpredictable and regenerated for every response. A fixed value that ships in a template is the same as no nonce at all, because the attacker can read it and include it.

Why a list of hosts leaks

A host allowlist says: any script served by these hosts is fine. That is a claim about everything those hosts will ever serve, and for a surprising share of them it is false.

  • Endpoints that echo a callback. A JSONP endpoint takes a parameter and reflects it into executable JavaScript. If the host is allowed, the attacker picks the code.
  • Content delivery networks that serve any version of anything. If a CDN will serve any package on request, an attacker chooses one with a known gadget, or an old framework that evaluates markup it finds in the page.
  • Hosts that serve user uploads. Object storage on a shared domain, a public repository mirror, a pages service where anyone can publish. The domain is yours in the policy and not in practice.

Measuring this was the point of the 2016 paper cited below: most deployed policies at the time could be bypassed, usually through exactly one entry the author had never thought about. Our evaluator carries the same list of usual suspects and names them rather than quietly scoring the policy well.

The directives nobody sets, and should

Attention goes to script-src, and three other directives decide whether it holds.

DirectiveWhat it stopsSensible value
base-uriAn injected base tag repointing every relative script URL, which defeats a host allowlist entirely'none' or 'self'
object-srcPlugin content, which historically executed script in ways script-src did not cover'none'
frame-ancestorsYour page being framed for clickjacking, the modern replacement for X-Frame-Options'none' or the origins that may frame you
form-actionAn injected form posting credentials to somewhere else'self'

A policy that sets these and nothing else is still worth having. A policy with a perfect script-src and no base-uri can be walked around.

Getting one deployed without breaking the site

The failure mode people fear is real: a policy that blocks something the site needs, in a corner of the site nobody tested. The answer is the report-only header, which enforces nothing and reports everything.

  1. Send Content-Security-Policy-Report-Only with the policy you want and a reporting endpoint.
  2. Leave it for long enough to cover the quiet parts of the application: the admin screens, the export, the thing finance uses once a month.
  3. Read what comes back. Expect noise from browser extensions, which inject into the page and are not your problem to allow.
  4. Fix what is genuinely yours, mostly by adding a nonce to inline scripts and moving inline event handlers into files.
  5. Switch the same policy to the enforcing header.

Report-only protects nothing. It is a measurement, and it is common to find a domain that has been in report-only for two years. If the reports have been clean for a fortnight, the change is one header name.

Reading the reports is the part that stalls people, because what arrives is a heap of JSON. Our CSP evaluator takes a paste of them and groups them into the handful of decisions they actually represent, and it will read a page's HTML and propose a policy shaped to what that page really loads.

What a policy does not do

  • DOM based injection. If your own trusted script takes attacker text and hands it to something that treats strings as code, the script doing it is trusted and the policy has no opinion. Trusted Types is the browser mechanism aimed at that specific gap.
  • Data exfiltration through allowed channels. Tightening connect-src and img-src narrows where stolen data can be sent, and it does not close it.
  • Anything outside the browser. Server side injection, access control, business logic: a policy is a client side control and sees none of it.
  • Making the report endpoint someone else's problem. Reports are posted by anyone who can reach the endpoint, including people sending you made up ones, so treat their contents as untrusted input.

The short version. Write the policy as a nonce with 'strict-dynamic' rather than a list of hosts, set base-uri, object-src, frame-ancestors and form-action while you are there, roll it out in report-only, read the reports until they are quiet, then enforce. And keep fixing the injection: the policy decides what the bug costs, not whether it exists.

References & further reading

  1. W3C, Content Security Policy Level 3. The specification, including the source expression grammar, the fallback chain and how 'strict-dynamic' changes the evaluation.
  2. L. Weichselbaum, M. Spagnuolo, S. Lekies, A. Janc, CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy, ACM CCS 2016. The measurement study behind the move from host allowlists to nonces.
  3. MDN Web Docs, Content-Security-Policy. Directive by directive reference, with browser support notes.
  4. W3C, Trusted Types. The mechanism aimed at DOM based injection, where the script doing the damage is one the policy already trusts.
  5. OWASP, Content Security Policy Cheat Sheet. Practical deployment notes, including the report-only rollout.
All guides Web app penetration testing

Want this tested on you?

Reading about it is one thing. Seeing it proven on your own systems is another.