Web Security

A Content-Security-Policy That Reports as Present and Defends Nothing

A script-src carrying 'unsafe-inline' passes every presence check and stops no injection. The theatre policy, the working one, and the war story of a strict CSP that broke sign-up with no error anywhere.

Webcuris Research

Security Engineering

·3 min read

A terminal row showing 'unsafe-inline' struck through with an arrow to 'nonce-…' 'strict-dynamic'

Most sites that have a Content-Security-Policy have one that does nothing. The header is present, every checker marks it green — and an injected <script> still runs, because one directive value quietly switched the protection off. That value is 'unsafe-inline', and it is in more policies than any other, because it is what makes a CSP stop breaking things during rollout. This article is about telling a policy that defends from a policy that merely exists.

The policy that changes nothing

present, green, and useless against injectionhttp
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'

The entire point of script-src is that markup an attacker injects into your page cannot execute. 'unsafe-inline' re-permits exactly the thing injections are made of — inline <script> blocks and event handlers — so the policy defends against an attacker polite enough to host their payload on their own domain and load it by URL, and nobody else. XSS payloads are inline almost by definition.

PolicyWhat a presence checker saysWhat an injected <script>alert(1)</script> does
No CSP at allMissing — redRuns
script-src 'self' 'unsafe-inline'Present — greenRuns
script-src 'nonce-…' 'strict-dynamic'Present — greenBlocked
Rows one and two differ in the report and not in the outcome. That gap is the whole problem.

The policy that works

A defending policy allows scripts by identity, not by location: each response carries a fresh random nonce, legitimate script tags carry it, and anything without it — which includes everything an attacker injects — is refused. 'strict-dynamic' lets your nonced scripts load the chunks a modern framework splits itself into, without enumerating hashed filenames:

the shape to aim forhttp
Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-R4nd0mPerRequest' 'strict-dynamic';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none'
  • The nonce must be per-request and unpredictable. A static one is a password printed on every page; generate it from a CSPRNG in middleware, never at build time.
  • A static inline script can be allowed by hash ('sha256-…') instead of a nonce — right for things like a pre-paint theme snippet that cannot change per request.
  • Styles are the honest exception. Frameworks inline critical CSS during streaming, and there is no nonce mechanism for it comparable to scripts; 'unsafe-inline' on style-src is a real but far narrower exposure — a style injection can reskin or leak a little via selectors, it cannot execute. Say so in a comment rather than letting someone "fix" it into a broken build.

How a strict CSP breaks things — silently

We run the nonce-based policy above on this site, and it bit us in the most instructive way possible. Our policy said frame-src 'none' — correct, right up until the day sign-up gained a CAPTCHA that renders its challenge in an iframe. The result was not an error. The widget mounted, printed "completing a quick automated check…", and stayed there forever. No console explosion anyone connected to a policy, no failed request with a helpful status — the token simply never arrived, so a perfectly healthy server refused every sign-up, and each half of the system looked innocent on its own.

That is the signature of CSP failures: absence, not error. The defence against it is procedural:

  1. Roll out with Content-Security-Policy-Report-Only first — the browser reports violations without enforcing, so you learn what would break before it does.
  2. When you tighten a directive, exercise the flows that depend on third parties: payments, CAPTCHAs, embedded video, support widgets. They are where the frames and remote scripts live.
  3. Widen by named origin, never by scheme. frame-src https: fixes the same symptom by letting any site on the internet be framed into yours.

How to verify beyond presence

Reading the header is the start, not the verdict — the same rule that applies to every security header you check:

read the directives, not the existencebash
curl -sI https://example.com | grep -i content-security-policy
# then actually look for: 'unsafe-inline' in script-src, wildcards,
# a missing object-src, a missing frame-ancestors

Then prove behaviour once, in staging: paste an inline alert(1) into any page that reflects input, with devtools open. A working policy produces a loud, satisfying refusal in the console. A theatre policy produces a dialog box and an action item.

This distinction is why a Webcuris scan reads your policy's directives rather than its presence — 'unsafe-inline' in script-src is reported as the finding it is, with the working replacement in the fix, not as a green tick for having sent a header. It sits alongside the HSTS check in the same free assessment.

Get the next one

One email when a new article goes out. No newsletter, no drip sequence, no sales follow-up.

Unsubscribe in one click. We never sell or share the address.

Keep reading

Contact

Talk to us.

Questions about what the engine checks, whether it fits your estate, or what it deliberately refuses to do. A person reads every message.

  1. 01You writePlain form, no qualifying call, no obligation. The marketing checkbox is optional and unticked.
  2. 02A person reads itMessages land with the team, not a queue-bot. Nothing is auto-replied.
  3. 03You get an answerTo the address you gave — including “this product is not the right fit”, when that is the honest answer.
Reporting a vulnerability?
Read the disclosure policy first — it tells you what is in scope and what to expect.
New messagereplies go to your email

We reply to this address, so a disposable one will not reach you.

+91

0 / 4000