The first time I turned on Content Security Policy reporting for a busy storefront, the endpoint received tens of thousands of reports in a day. Almost all of them were noise: browser extensions, translation overlays, injected ad scripts from someone's ISP. Buried in that pile were a handful of reports that mattered, including one innerHTML sink fed from a URL fragment on a product page. Nobody had noticed it because CSP had quietly blocked the payload. That is the good news and the trap of CSP: it can stop an attack and tell you nothing unless you read the reports properly.
I wrote this with help from AI tools and checked every header, payload and query myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET, and I build PixelValut, a set of browser-side developer tools. This is Part 4 of the series Client Trust & Agent Hijack Defense. Part 1 covered prompt injection in fetched docs, Part 2 covered tool-calling attack surfaces and Part 3 covered SRI and CDN pinning. SRI checks the scripts you chose. CSP and Trusted Types decide what else is allowed to run, and their reports are your evidence trail.
๐ Series Roadmap โ Client Trust & Agent Hijack Defense
- Part 1: Prompt Injection via Docs Your Tools Fetch
- Part 2: WebMCP & Tool-Calling Attack Surfaces
- Part 3: Subresource Integrity & CDN Script Pinning
- Part 4 (This Post): CSP, Trusted Types & Residual XSS: Reading Reports Like a Forensics Log
- Part 5: Extension & npm Postinstall Forensics: Homoglyphs, Base64 & Unexpected Network
- Part 6: Browser Secrets That Leak: Tokens, Storage Quotas & Exfil Paths
Related earlier posts: Deobfuscating Malicious npm & Browser Payloads ยท ReDoS in JS & PHP: Spotting Catastrophic Backtracking ยท OAuth 2.1 & OIDC Forensics in SPAs
How do you use CSP violation reports to find real XSS?
Direct answer: Send CSP and Trusted Types violations to a reporting endpoint, store them as structured events, and filter out extension and injected-script noise by source and blocked URL. Then group what remains by directive, page and sample. Inline-script and Trusted Types sink violations from your own pages are the strongest signals of a real injection path.
A policy that produces useful reports
Allowlist policies full of hostnames are both weak and noisy. A nonce-based strict policy with Trusted Types gives fewer, more meaningful reports:
Content-Security-Policy-Report-Only:
script-src 'nonce-R4nd0mPerResp0nse' 'strict-dynamic' 'report-sample';
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'self';
require-trusted-types-for 'script';
trusted-types app-html dompurify;
report-uri /csp/report;
report-to csp
Reporting-Endpoints: csp="https://shop.example/csp/report"
(Headers are shown wrapped for readability; send them on one line.)
What each piece buys you in reports:
-
'report-sample'includes the first 40 characters of the blocked inline script, event handler or sink value. Without it, you are guessing. -
'strict-dynamic'lets nonce-trusted scripts load their own dependencies, so you stop getting reports for every analytics sub-resource. -
require-trusted-types-for 'script'makes the browser report every string passed to DOM XSS sinks such asinnerHTML,outerHTML,document.writeandeval, as long as it is not a Trusted Type. -
report-uriplusreport-tocovers both the legacy format and the Reporting API. Browsers that supportreport-touse it and ignorereport-uri.
Start in report-only mode, read for a week or two, fix the real issues, then enforce.
Anatomy of a report
The legacy format arrives as application/csp-report:
{
"csp-report": {
"document-uri": "https://shop.example/product/42?ref=mail",
"violated-directive": "require-trusted-types-for",
"effective-directive": "require-trusted-types-for",
"blocked-uri": "trusted-types-sink",
"script-sample": "Element innerHTML|<img src=x onerror=fetch(",
"source-file": "https://shop.example/assets/product.8c1f.js",
"line-number": 1,
"column-number": 18342,
"disposition": "report",
"status-code": 200
}
}
The Reporting API sends a batch as application/reports+json, an array of objects with type: "csp-violation" and a body containing camel-cased fields: documentURL, blockedURL, effectiveDirective, sample, sourceFile, lineNumber, columnNumber, disposition. Normalise both into one shape before storage.
How to read the key fields like evidence:
| Field | What it tells you | Real-attack signal | Typical noise |
|---|---|---|---|
effective-directive |
Which rule fired |
script-src-elem, script-src-attr, require-trusted-types-for on your pages |
img-src, font-src from extensions |
blocked-uri |
What was blocked |
inline, eval, trusted-types-sink, unknown external hosts |
chrome-extension, moz-extension, safari-web-extension schemes |
script-sample |
First 40 chars of payload |
onerror=, javascript:, fetch(, atob(
|
Extension bootstrap code, translation widgets |
source-file + line/col |
Script that hit the sink | Your own bundle | Empty, or extension URLs |
document-uri |
Page and query string | Encoded payloads in query or fragment-driven routes | Cached old pages, preview bots |
disposition |
report or enforce
|
Enforce-mode reports mean an attack was actually blocked | n/a |
Decoding the evidence
Attack payloads usually arrive percent-encoded, sometimes twice. A document-uri like ?q=%253Cimg%2520src%253Dx%2520onerror%253D... is a double-encoded image tag. Decode one layer at a time so you can see what the attacker tried and which parameter they used. I paste these into the PixelValut URL encoder/decoder and decode repeatedly until it stops changing. If the decoded value appears in a Trusted Types sample from your own bundle, you have found the sink and the source.
Also check that the policy is actually on every response. CSP set only on the main HTML route often misses error pages, redirects and legacy paths, and attackers aim for exactly those. The HTTP status checker shows status, redirect chain and headers for a URL, or from the terminal:
for p in / /product/42 /search?q=test /this-does-not-exist /account/login; do
printf '%-24s ' "$p"
curl -s -o /dev/null -D - "https://shop.example$p" \
| grep -ciE '^content-security-policy(-report-only)?:'
done
Any 0 is a page without a policy.
A small collector with noise filtering
You do not need a vendor to start. This Node collector accepts both formats, drops obvious noise and stores the rest:
import express from 'express';
const app = express();
app.use(express.json({ type: ['application/csp-report', 'application/reports+json', 'application/json'], limit: '64kb' }));
const NOISE = /^(chrome|moz|safari-web|ms-browser)-extension|^about$|^data$/;
function normalise(r) {
const b = r['csp-report'] ?? r.body ?? {};
return {
page: b['document-uri'] ?? b.documentURL,
directive: b['effective-directive'] ?? b.effectiveDirective,
blocked: b['blocked-uri'] ?? b.blockedURL ?? '',
sample: b['script-sample'] ?? b.sample ?? '',
source: b['source-file'] ?? b.sourceFile ?? '',
line: b['line-number'] ?? b.lineNumber,
col: b['column-number'] ?? b.columnNumber,
disposition: b.disposition,
};
}
app.post('/csp/report', (req, res) => {
const items = Array.isArray(req.body) ? req.body : [req.body];
for (const raw of items) {
const r = normalise(raw);
if (NOISE.test(r.blocked) || NOISE.test(r.source)) continue;
console.log(JSON.stringify({ type: 'csp', ...r, ts: Date.now() })); // ship to your log store
}
res.sendStatus(204);
});
app.listen(8080);
Rate-limit the endpoint and never trust report content: it is attacker-controlled input. Do not render samples as HTML in your dashboard, or your report viewer becomes the next XSS target.
Then group, newest first:
SELECT directive, page_path, left(sample, 40) AS sample, source, count(*) AS hits,
max(ts) AS last_seen
FROM csp_reports
WHERE ts > now() - interval '7 days'
GROUP BY 1, 2, 3, 4
ORDER BY hits DESC
LIMIT 50;
Trusted Types: turning sinks into reviewable code
Once you see sink violations, fix them at the source instead of loosening the policy. Create a small number of named policies and route all HTML through them:
const appHtml = window.trustedTypes?.createPolicy('app-html', {
createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
});
el.innerHTML = appHtml ? appHtml.createHTML(userBio) : DOMPurify.sanitize(userBio);
Now the only code that can write HTML is in one reviewable place. Creating a policy that is not on the trusted-types list triggers its own violation, which is a useful alert for a dependency trying to add a permissive policy. Chromium-based browsers have supported Trusted Types for years and other engines have been adding support, so check current compatibility and keep sanitising in the fallback path.
What CSP does not stop: residual XSS
Even a strict policy leaves gaps. Know them so a clean report stream does not give you false confidence:
- Script gadgets. Framework code that turns attribute values into behaviour can execute attacker markup through trusted scripts.
-
Allowlisted hosts with JSONP or user uploads. If an allowlisted domain serves attacker-controlled JavaScript, CSP approves it. Nonces with
'strict-dynamic'avoid host lists. - Nonce reuse. A nonce must be random per response. Cached HTML with a fixed nonce makes the policy decorative.
- Dangling markup and HTML injection can steal data without running any script.
-
Logic bugs in your own trusted code, such as open redirects to
javascript:URLs that slip past weak checks.
Diagnostic checklist
- [ ] CSP is present on every response, including errors and redirects.
- [ ] Policy uses per-response nonces with
'strict-dynamic', plusobject-src 'none'andbase-uri 'none'. - [ ]
'report-sample'is enabled so samples arrive with reports. - [ ] Both
report-uriandreport-towithReporting-Endpointsare configured. - [ ] Collector accepts both formats, filters extension noise and rate-limits.
- [ ] Report data is treated as untrusted and never rendered as HTML.
- [ ] Reports are grouped by directive, page, sample and source weekly.
- [ ] Encoded payloads in page URLs are decoded layer by layer.
- [ ] Trusted Types runs in report-only first, with named policies listed.
- [ ] Enforcement is turned on after real violations are fixed.
FAQ
Should I start with enforcing CSP or report-only?
Report-only. Enforcing a policy you have not observed will break features, and teams then remove CSP entirely.
Why do most of my reports come from extensions?
Extensions inject scripts and styles into pages. Filter their schemes and empty source files, but keep counts so you notice sudden changes.
Is report-uri deprecated?
It is deprecated in favour of report-to, but still widely supported. Sending both is the safe choice today.
Does Trusted Types replace sanitisation?
No. It forces sink writes through policies you control. The policy still needs a sanitiser such as DOMPurify for user HTML.
Can attackers flood my report endpoint?
Yes. Rate-limit by IP, cap body size, and sample if volumes spike. Reports are best-effort telemetry, not an audit log.
How long until I can enforce?
Usually one or two weeks of clean report-only data across your main pages and browsers is enough to switch on enforcement.
Next in this series, Part 5 covers extension and npm postinstall forensics: homoglyphs, Base64 blobs and unexpected network calls.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal

Top comments (0)