cd /news/ai-safety/csp-trusted-types-residual-xss-readi… · home › topics › ai-safety › article
[ARTICLE · art-166790] src=dev.to ↗ pub= topic=ai-safety verified=true sentiment=· neutral

CSP, Trusted Types & Residual XSS: Reading Reports Like a Forensics Log

A principal software engineer at PixelValut documented a method for turning Content Security Policy and Trusted Types violation reports into a usable forensic trail, arguing that nonce-based strict policies with Trusted Types produce far fewer and more meaningful reports than hostname allowlists. After enabling reporting on a busy storefront, the engineer found the endpoint flooded with noise from browser extensions, translation overlays and ISP-injected scripts, but a handful of real signals surfaced, including an innerHTML sink fed from a URL fragment on a product page that CSP had silently blocked. The recommended approach sends violations to a reporting endpoint, stores them as structured events, filters noise by source and blocked URL, then groups survivors by directive, page and sample.

by read7 min views2 publishedNov 10, 2026

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

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.

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'``'strict-dynamic'`` require-trusted-types-for 'script'``innerHTML, outerHTML, document.write and eval, as long as it is not a Trusted Type. report-uri plus report-to Start in report-only mode, read for a week or two, fix the real issues, then enforce.

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 orenforce Enforce-mode reports mean an attack was actually blocked n/a

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.

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;

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.

Even a strict policy leaves gaps. Know them so a clean report stream does not give you false confidence:

javascript: URLs that slip past weak checks.object-src 'none' and base-uri 'none'. Reporting-Endpoints are configured. Report-only. Enforcing a policy you have not observed will break features, and teams then remove CSP entirely.

Extensions inject scripts and styles into pages. Filter their schemes and empty source files, but keep counts so you notice sudden changes.

It is deprecated in favour of report-to, but still widely supported. Sending both is the safe choice today.

No. It forces sink writes through policies you control. The policy still needs a sanitiser such as DOMPurify for user HTML.

Yes. Rate-limit by IP, cap body size, and sample if volumes spike. Reports are best-effort telemetry, not an audit log.

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.

── more in #ai-safety 4 stories · sorted by recency
── more on @pixelvalut 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/csp-trusted-types-re…] indexed:0 read:7min 2026-11-10 · —