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.

Anatomy of a weak inherited script policy Four tokens from an inherited Content Security Policy header — an unsafe-inline keyword, a wildcard host allow-list, a static nonce, and absent object-src and base-uri directives — each annotated with the specific capability it hands to an attacker, all feeding into a single scored audit table. script-src 'unsafe-inline' host allow-list *.cdn.example nonce source 'nonce-BUILDID' absent object-src, base-uri any injected script runs inline any file on that host becomes executable reused value is replayable forever base tag re-points relative script URLs every token becomes one row of the scored audit table weight by blast radius, then remediate top-down

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.

Reconciling observed scripts against the policy A headless crawl network log, the Resource Timing entries and a DOM snapshot merge into one observed script set, which is compared with the policy directives and sorted into three buckets: covered origins, origins that load but are not allow-listed, and allow-list entries never used. headless crawl network log Resource Timing initiatorType DOM snapshot script[src] tags observed set origins + attributes reconcile set vs directive served header script-src tokens covered allowed and used uncovered loads, not listed stale entry listed, never used

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.

Remediation tiering decision tree Each audit finding is classified by what it enables into three tiers — immediate execution, a bypass route around the policy, and an integrity or sink gap — and each tier maps to a specific concrete remediation, after which the crawl is repeated. classify each finding by what it enables P0 execution today unsafe-inline, JSONP host P1 bypass route no base-uri, no object-src P2 integrity gap no SRI, no Trusted Types per-request nonce plus strict-dynamic object-src 'none' and base-uri 'none' integrity + crossorigin, then Trusted Types re-crawl after every tier — each fix moves scripts between buckets re-score the table and record the delta in the audit ticket

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-uri and form-action do not fall back to default-src. Neither do frame-ancestors or sandbox. If your audit assumes a tight default-src covers everything, it will pass a policy that lets an injected <base href> re-point every relative script URL on the page. Set base-uri 'none' explicitly, or 'self' if the app genuinely uses a base tag.

  • integrity without crossorigin="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 with The 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-for is 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.

Permalink to "Related"

Related Articles

Layering CSP Nonces, SRI and Trusted Types
Rolling Out a Script Policy in Report-Only Mode
Unified Script Policy Architecture Runtime Policy Enforcement & T…