Auditing a Script Policy for Gaps
Permalink to "Auditing a Script Policy for Gaps"Part of Unified Script Policy Architecture, this page is a checklist for the policy you inherited rather than the one you designed: how to read it for the weaknesses that actually matter, how to enumerate every script the page really loads, and how to score and sequence the fixes.
Quick Reference
Permalink to "Quick Reference"| Check | Gap to look for | What it lets an attacker do | Tier |
|---|---|---|---|
script-src keywords |
'unsafe-inline' with no nonce or hash present |
Run any injected inline script | P0 |
script-src keywords |
'unsafe-eval', 'unsafe-hashes' |
Turn a string sink into executed code | P0 |
| Host allow-list | Origin serving user uploads, or a JSONP endpoint | Load attacker bytes from a trusted origin | P0 |
| Nonce quality | Same nonce on two responses, or a build-time constant | Replay the nonce in an injected tag | P0 |
base-uri |
Directive absent (it does not inherit default-src) |
Re-point every relative script URL | P1 |
object-src |
Not set to 'none' |
Execute via <object> / <embed> |
P1 |
default-src |
No fallback directive at all | Everything script-src forgot is unrestricted |
P1 |
integrity |
External script tag with no attribute | Serve altered bytes from the allowed origin | P2 |
crossorigin |
integrity present, attribute missing |
Resource blocked outright, or silently unverified | P2 |
| Trusted Types | No require-trusted-types-for 'script' on a DOM-heavy app |
DOM XSS through innerHTML and friends |
P2 |
| Disposition | Only Content-Security-Policy-Report-Only in production |
Nothing is blocked; the policy is documentation | P0 |
Audit order: capture the served header, crawl the page, reconcile the two, score, then fix top-down.
The mental model
Permalink to "The mental model"An inherited policy is a claim about which scripts may run. An audit is the act of testing that claim against reality, and reality lives in two places the policy file never sees: the response headers your edge actually emits, and the set of scripts a real page load actually pulls. Almost every serious gap I have seen is a mismatch between those two, not a typo in a directive.
Read the header token by token and ask one question per token: what would an attacker need to control for this token to stop mattering? For 'unsafe-inline' the answer is “a single reflected parameter”. For *.cdn.example the answer is “any upload form on that CDN”. For a nonce baked into a build artefact the answer is “one view of the page source”. For a missing base-uri the answer is “one injected <base> tag”, after which every relative script URL on the page resolves against the attacker’s origin. Tokens that look defensive in isolation frequently cancel each other out — a nonce and an over-broad host list in the same directive means the host list is the real policy, because an attacker who cannot guess the nonce simply loads from the allowed host instead.
The second half of the model is coverage. A policy can be flawless on paper and still miss half the page, because the allow-list was written against the markup and the markup is no longer the whole story: tag managers inject scripts, an A/B tool rewrites the DOM, a vendor snippet loads a second vendor. Until you have enumerated what actually loads on a real page view, you are auditing intent, not enforcement.
Canonical example: enumerate what loads, then reconcile
Permalink to "Canonical example: enumerate what loads, then reconcile"Start with the header as served, not the one in the repository. Edges, WAFs and framework middleware all rewrite CSP, and the version you audit must be the version the browser parses.
curl -sS -D - -o /dev/null https://example.com/ | grep -i '^content-security-policy'
Then drive a headless browser and record every script request together with the attributes on each script element. Puppeteer’s request interception sees dynamically injected scripts that a static parse of the HTML never would.
// scripts/policy-gap.mjs — run: node scripts/policy-gap.mjs https://example.com/
import puppeteer from 'puppeteer';
const target = process.argv[2];
const browser = await puppeteer.launch();
const page = await browser.newPage();
const origins = new Set();
page.on('request', (req) => {
if (req.resourceType() === 'script') origins.add(new URL(req.url()).origin);
});
let csp = '';
page.on('response', (res) => {
if (res.url() === target) csp = res.headers()['content-security-policy'] ?? '';
});
await page.goto(target, { waitUntil: 'networkidle0' });
const tags = await page.$$eval('script[src]', (els) =>
els.map((el) => ({
src: el.src,
integrity: el.getAttribute('integrity'),
crossorigin: el.getAttribute('crossorigin'),
})),
);
const directive = csp.split(';').map((d) => d.trim())
.find((d) => d.startsWith('script-src')) ?? '';
const tokens = directive.split(/\s+/).slice(1);
console.log('script-src :', tokens.join(' ') || '(absent — falls back to default-src)');
console.log('observed :', [...origins].join(' '));
const self = new URL(target).origin;
for (const t of tags) {
const external = new URL(t.src).origin !== self;
if (external && !t.integrity) console.log(`NO INTEGRITY ${t.src}`);
if (t.integrity && t.crossorigin !== 'anonymous') console.log(`NO CROSSORIGIN ${t.src}`);
}
await browser.close();
The output splits the observed origins into three buckets, and each bucket is a different kind of finding. Origins that appear in both the policy and the crawl are covered. Origins that load but are not in the policy mean the policy is only surviving because something else is loose — usually 'unsafe-inline', a default-src wildcard, or a report-only header. Allow-list entries with no matching request are stale: nobody uses them, and every one of them is an unaudited execution path you are still granting.
Run the crawl against more than the homepage. Checkout, login, account settings and any page with an embedded third-party widget load different script sets, and a policy audited only against the marketing page will be wrong about the pages that hold the data. If the account is behind a login, seed the crawl with a session cookie via page.setCookie() rather than skipping those routes.
The scored audit table
Permalink to "The scored audit table"Copy this table into the audit ticket and fill in one row per check. Score each check 0 (absent or broken), 1 (partial — for example, an allow-list that is narrow but still contains one uploadable origin), or 2 (clean). Multiply by the weight, then divide the total by the maximum of 46 to get a single percentage you can track across quarters.
| # | Check | Fails when | Weight | Score 0–2 | Evidence |
|---|---|---|---|---|---|
| 1 | Enforcing header present | Only Content-Security-Policy-Report-Only is sent |
3 | header dump | |
| 2 | default-src fallback set |
No default-src directive at all |
2 | header dump | |
| 3 | No 'unsafe-inline' in script-src |
Keyword present with no nonce or hash alongside | 3 | header dump | |
| 4 | No 'unsafe-eval' / 'unsafe-hashes' |
Either keyword present | 3 | header dump | |
| 5 | Host allow-list free of user-content origins | Any origin serving uploads or previews | 3 | origin review | |
| 6 | Host allow-list free of JSONP endpoints | Any callback-reflecting path on an allowed origin | 3 | origin review | |
| 7 | 'strict-dynamic' present |
Host list is the effective policy on modern engines | 2 | header dump | |
| 8 | Nonce unique per response | Two loads return the same nonce | 3 | two curl runs |
|
| 9 | Nonce entropy ≥ 128 bits | Short, sequential or timestamp-derived value | 2 | nonce sample | |
| 10 | object-src 'none' |
Directive absent or permissive | 2 | header dump | |
| 11 | base-uri 'none' or 'self' |
Directive absent (no default-src fallback) |
2 | header dump | |
| 12 | Every external script has integrity |
Crawl reports NO INTEGRITY |
2 | crawl output | |
| 13 | Every integrity has crossorigin |
Crawl reports NO CROSSORIGIN |
2 | crawl output | |
| 14 | Trusted Types required | No require-trusted-types-for 'script' on a DOM-heavy app |
2 | header dump | |
| 15 | trusted-types policy list constrained |
Directive absent or set to * |
1 | header dump | |
| 16 | Reporting endpoint live | Violations produce no stored reports | 2 | collector query | |
| 17 | Policy covers non-homepage routes | Only one route was ever tested | 2 | crawl matrix |
Weights are deliberately coarse. The point is not precision, it is refusing to let seventeen small green rows outvote a single red one on check 3.
Remediation order by risk
Permalink to "Remediation order by risk"Sequence fixes by what the gap grants an attacker today, not by how quickly the ticket closes. Anything that permits execution right now is P0 and ships this week. Anything that gives an attacker a route around an otherwise sound policy is P1. Anything that would only matter after a separate compromise — a tampered CDN, an unguarded DOM sink — is P2, which is still real work but does not justify holding the P0 release.
The P0 fix is almost always the same shape: replace the host allow-list and the inline keyword with a per-request nonce and 'strict-dynamic', which makes supporting browsers ignore the host list entirely and trust only scripts loaded by an already-trusted script. Wiring that generation correctly is covered in Generating Per-Request CSP Nonces, and if the app has hundreds of inline blocks that cannot take a nonce, Migrating from unsafe-inline to Hash-Based CSP is the slower path. The P2 tier is where SRI lands: pin external bytes with SHA-384 and pair every hash with a CORS request.
<script src="https://cdn.example.com/widget.4f2c1a.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
nonce="{{ cspNonce }}"></script>
For vendor scripts that publish no stable build, the honest remediation is not a hash but relocation — see Self-Hosting Third-Party Scripts for the trade-offs.
Variants
Permalink to "Variants"Static header review with no crawl
Permalink to "Static header review with no crawl"When you cannot run a browser against the target — a customer’s production site, a policy in a pull request — you can still score checks 1 through 11 from the header alone. Paste it into Google’s CSP Evaluator, which flags known-bypassable hosts and missing directives, then confirm the nonce is per-response by fetching twice:
for i in 1 2; do
curl -sS -D - -o /dev/null https://example.com/ \
| grep -io "nonce-[A-Za-z0-9+/=_-]*"
done
Two identical values are a P0 finding. This path cannot score integrity coverage or route coverage, so record those rows as unknown rather than passing.
Enumerate from the page itself
Permalink to "Enumerate from the page itself"If the crawl harness is unavailable, the page can inventory itself from the console. Resource Timing records network fetches regardless of how the element was created:
performance.getEntriesByType('resource')
.filter((e) => e.initiatorType === 'script')
.map((e) => new URL(e.name).origin)
.filter((v, i, a) => a.indexOf(v) === i);
This misses inline scripts entirely and misses anything loaded before the snippet runs, so treat it as a cross-check on the crawl rather than a replacement. A headless dump is the middle ground:
google-chrome --headless --disable-gpu --dump-dom https://example.com/ \
| grep -o '<script[^>]*>' | sort -u
Keep the audit running in CI
Permalink to "Keep the audit running in CI"A one-off audit decays within a sprint. Run the reconciliation script as a scheduled job and fail it when an uncovered origin appears:
# .github/workflows/policy-audit.yml
on:
schedule:
- cron: '0 6 * * 1'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: node scripts/policy-gap.mjs https://example.com/ | tee gap.txt
- run: '! grep -q "NO INTEGRITY\|NO CROSSORIGIN" gap.txt'
Pair it with violation telemetry so a new origin shows up in reports before the weekly job notices — Collecting CSP Violation Reports with the Reporting API covers the collector side.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
A report-only header that never graduated is the most common finding. Teams roll out
Content-Security-Policy-Report-Only, tune it for a month, get busy, and leave it. Nothing is ever blocked, the dashboard looks healthy because violations are informational, and the policy is documentation with a header name. Grep the response for the enforcing header specifically; the presence of a CSP header proves nothing. -
base-uriandform-actiondo not fall back todefault-src. Neither doframe-ancestorsorsandbox. If your audit assumes a tightdefault-srccovers everything, it will pass a policy that lets an injected<base href>re-point every relative script URL on the page. Setbase-uri 'none'explicitly, or'self'if the app genuinely uses a base tag. -
integritywithoutcrossorigin="anonymous"is worse than no integrity. For a cross-origin URL the browser makes a no-CORS request, gets an opaque response it cannot hash, and blocks the resource withThe resource has been blocked because the integrity cannot be enforced. A same-origin script silently passes, so this defect frequently ships to production after testing fine on localhost. The mechanics are in How CORS and crossorigin Affect SRI. -
require-sri-foris not an available control. It was drafted for CSP Level 3 and implemented behind a flag in Chromium, then removed; no shipping browser enforces it today. If a policy in your estate contains it, treat the directive as inert and score integrity coverage from the markup instead. Combining require-sri-for with CSP covers the current status and the practical substitutes. -
Trusted Types support is uneven, so score it as depth, not as coverage. Enforcement shipped first in Chromium and Firefox added support more recently; WebKit’s implementation has lagged. That makes
require-trusted-types-for 'script'an excellent way to find unguarded sinks across your whole user base’s Chromium traffic and a poor way to claim DOM XSS is closed everywhere. Enforcing require-trusted-types-for script walks the rollout.
Verification Steps
Permalink to "Verification Steps"1. Confirm you audited the header that is actually served
Permalink to "1. Confirm you audited the header that is actually served"curl -sS -D - -o /dev/null https://example.com/ \
| grep -iE '^content-security-policy(-report-only)?:'
Expected output on a policy that has graduated out of report-only mode is a single enforcing line, optionally alongside a stricter report-only line:
content-security-policy: default-src 'none'; script-src 'nonce-r4Nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'
If only the -report-only variant comes back, stop the audit and record check 1 as a zero — every downstream row is moot.
2. Confirm the crawl saw every script
Permalink to "2. Confirm the crawl saw every script"node scripts/policy-gap.mjs https://example.com/
Cross-check the origin count against the DevTools Network panel filtered to JS on the same route. A crawl that reports fewer origins than the panel usually means a consent banner suppressed the vendor tags; accept the cookie in the harness and re-run before trusting the numbers.
3. Confirm the tightened policy blocks what it should
Permalink to "3. Confirm the tightened policy blocks what it should"Deploy the proposed policy in report-only alongside the current enforcing one, then load the route and count violations by directive:
curl -sS https://collector.example.com/api/reports?route=/checkout \
| jq -r '.[] | [.disposition, ."effective-directive", ."blocked-uri"] | @tsv' \
| sort | uniq -c | sort -rn
Every line with a report disposition is something the stricter policy would have blocked. Zero lines over a full traffic cycle is the signal to promote it to enforcement; the staged approach is detailed in Rolling Out a Script Policy in Report-Only Mode.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Is 'unsafe-inline' harmless when the policy also sends a nonce?
Browsers that understand nonces ignore 'unsafe-inline' whenever script-src also contains a nonce or a hash, so on current engines it is a compatibility fallback rather than an open door. It still deserves a finding: it hides real violations while you audit, it protects nothing on any engine that ignores nonces, and it invites the next developer to drop the nonce and keep the keyword.
How do I tell whether an allow-listed origin is actually safe?
Look for three things on that origin: a JSONP or callback endpoint that reflects a caller-supplied function name, any path that serves user-uploaded files, and open redirects that let an attacker reach either. Any one of them turns the allow-list entry into arbitrary script execution. Paste the header into Google’s CSP Evaluator, which carries a list of known-bypassable hosts.
Should I keep a report-only header after switching to enforcement?
Yes, but not the same policy. Send the current policy in Content-Security-Policy and the next, stricter one in Content-Security-Policy-Report-Only. Reports carry a disposition field of enforce or report, so your collector can tell which header fired. A report-only header duplicating the enforced policy generates noise and proves nothing.
Does adding integrity to every script close the allow-list gap?
No. Integrity pins bytes; the allow-list decides sources. They fail in different directions. A JSONP endpoint returns different bytes on every request, so it cannot be pinned at all, and an origin that serves user uploads still resolves to a hash you would happily have signed. Fix the source list and the byte pinning as separate findings.
What if the app writes to innerHTML everywhere and Trusted Types would break it?
Ship require-trusted-types-for 'script' in report-only first and read the script-sample field on the violations. That gives you a ranked list of sinks with real payload prefixes. Then register a default policy that sanitises with DOMPurify so legacy call sites keep working, and migrate the loudest sinks to explicit policies before you enforce.
Related
Permalink to "Related"- Layering CSP Nonces, SRI and Trusted Types — the target architecture your remediation plan is converging on, layer by layer
- Scoring Third-Party Script Risk — a companion rubric for ranking the vendors your allow-list still names
- Detecting Changes in Third-Party Scripts — catching the unpinnable vendor bundles that no audit table can freeze