Offscript
← All posts

CSP violation reports are a free breach sensor you’re already ignoring

Guides · Dominic Couture ·

If you ship a Content-Security-Policy header, you probably think of it as a hardening control: an allowlist that keeps injected scripts from running. There’s a second half to CSP that gets much less attention. Add a reporting directive and every browser that loads your app will file a report, from inside the user’s machine, when something on the page tries to load a script or open a connection the policy doesn’t allow. The rendered page is a layer your EDR, your WAF, and your server logs can’t see into, and it’s exactly where malicious extensions, tampering middleboxes, and injected scripts operate. Getting the reports costs one response header.

The catch is that the reports are mostly garbage, and that’s why almost everyone who enables reporting turns it off within a week. Turn on report-uri for an app with a few thousand users and you’ll get a pile of violations caused by coupon extensions, antivirus products, in-page translators, and bookmarklets someone wrote in 2011. Mixed into the garbage, though, are extensions and injected scripts phoning home from machines you will never otherwise get to inspect. This post is about telling the two apart.

What a violation report actually contains

When a browser blocks (or would block) a resource, it POSTs a JSON document to the endpoint named in the policy. With the legacy report-uri mechanism, reports arrive one violation at a time with Content-Type: application/csp-report:

{
  "csp-report": {
    "document-uri": "https://payroll.corp.example.com/employees",
    "referrer": "",
    "violated-directive": "script-src-elem",
    "effective-directive": "script-src-elem",
    "original-policy": "default-src 'self'; script-src 'self'; report-uri https://…",
    "disposition": "enforce",
    "blocked-uri": "https://cdn-sync-metrics.click/loader.js",
    "status-code": 200,
    "script-sample": ""
  }
}

The fields worth paying attention to:

  • document-uri is the page the user had open. On an internal app this tells you which screen, and roughly which data, was in front of whatever triggered the violation.
  • effective-directive tells you what kind of resource was involved. script-src-elem is a script tag. connect-src is a fetch(), XHR, WebSocket, or beacon, which means data leaving the page.
  • blocked-uri is where the browser was told to go, and the value you run reputation checks against.
  • disposition set to enforce means the load was actually blocked. report means the policy was delivered as Content-Security-Policy-Report-Only, so the load went through but you still heard about it.
  • source-file and line-number say where the request originated, when the browser knows. The scheme is the interesting part: a chrome-extension:// source file means an extension made the request.
  • script-sample holds the first 40 characters of a blocked inline script or style, populated only if the policy includes the 'report-sample' keyword. Forty characters isn’t much, but it’s often enough to recognize an injected snippet.

One page load can fire the same violation once per blocked element, and every user fires it again. A moderately busy app with a single policy mistake will generate tens of thousands of reports a day, so plan on aggregating before you plan on reading.

report-uri, report-to, and what browsers actually send

There are two delivery mechanisms, and you’ll be receiving both formats for years. report-uri is deprecated but supported essentially everywhere: one POST per violation, sent immediately, in the wrapped kebab-case format above. Its replacement is the Reporting API, where you declare a named endpoint in a Reporting-Endpoints response header and reference it with the report-to directive:

Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"
Content-Security-Policy: default-src 'self';
  report-uri https://reports.example.com/csp; report-to csp-endpoint

Reporting API deliveries look different. The browser batches reports and POSTs an array with Content-Type: application/reports+json, the field names switch to camelCase, and you also get the reporting user agent and the report’s age in milliseconds:

[
  {
    "age": 2163,
    "type": "csp-violation",
    "url": "https://payroll.corp.example.com/employees",
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) …",
    "body": {
      "blockedURL": "https://cdn-sync-metrics.click/loader.js",
      "documentURL": "https://payroll.corp.example.com/employees",
      "disposition": "enforce",
      "effectiveDirective": "script-src-elem",
      "originalPolicy": "default-src 'self'; …",
      "statusCode": 200
    }
  }
]

A few things to know before you build a receiver:

  • Send both directives. A browser that understands report-to ignores report-uri, and older browsers do the reverse, so declaring both doesn’t double-report anything. Chrome has supported the Reporting API for years, while Safari and Firefox got it much later and older releases of both still only speak report-uri.
  • Accept everything. Your endpoint will see application/csp-report, application/reports+json, and the occasional plain application/json from older engines. Don’t reject anything based on Content-Type, and normalize the two naming conventions into one schema.
  • Batching means delay. Reporting API deliveries can arrive up to a minute late, out of order, and sometimes after the user has left the page. Timestamp reports on your receiver and adjust by the age field.
  • blocked-uri isn’t always a URL. It can be inline, eval, data, blob, about, or an empty string, and old Firefox releases reported inline violations as self. These values tell you what kind of thing was blocked but nothing about where it came from.
  • You won’t always get a full path. Browsers strip the reported URL down to its origin in several cases, notably when the request was redirected, so build your tooling around hosts rather than paths.
  • Older WebKit omits fields, effective-directive in particular. Fall back to violated-directive when normalizing.
  • A policy in a <meta> tag can’t report at all. Reporting directives are ignored there. If you want the telemetry, the policy has to be a response header.

A field guide to the noise

Almost all of the volume traces back to a stable set of non-malicious sources:

  • Benign extensions. Ad blockers, coupon hunters, password managers, dark-mode themers, and grammar checkers all rewrite your pages. They show up as blocked-uri values of chrome-extension://… or moz-extension://…, or as inline style and script violations with an extension source-file. Chrome tries to exempt extension-injected content from your page’s CSP, but plenty leaks through, and other browsers leak more.
  • Security products. Desktop antivirus that intercepts TLS often injects its own script into every page it inspects. Kaspersky’s injections from gc.kis.v2.scr.kaspersky-labs.com show up in basically every collection of CSP reports. Corporate TLS-inspection proxies do the same thing with banner and telemetry scripts.
  • Network injection. ISPs, hotel Wi-Fi, and captive portals inject ads and data-cap warnings into plain HTTP pages. HTTPS prevents this, so on a modern internal app it’s rare, but any HTTP redirect page you still serve will generate reports from airport lounges.
  • Privacy tools. Blockers that stub out analytics rewrite tracker URLs to 127.0.0.1 or data: URLs. A connect-src violation pointing at localhost is almost always one of these.
  • In-page translation. Google Translate and similar tools pull translate.googleapis.com and related hosts into the page and trip half your directives in the process.
  • Bookmarklets and dev tools. Anything a user pastes into the URL bar or console arrives as inline and eval violations.
  • Everything else. Reports with a document-uri of about:blank, empty blocked-uri values, prefetchers, link-preview bots, and security scanners replaying your pages from odd origins. Don’t bother trying to diagnose these.

The good news is that this list barely changes. A few dozen filter patterns covering extension schemes, known AV hosts, translate endpoints, localhost stubs, and the keyword-only blocked-uri values will remove well over 95% of the volume, and what’s left after that is quiet enough to actually read.

The fraction that’s signal

At the end of 2024, a wave of popular Chrome extensions, Cyberhaven’s among them, had their developer accounts phished and data-harvesting updates pushed to millions of browsers overnight. An extension has full access to every page it runs on, but the harvested data still has to leave somehow. When the exfiltration happens from page context, through a fetch(), a beacon, or an injected script tag, your connect-src decides whether it goes through, and you get a report either way. Here’s what that looks like:

{
  "age": 4,
  "type": "csp-violation",
  "url": "https://payroll.corp.example.com/employees",
  "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/139.0.0.0 …",
  "body": {
    "blockedURL": "https://cdn-sync-metrics.click/v2/collect",
    "documentURL": "https://payroll.corp.example.com/employees",
    "disposition": "enforce",
    "effectiveDirective": "connect-src",
    "originalPolicy": "default-src 'self'; connect-src 'self'; …",
    "sourceFile": "chrome-extension://acmlbkgpdlfjmpcabjnbeojmeplcnjdb/content.js",
    "statusCode": 0
  }
}

There’s a lot in this one report:

  • What did it? The sourceFile scheme says an extension, and the 32-character ID in the URL can be looked up in the Chrome Web Store. If it’s been pulled from the store since, that tells you something too.
  • Where was the data going? cdn-sync-metrics.click was registered last month, serves no content, and shows up on two threat-intel lists.
  • What was exposed? The documentURL says the browser was on the payroll app’s employee list when the request fired.
  • Which machine? The user_agent plus the reporting IP on your endpoint narrows it down to a specific laptop, including unmanaged contractor laptops that have no EDR and never will.
  • Did it succeed? disposition: enforce means the browser blocked the request and reported it. In report-only mode the data got out, but at least you know it happened.

One caveat worth being upfront about: an extension that exfiltrates purely through its own background service worker never touches your page’s CSP, so this sensor doesn’t catch everything. A lot of real-world harvesting code does run in the page though, because that’s where the data is, and it phones home from there. A report like the one above is an incident lead that no other tool on that machine would have produced.

How to triage it yourself

  1. Normalize both formats into one record: document host, effective directive (falling back to violated directive), blocked host, source-file scheme, disposition, count, distinct reporters. Tolerate missing fields and strange Content-Types.
  2. Start in report-only mode if you aren’t enforcing yet. The telemetry is identical and nothing can break.
  3. Aggregate aggressively. Group by directive and blocked host per day. Raw report counts mostly reflect traffic; the number of distinct reporters is what actually tells you something.
  4. Spend the first week on your own policy. A violation that fires for every user is your app disagreeing with your policy. Fix the policy until the baseline is quiet.
  5. Build the noise allowlist from the list above: extension schemes you’ve reviewed, AV injection hosts, translate endpoints, localhost stubs, keyword-only blocked URIs.
  6. Alert on what’s left, in priority order. connect-src to a host you’ve never seen before comes first, since that’s the shape exfiltration takes. Then script-src to a new host, then extension source files on your most sensitive apps. Check domain age and reputation on every new host.
  7. Use distinct reporters to judge scope. One reporter means one suspect machine. Every reporter at once means something changed in your infrastructure, like a deploy, a middlebox, or a CDN configuration, rather than malware.
  8. Remember the endpoint is unauthenticated. Browsers can’t hold secrets, so anyone who knows the URL can POST made-up reports at it. Treat reports as leads and confirm on the machine or in the extension itself before declaring an incident.