DEV Community

Cover image for CSP, Trusted Types & Residual XSS: Reading Reports Like a Forensics Log
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

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

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

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.

Dim concrete vault corridor with a single strip light and a closed steel door, AI-generated

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"
Enter fullscreen mode Exit fullscreen mode

(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 as innerHTML, outerHTML, document.write and eval, as long as it is not a Trusted Type.
  • report-uri plus report-to covers both the legacy format and the Reporting API. Browsers that support report-to use it and ignore report-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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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', plus object-src 'none' and base-uri 'none'.
  • [ ] 'report-sample' is enabled so samples arrive with reports.
  • [ ] Both report-uri and report-to with Reporting-Endpoints are 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

Top comments (0)