Skip to content

Repository files navigation

csp-bypass-verifier

Your CSP header looks correct. Cloudflare Page Shield shows scripts as detected. You might still be running unauthorized code.

The Problem

You've deployed CSP on your site and enabled Cloudflare Page Shield. Page Shield's inventory shows external scripts from unknown domains as "detected". You check your script-src — those domains aren't in it. The browser should be blocking them. You feel protected.

There's a gap you can't see from the Page Shield dashboard.

Page Shield (and DOM-based script monitors like it) detect scripts by observing <script src="..."> elements in the page DOM. When a browser extension injects a <script src> tag, Page Shield records the src= URL — regardless of whether the browser actually loaded the script. A blocked script and an executed script look identical in the inventory. "Detected" does not mean "blocked".

What Page Shield cannot see: a second injection path that bypasses CSP entirely, produces no src= attribute, and fires no CSP violation. No inventory entry appears. No alert triggers. The execution is silent.

The Bypass

Browser extension background service workers are not subject to the page's Content Security Policy. They can:

  1. Fetch any external script content (their network requests bypass the page's connect-src)
  2. Pass the script text to chrome.scripting.executeScript (injected into the page's main JS world)
  3. Create an inline <script> element with textContent = fetchedCode
  4. Append it to the page

Step 4 creates an inline script — no src= URL, no network request from the page context. If the page's CSP includes 'unsafe-inline', the script executes. The script monitor sees no src= attribute. No CSP violation fires. The execution is silent.

Path A  <script src="external.com/payload.js">
  ↓ CSP checks script-src       → BLOCKED ✓
  ↓ Monitor observes src= in DOM → RECORDED ✓  (appears in inventory)

Path B  background SW fetches external.com/payload.js
        → injects <script textContent="...code...">
  ↓ CSP checks: is this inline?  → 'unsafe-inline' present → ALLOWED ✗
  ↓ Monitor observes: no src=    → NOT RECORDED ✗  (invisible)

'unsafe-inline' is the root cause. Per W3C CSP Level 3, 'unsafe-inline' allows execution of inline <script> elements and event handlers. OWASP explicitly recommends avoiding it — it negates CSP's core XSS protection. When present alongside a browser extension that uses background fetch + inline injection, it creates a silent execution path that compliance monitoring cannot detect.

What this looks like in Page Shield: Path B leaves no trace in your inventory. The bypass executes silently, your dashboard stays clean, and your CSP header still looks correct. The only way to know whether you're actually protected is to test both paths.

This tool automates that test.

What It Tests

Six test cases across three groups:

TC Group What it checks
TC-1 PATH-A <script src> injection is blocked by script-src CSP
TC-2 PATH-A Blocked src= URL is observable by DOM-based script monitors
TC-3 PATH-B Background fetch + inline injection executes despite CSP
TC-4 PATH-B Inline execution fires no CSP violation (invisible to monitors)
TC-5 CSP policy 'unsafe-inline' is effectively active — not neutralised by nonce/hash (CSP L3 §8.2)
TC-6 CSP policy Strict mode absent — 'strict-dynamic' + nonce/hash not deployed

TC-5 and TC-6 are static policy analysis — they appear in Phase 1 (policy scan) and Phase 3 (diagnosis), not in the live verification table. TC-1 through TC-4 are browser-observed and appear in Phase 2.

Verdict logic:

  • TC-3 confirmed + TC-5 confirmed → CSP BYPASS CONFIRMED (exit code 1)
  • TC-1 confirmed + TC-2 confirmed + TC-5 safe + TC-6 safe → STRONG CSP (exit code 0)
  • TC-1 confirmed + TC-2 confirmed + TC-5 safe → CSP enforcing correctly (exit code 0)
  • TC-3 confirmed + TC-5 safe → INCONSISTENT — CSP header may not match the loaded page (exit code 2)
  • Report-only policy → REPORT-ONLY — policy logs but does not block (exit code 2)
  • Partial / navigation failure → PARTIAL (exit code 2)

How It Works

The tool runs in three sequential phases:

Phase 1 — Policy Scan (before browsers launch): fetches the CSP header via fetch() and immediately prints a static analysis — whether 'unsafe-inline' is effectively active, whether strict-dynamic is deployed, and whether the policy is in enforce or report-only mode. This gives instant feedback before the ~15 second browser wait.

Phase 2 — Live Verification: two Chrome windows open side by side, each loaded with a different extension.

  • Left — Path A (path-a-script-src): injects <script src="..."> elements. CSP blocks these. The src= URL is observable in the DOM (TC-1, TC-2).
  • Right — Path B (path-b-inline-bypass): background service worker fetches script content, injects as inline <script textContent>. Executes silently if 'unsafe-inline' is present (TC-3, TC-4).

A sentinel (window.__cspTestPathB) is set before the fetched code runs, confirming execution independently of the fetched script's behaviour.

┌──────────────────────────────┐  ┌──────────────────────────────┐
│  Chrome — Path A             │  │  Chrome — Path B             │
│  extension: path-a-script-src│  │  extension: path-b-inline    │
│                              │  │                              │
│  Network: (blocked:csp)      │  │  Network: (nothing from page)│
│  Console: ✗ blocked by CSP   │  │  Console: inline injected    │
│  Sentinel: not set           │  │  Sentinel: ✅ confirmed      │
└──────────────────────────────┘  └──────────────────────────────┘

Phase 3 — Diagnosis: cross-references the policy analysis with the live results. For a confirmed bypass, prints what happened, what it's invisible to (Cloudflare Page Shield), the PCI-DSS impact, and a copy-paste CSP fix. TC-6 (strict mode absent) is presented as an additional hardening recommendation — not as a co-equal finding to the root cause (TC-5).

Requirements

  • Node.js 18+
  • macOS or Linux with a display (Chrome extensions require a visible window)
  • On Linux CI: xvfb-run node verify-csp-bypass.mjs

Setup

npm install
npx playwright install chromium

Usage

# Test any page — TARGET_URL is required
TARGET_URL="https://your-site.com/checkout" npm run verify

# Tune wait time (default: 8000ms — time for background SW to fetch and inject)
TARGET_URL="https://your-site.com/checkout" WAIT_MS=5000 npm run verify

# Authenticated pages: pass cookies so Phase 1 pre-flight fetch sees the real CSP header.
# Without this, the pre-flight fetch may hit a login redirect and miss the policy.
# Copy the value from DevTools → Network → any request → Request Headers → Cookie.
COOKIES="session=abc123; _csrf=xyz" TARGET_URL="https://your-site.com/checkout" npm run verify

# Capture evidence for security audit / compliance report
EVIDENCE=1 TARGET_URL="https://your-site.com/checkout" npm run verify

# Use local mock server for offline testing
npm run mock-server
# Then update TEST_SCRIPTS in extensions to http://localhost:7777/mock-script-a.js

Evidence Capture

When EVIDENCE=1 is set, each run writes to evidence/{timestamp}-{hostname}/:

File Contents
report.json Structured findings — TC results, CSP analysis, verdict
path-a-summary.png Annotated screenshot: <script src> blocked by CSP
path-a-trace.zip Full Playwright trace: Network + Elements + Console replay
path-b-summary.png Annotated screenshot: inline bypass result, CDN request count
path-b-trace.zip Full Playwright trace: proves no page-level network requests
path-b-requests.json CDN network request log (empty array = invisible to monitors)

Viewing traces: Open trace.playwright.dev in Chrome and drop any .zip file, or use the local viewer:

# List all evidence runs
npm run show-evidence -- list

# Open latest run in local Playwright trace viewer (no internet required)
npm run show-evidence

# Open a specific run (by index from list)
npm run show-evidence -- 2

The local viewer starts at http://localhost:9323 (one port per trace file).

Exit codes: 0 = no bypass detected · 1 = bypass confirmed · 2 = inconclusive (report-only, navigation failure, or inconsistent results)

The Fix

Remove 'unsafe-inline' from script-src. Replace with per-request nonces (preferred for dynamic pages) or hashes (preferred for static inline scripts).

# Before (vulnerable — 'unsafe-inline' effectively active, Path B bypass works)
Content-Security-Policy: script-src 'self' 'unsafe-inline' cdn.example.com;

# Step 1 — add nonce (disables 'unsafe-inline' per CSP Level 3 §8.2)
# Server generates a cryptographically random nonce per HTTP response
Content-Security-Policy: script-src 'self' 'nonce-{server-random}' cdn.example.com;

# Step 2 — add strict-dynamic (removes need to allowlist CDN hosts)
# NOTE: strict-dynamic alone does NOT disable 'unsafe-inline' — the nonce does
Content-Security-Policy: script-src 'nonce-{server-random}' 'strict-dynamic';

CSP Level 3 §8.2: When a nonce- or hash- source is present, browsers ignore 'unsafe-inline' — even if the header still lists it. Adding a nonce to an existing policy immediately hardens it. 'strict-dynamic' alone does not neutralise 'unsafe-inline'; the nonce or hash is what does that.

Project Structure

verify-csp-bypass.mjs      # Entry point — pre-flight fetch, browser launch, phase orchestration
show-evidence.mjs           # CLI viewer — opens Playwright traces in local browser
mock-server.mjs             # Local HTTP server for offline testing
src/
  browser.mjs               # Playwright browser launchers (Path A + Path B)
  policy.mjs                # CSP header parsing + analysis (W3C CSP Level 3)
  results.mjs               # TC evaluation, verdict derivation, Phase 1-2 rendering
  diagnosis.mjs             # Phase 3 rendering — diagnosis, PCI impact, fix guidance
  colors.mjs                # ANSI helpers + ANSI-aware padding, no external deps
  evidence.mjs              # Evidence capture — tracing, screenshots, report.json
extensions/
  path-a-script-src/        # Extension: <script src> injection (blocked, observable)
    manifest.json
    content.js              # Isolated world injection
    background.js           # MAIN world injection via chrome.scripting
  path-b-inline-bypass/     # Extension: background fetch + inline (bypasses CSP)
    manifest.json
    background.js

Technical Notes

  • world: MAIN does not bypass CSP for the injected function itself — the function code runs, but <script src> elements it creates are still subject to script-src. Path B bypasses CSP because the fetch happens in the service worker context (outside page CSP), not because of world: MAIN.
  • CSP directive specificity is respected per CSP Level 3 § 6.7: script-src-elemscript-srcdefault-src (fallback). The analysis checks in this order.
  • CSP Level 3 §8.2 — nonce/hash neutralises 'unsafe-inline': When a nonce- or hash- source is present in the effective script-src, browsers ignore 'unsafe-inline' entirely. TC-5 accounts for this — it only flags a vulnerability when 'unsafe-inline' is present and no nonce/hash is present to neutralise it.
  • Report-only policies (Content-Security-Policy-Report-Only) log violations but do not block execution. The tool distinguishes enforce vs report-only and flags report-only-only configurations separately.
  • DOM monitors detect scripts by observing src= attributes on <script> elements. Path B produces no src= URL — structurally invisible to this detection method (TC-4 confirms this).
  • (blocked:csp) in DevTools Network tab means the request never left the browser. The URL still appears in DOM-based monitors because they read the src= attribute from the element, not from network traffic.

Known Limitations

  • Phase 1 policy scan requires cookies for authenticated pages. The pre-flight CSP fetch runs without browser cookies by default. Pass session cookies via the COOKIES env var (copy from DevTools → Network → Request Headers → Cookie) to enable Phase 1 on pages behind authentication. Without cookies, the fetch fails gracefully and browsers still launch for Phase 2-3 evaluation.

  • MV3 USER_SCRIPT world (Chrome 120+) is not tested. Extensions using the userScripts permission can inject into the USER_SCRIPT world, which is exempt from page CSP entirely — including script-src. This is a more powerful bypass than Path B and is not detectable via 'unsafe-inline' analysis. If your threat model includes extensions using USER_SCRIPT, a nonce-based CSP is still the correct fix (it prevents inline execution regardless of injection world).

  • Assumes Chromium-based browsers. Extension injection behaviour is Chrome/Chromium MV3 specific. Firefox has different extension APIs and CSP interaction.

  • Does not test hash-source inline scripts. If your page uses sha256- hashes for specific inline scripts, the tool will correctly detect them as neutralising 'unsafe-inline', but won't verify whether your hash values are correct.

References

License

MIT — see LICENSE.

About

Does your CSP block browser extension script injection — or does Cloudflare Page Shield just make it look that way? Tests both paths: blocked-but-visible (script src) and silent bypass (background fetch + inline).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages