Why most content-security policies block nothing
Most content-security policies in production were written to satisfy a scanner, not to stop an attack. An automated assessment flagged a missing header, and the ticket got closed by pasting a policy from a tutorial: a wildcard default-src, a script-src carrying 'unsafe-inline' and 'unsafe-eval', and a tail of CDN origins added one deadline at a time. The header is present. The check turns green. Nothing is blocked.
CSP exists to contain script injection. Cross-site scripting is the working assumption for any application that renders user-supplied data, and the policy is the browser-enforced boundary that decides what an injected payload is allowed to do. The directive that decides almost everything is script-src. The moment it carries 'unsafe-inline', an attacker who can inject markup can inject a script element and the browser will run it. Every other line in the header is decoration around a door left open.
The tell is provenance. A policy shaped by a compliance checklist looks like the checklist: header present, directives long, exceptions everywhere. A policy shaped by the application looks like the application: every script the app legitimately runs is named or nonced, and everything else is refused. The compliance and operations portal we built and operate for GRM LAW, a Brisbane law firm, runs the second kind: role-based access under a hardened content-security policy across the whole app. That header earns its place because it was written against the portal's real behaviour, not against a scan result.
What does a strict policy actually look like?
The core discipline is refusing 'unsafe-inline' for scripts. Every inline script the application ships either carries a nonce minted fresh on each response or is pinned by a hash, and script-src trusts the nonce rather than an origin list. Where a legitimately trusted script needs to pull in its own dependencies, 'strict-dynamic' extends trust through that loader instead of forcing you to enumerate a vendor's entire supply chain. Origin allowlists are the weakest form of the control; nonces bind trust to the exact response you just rendered.
Then there are the quiet directives that cost almost nothing and close whole attack classes. object-src 'none' shuts off plugin content nobody ships any more. base-uri 'self' stops an injected base element from silently rewriting every relative URL on the page. frame-ancestors pins who may embed the app, which is the modern clickjacking control, and 'none' is the right answer for most portals. form-action pins where forms may submit, so an injected form cannot quietly post harvested credentials to an attacker's host. Four lines, near-zero breakage, and each one removes a technique a tester would otherwise use.
The rest of the policy fences resources: connect-src for the APIs the front end may call, img-src, font-src and frame-src for what may render, and no wildcard default-src underneath waving through whatever you forgot. One thing a strict policy is not: a substitute for output encoding and input validation. CSP is the containment layer that limits the blast radius when a primary control fails, which is exactly why it deserves engineering effort rather than a pasted header.
What breaks when you tighten it?
Real things break, which is why permissive policies persist. Inline event handlers scattered through older templates stop firing. Framework bootstrap scripts that hydrate the page inline get refused until they carry a nonce. Libraries that inject style attributes at runtime collide with style-src. Tag managers stop injecting whatever was configured last quarter. Every one of these is a symptom of the same condition: script and style smeared through the markup instead of shipped as governed assets. Tightening the policy surfaces the debt; it does not create it.
Sequence the work so the cheap wins land first. frame-ancestors, object-src, base-uri and form-action rarely break anything and can usually be enforced on day one. Then fence resources: read the application's real network behaviour, pin connect-src, img-src and font-src to what it actually uses, and delete the wildcard default. Scripts and styles come last, because they are the point of the exercise and the most disruptive. Move inline handlers into modules, thread the nonce through the server rendering path, and take the vendor conversations one script at a time.
Budget it as an engineering change, not a header edit. On a server-rendered portal the nonce must be minted per response and carried through every template and component that emits a script tag, so the change touches the rendering layer, the design system and the deployment. Depending on how much inline behaviour the codebase carries, that is weeks of disciplined work, not an afternoon. It pays back every time a template bug that would have been an exploitable injection becomes a violation report instead.
Report-only is a migration tool, not a destination
The Content-Security-Policy-Report-Only header runs a policy without enforcing it. The browser evaluates every directive, blocks nothing, and sends a violation report to the endpoint you name. That gives you the migration pattern: keep the current permissive policy enforced, ship the strict candidate in report-only beside it, and watch what would have broken before anything does.
The reports need triage discipline. Browser extensions, antivirus proxies and injected middleware generate violations that have nothing to do with your code. Filter for violations sourced from your own origin that repeat across users, and let the one-off noise go. What remains is the true inventory of inline scripts, unpinned resources and forgotten third-party calls. That inventory is the work list.
Soak the candidate long enough to cover the application's rare paths: the month-end run, the reporting cycle, the admin screen someone opens twice a quarter. Then flip it to enforced. And name the failure mode plainly, because it is common: a policy that stays in report-only forever is not a control. A tester who sees Report-Only in the response header proceeds exactly as if there were no policy at all, because functionally there is not.
Design systems and other people's scripts
Your design system decides whether a strict policy is cheap or expensive. Component libraries that emit inline style attributes or runtime style tags drag 'unsafe-inline' back into style-src; components that emit inline script fragments do the same for script-src, which defeats the entire exercise. The structural fix: components ship styles as compiled stylesheets, behaviour as modules, and anything that must be inline receives the response nonce through the rendering pipeline. The GRM LAW portal's design system was built to live under its policy, which is the cheap way. Retrofitting one is the expensive way, and still worth doing.
Third-party scripts are where policies go to die. Every vendor tag is a delegation: someone else's build pipeline executing in your users' sessions, inside your origin. A tag manager is the extreme case, arbitrary script injection offered as a product feature, and allowlisting one hollows out the policy no matter how strict the rest of the header reads. The workable options are to self-host the vendor script where licensing allows, versioned and reviewed like any other dependency; to load it through a nonced loader with 'strict-dynamic' so trust flows deliberately; or to decline the tag and meet the underlying need first-party.
Be equally suspicious of broad CDN allowlists. A public origin that hosts thousands of libraries, including old framework builds with known script gadgets and JSONP endpoints that reflect a callback parameter, is not a safe origin to trust wholesale. It is a payload library you have pre-authorised. If a public CDN must stay, pin the exact paths you use. Better, bring the assets into your own build and delete the origin from the policy.
What does a pentest look for in your CSP?
The policy header is one of the first things we read on an engagement, before any payload is sent, because it tells us how far a foothold will travel. 'unsafe-inline' in script-src promotes any reflected or stored injection we find into full script execution in the victim's session. A missing frame-ancestors invites a clickjacking overlay on your sign-in. An open form-action lets an injected form post credentials elsewhere. Absent base-uri and object-src are the small doors a checklist forgets. A broad CDN allowlist gets checked for hosted gadgets that turn the allowlist itself into the exploit. Report-Only gets treated as absent, because it is.
We test the policy rather than admiring it. If the app uses nonces, we look for places the nonce leaks, repeats across responses, or is guessable. We look for the unpolicied corners: the error pages, the auth screens, the admin surface that never got the header even though the public pages did. Every finding is validated by hand and written up with reproduction steps and a concrete fix; we do not forward raw scanner output. On fixed-scope offensive work a re-test is included, so the policy you end up with is one we have already tried, and failed, to escape.
A CSP review rarely stands alone. It lands inside a penetration test or a secure code review, where the policy is read against the application it is supposed to contain. The method is on our approach page; the standards we hold ourselves to are on our trust page.
