Your CSP header looks correct. Cloudflare Page Shield shows scripts as detected. You might still be running unauthorized code.
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.
Browser extension background service workers are not subject to the page's Content Security Policy. They can:
- Fetch any external script content (their network requests bypass the page's
connect-src) - Pass the script text to
chrome.scripting.executeScript(injected into the page's main JS world) - Create an inline
<script>element withtextContent = fetchedCode - 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.
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)
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. Thesrc=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).
- 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
npm install
npx playwright install chromium# 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.jsWhen 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 -- 2The 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)
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.
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
world: MAINdoes not bypass CSP for the injected function itself — the function code runs, but<script src>elements it creates are still subject toscript-src. Path B bypasses CSP because the fetch happens in the service worker context (outside page CSP), not because ofworld: MAIN.- CSP directive specificity is respected per CSP Level 3 § 6.7:
script-src-elem→script-src→default-src(fallback). The analysis checks in this order. - CSP Level 3 §8.2 — nonce/hash neutralises
'unsafe-inline': When anonce-orhash-source is present in the effectivescript-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 nosrc=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 thesrc=attribute from the element, not from network traffic.
-
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
COOKIESenv 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_SCRIPTworld (Chrome 120+) is not tested. Extensions using theuserScriptspermission can inject into theUSER_SCRIPTworld, which is exempt from page CSP entirely — includingscript-src. This is a more powerful bypass than Path B and is not detectable via'unsafe-inline'analysis. If your threat model includes extensions usingUSER_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.
- W3C CSP Level 3
- OWASP CSP Cheat Sheet
- Chrome Extensions: chrome.scripting API
- Chrome Extensions: Content Scripts
- MDN: Content-Security-Policy
MIT — see LICENSE.