Read this CSP: four policies, which one actually stops an XSS?
Four Content-Security-Policy headers that all look strict. Predict what each one really permits before you read the answers, then paste any of them into the CSP builder to confirm.
A Content-Security-Policy is written once and misread forever. The ones that look strict and permit everything are not the exception; they are the common case. So this is a reading drill. Below are four policies. For each, answer one question: if an attacker gets a string of their choosing into your page, does this policy stop it from running?
Decide for all four before the answers. When you want to confirm, paste any policy into the CSP builder, which names what each directive permits and what it will break.
The rule you need
A browser runs an injected <script> unless script-src forbids it. The traps are three: 'unsafe-inline' lets any inline script run, which is most of what XSS is; a bare https: or * source lets script load from anywhere; and a missing script-src falls back to default-src, so if that is loose, so is your script policy.
Policy 1
Content-Security-Policy:
default-src 'self';
img-src 'self' data:;
style-src 'self' 'unsafe-inline'
Policy 2
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline';
object-src 'none'
Policy 3
Content-Security-Policy:
default-src 'none';
script-src 'self';
connect-src 'self';
img-src 'self';
style-src 'self';
base-uri 'none'
Policy 4
Content-Security-Policy:
script-src 'self' https:;
object-src 'none'
Commit to four answers now.
Answers
Policy 1 stops it. There is no script-src, so script falls back to default-src 'self', which blocks both inline script and anything loaded from another origin. The 'unsafe-inline' here is on style-src, which loosens CSS, not JavaScript. Loose inline styles have their own smaller risks, but an injected <script> does not run. This one is fine on the question that matters.
Policy 2 does not stop it. It looks careful, with object-src 'none' and a locked default-src. But script-src explicitly includes 'unsafe-inline', which is the single thing you are trying to prevent. An injected inline script runs. This is the most common real-world mistake, usually added to make an analytics snippet or an inline handler work, and it quietly cancels the whole point of the header.
Policy 3 stops it, and it is the only genuinely tight one. default-src 'none' denies everything by default, each resource type is opened to 'self' only, there is no 'unsafe-inline' anywhere, and base-uri 'none' closes the <base> trick that can redirect relative script URLs. An injected script has no origin it is allowed to come from and no inline permission. This is the shape you are aiming for.
Policy 4 does not stop it. script-src 'self' https: reads as "my own site, over HTTPS", but https: means any origin reached over HTTPS. An attacker hosts their script on any HTTPS site they control and your page loads it, because your policy said HTTPS was enough. A scheme on its own is not a restriction. This is the squat-equivalent of CSP: it looks like a limit and is a wildcard.
What the score means
Four out of four: you read a policy by asking what script-src resolves to, not by counting directives, and the https: wildcard did not read as safe. Two or three: the usual miss is policy 4, because https: looks like a tightening when it is the opposite, or policy 1, where people see 'unsafe-inline' and stop reading without checking which directive it is on. One or zero: you were judging the policies by length, and length is exactly what the loose ones have plenty of.
The reading habit: find script-src first, and if it is absent, read default-src in its place. Then check for 'unsafe-inline' on that directive, and for any source that is a bare scheme or a *. If either is there, the policy permits the attack no matter how many other directives it lists.
Your CSP probably does nothing is the full version of this argument, and the CSP builder will take a policy you already ship and tell you what it really allows.