The Content-Security-Policy header belongs to the class of things you configure once and never open again. It is served, it is syntactically valid, an automated evaluator gives it an acceptable grade. That is exactly why a policy can block working traffic for a long time without anyone noticing: the usual ways of checking it are structurally incapable of catching this class of defect.
What follows is one such case, on my own site. The request was narrow: run the header through CSP Evaluator and fix two errors. Both turned out to be empty. The real defect surfaced only once the check was done against browser behaviour instead of the header.
A dead allowlist entry is not a harmless leftover
The evaluator reported two error-level findings in script-src: a host known for its JSONP endpoints, and a host serving Angular builds. The bypass works the same way in both cases. If an attacker can inject a <script src> pointing at an allowed host, the policy lets it through — and from there a JSONP callback or Angular's template engine executes whatever the attacker needs. Formally 'self' and the hashes are in place; in practice the policy has a hole.
Reading the code gave a dull answer: the service those hosts existed for was disabled in configuration — empty tracker id, mode off. One of the two hosts did not appear anywhere in the codebase except the policy template itself. It had never been wired up at all.
Turning an integration off and removing its hosts is one change, not two of which the second is deferred forever. The reverse holds as well: bringing the integration back means restoring the hosts in both directives, script-src and connect-src. Otherwise the tag is blocked silently while the interface keeps offering a toggle that switches nothing on. Write that into the comment next to the directive — where the edit will be made, not into a ticket.
Why the usual check could not see the important part
The site runs on a deny-by-default model: an inline bootstrap sets every storage category to denied, and third-party tags are loaded by a separate same-origin script — only after the visitor clicks the consent button.
One consequence is easy to miss: before consent, the page sends no third-party request at all. Any check that ends at page load is therefore physically unable to see whether the allowlist covers what the site actually requests. It will report zero violations, and that will be true and beside the point.
| Check | What it sees | What it misses |
|---|---|---|
curl -I on the header | syntax, the host list | whether that list serves the actual requests |
| CSP Evaluator | bypassable hosts in script-src | it does not grade connect-src at all |
| Lighthouse, opening the page | violations before consent — there are none | everything that happens after consent |
nginx -t | that the file parses | nothing about behaviour |
The general form goes beyond analytics: any allowlist behind a consent gate or a feature flag can only be verified in the enabled state. A negative result in the default state proves nothing — neither that the policy is tight enough, nor that it is wide enough.
What the consent-granted measurement found
Measuring in the consent-granted state produced a violation on the article page that nobody had asked about:
Refused to connect because it violates the
document's Content Security Policy.
https://region1.google-analytics.com/g/collect
?v=2&...&en=page_view
The mechanism is this. Analytics does not send the event to one fixed host: from a European address it goes to the regional endpoint region1.google-analytics.com. What sat in connect-src was the exact host www.google-analytics.com, which does not cover the regional one.
A trap with lookalike names hides right here, and it is easy to fall for:
analytics.google.com != google-analytics.com
The policy did contain https://*.analytics.google.com, which created every impression that regional endpoints were covered. These are different domains, and it covers none of what is needed. The documentation prescribes https://*.google-analytics.com for measurement: it covers both www. and every regionN.. The advertising hosts are listed there too, but under a separate footnote — with advertising categories disabled, there is no reason to add them.
The fix came down to a single token:
- connect-src 'self' https://www.google-analytics.com ...
+ connect-src 'self' https://*.google-analytics.com ...
Two qualifications matter for an honest conclusion. First: img-src in this policy allows any https image, so the pixel transport was open — yet in the pre-fix measurement the only third-party request was the tag itself, with no successful call to analytics. The event was lost outright rather than taking a degraded path. Second: the narrow host had been in the configuration since its first commit, 73 days. But configuration history dates the configuration, not the size of the loss: how much of the traffic was European does not follow from this data, and claiming a number would be invention.
The method: a browser, a real click, the violation log
The check comes down to running the same chain a visitor runs:
- Start a headless browser with a debugging port.
- Open the page and let it load.
- Click the actual consent button rather than granting consent programmatically: from there the site's own code runs, and that is exactly the chain the allowlist has to cover.
- Collect third-party responses with their statuses, blocked requests with the blocking reason, and log entries about policy violations.
- Clear storage before each URL — otherwise the banner never reappears and the click quietly becomes a no-op.
Cover different page templates — an article, a section, a 404: the same web-server config sits behind them, but the markup differs. Green looks like "tag → 200, event → 204, violations 0".
The method has a trap of its own, and it nearly cost a false conclusion. The event does not leave immediately: with a five-second wait it is missing on most pages. That is a measurement artefact, not a defect — the working window is around fifteen seconds. The same artefact masks real violations: in the pre-fix run the refusal registered on one page out of three precisely because on the others the request had not left within the window.
A separate lesson, not about CSP: a requirement with no tool behind it does not get met. "Verify in the consent-granted state" had been written in internal documentation before — but no script existed, so no verification happened. What closes that gap is not a rule but an executable script with its failure branches exercised. Otherwise the promised exit code rests on code nobody ever ran. It is the same principle by which pipeline gates only mean something once you have watched them fail.
Two limits worth knowing in advance
nginx fails on token length, not header size. Hash every inline block and the hash list grows linearly with page count, until it hits a non-obvious limit: the config parser caps any single token, quoted strings included, at roughly 4 KB — not 32 KB. In practice nginx -t fails with too long parameter. The reflex to reach for large_client_header_buffers does not help: that setting concerns request headers, while the failure happens while parsing the configuration, before any traffic exists.
The cure is to hash only executable inline scripts. <script type="application/ld+json"> blocks are non-executed data, and script-src does not apply to them (verified on Chrome 150). When a single shared bootstrap is hashed, the hash set stays at one and does not grow with content.
strict-dynamic carries a cost the advice usually omits. The evaluator recommends it over enumerating hosts, and it fits dynamic tag loading well. But under it 'self' is ignored — which is not only a tightening. It removes the safety net across the window between deploying markup and refreshing hashes: a stale bootstrap hash means nothing executes at all, including the consent banner itself. On top of that, the switch changes templates, so it requires redeploying the whole page corpus. The decision depends on what is left in the policy after the cleanup: if nothing remains at error level, the price is not justified.
What to take away
CSP is a policy, and the same thing applies to it as to any policy as code: it describes a state that is supposed to match reality, and it diverges from reality silently. Three things are cheaper done straight away:
- pull the live config before editing — the generator may keep no backup of its own, and
nginx -tproves only that the file parses; - measure in the target state — consent granted, flag enabled — and across different page templates;
- check the host list against the vendor's documentation rather than from memory: regional endpoints and lookalike domains are the most common reason a policy looks correct and does not work.