A Content Security Policy (CSP) is a response header that tells the browser which scripts, styles, frames and connections a page may use. Anything outside the list is blocked. That is the point of CSP, and it is also its quietest failure mode: when a strict policy blocks something you wanted, the only trace is a line in the visitor's DevTools console. Your server sees nothing.
Breachloom, a browser-based web security training site, runs a strict policy: script-src 'self' plus three named hosts, no inline scripts, no 'unsafe-inline', no 'unsafe-eval'. Two things broke behind that policy, and both went unnoticed until violation reporting was switched on.
Silent block #1: the analytics beacon that never loaded
Cloudflare Web Analytics injects its beacon script into every HTML page served through Cloudflare. The beacon is loaded from static.cloudflareinsights.com and reports to cloudflareinsights.com. Neither host was in script-src or connect-src.
Result: on every page view the browser blocked the beacon, logged a CSP error in the console and sent nothing. The analytics dashboard simply showed no data, which looks exactly like "no visitors" rather than "broken".
The fix was two allow-list entries, kept as narrow as possible:
script-src 'self' https://static.cloudflareinsights.com ...
connect-src 'self' https://cloudflareinsights.com ...
The lesson is not about Cloudflare. Any platform feature that injects a script (analytics, bot protection, a chat widget) is a new script source. If your policy is strict, the feature arrives already broken, and it fails silently.
Silent block #2: a redirect that swapped the policy
The site has an intentionally vulnerable page for its cross-site scripting exercises. That page must run a reflected payload, so it gets its own, separate policy: script-src 'unsafe-inline', but also sandbox allow-scripts, connect-src 'none', form-action 'none' and default-src 'none'. The sandbox gives the document an opaque (null) origin, so a payload that runs there cannot read the site's cookies or storage, or call its API.
The special policy was attached by matching the path /ctf/xss-victim.html. On production, Cloudflare Pages normalises clean URLs: /ctf/xss-victim.html answers with a 308 redirect to /ctf/xss-victim. The redirected request no longer matched, received the site-wide strict policy, and the exercise's own harness script was blocked.
Locally everything worked, because the local server did not redirect. The fix was to match both forms of the path:
export const VICTIM_PATHS = new Set(["/ctf/xss-victim", "/ctf/xss-victim.html"]);
If you assign different policies by URL, test them against production URLs, after every redirect your host performs.
If you want to see what that sandboxed target is for, the reflected XSS exercise uses it: Reflected XSS lab.
Making CSP violations visible
Both problems became visible once the browser was asked to report them. MDN describes the modern mechanism: the report-to directive names an endpoint, and the separate Reporting-Endpoints response header maps that name to a URL. Reports arrive as POST requests with Content-Type: application/reports+json, each with "type": "csp-violation".
MDN marks report-to as Baseline 2026 (newly available) and notes that browsers which support it ignore the older report-uri. Until support is universal, MDN's own example sends both. The headers look like this:
Reporting-Endpoints: csp="/api/csp-report"
Content-Security-Policy: default-src 'self'; script-src 'self'; ...; report-to csp; report-uri /api/csp-report
The collector: small on purpose
A violation endpoint is public by design: every visitor's browser posts to it, without a session. That makes it an input like any other. The collector Breachloom runs follows a few rules:
-
Known content types only:
application/csp-report(legacyreport-uri),application/reports+json(Reporting API) andapplication/json. Anything else gets415. - Bounded body: at most 16 KB is read; malformed JSON is dropped silently.
- Rate limit per hashed IP, failing open, so a broken limiter never breaks the page.
- Allow-list of fields: document, directive, blocked URI, source file, line and a short sample.
- No query strings or fragments in stored URIs, because they can hold session IDs, reset tokens or e-mail addresses.
- No database writes: one structured log line per report.
-
Always
204, so the response never feeds back into the client.
The two report formats differ: report-uri sends {"csp-report": {...}} with kebab-case keys, while the Reporting API sends an array of reports with camelCase keys in body. One small function normalises both:
const s = (v, n = 200) => typeof v === "string" ? v.slice(0, n) : "";
const uri = v => s(v, 300).replace(/[?#].*$/, ""); // drop query and fragment
const int = v => Number.isInteger(v) && v >= 0 && v < 1e7 ? v : null;
export function pickFields(r) {
return {
doc: uri(r["document-uri"] || r.documentURL),
directive: s(r["effective-directive"] || r.effectiveDirective ||
r["violated-directive"] || r.violatedDirective, 80),
blocked: uri(r["blocked-uri"] || r.blockedURL),
source: uri(r["source-file"] || r.sourceFile),
line: int(r["line-number"] ?? r.lineNumber),
sample: s(r["script-sample"] || r.sample, 80)
};
}
A rollout checklist that would have caught both
-
Start with
Content-Security-Policy-Report-Onlyand the samereport-toendpoint. Nothing is blocked; everything that would be blocked is reported. - Read the reports grouped by directive and blocked host. A third-party host you did not add yourself, such as an injected beacon, shows up here first.
- Test policies on production URLs, including redirects and trailing-slash variants, if any page gets a different policy.
- Keep the enforcing policy and the report endpoint together. A strict policy without reporting is a silent one.
- Treat the collector as public input: content type, size, rate, field allow-list, no tokens in logs.
For the directive-by-directive side (nonces vs hashes, strict-dynamic, frame-ancestors), there is a longer guide: Content Security Policy: header, directives, nonces and rollout. The fastest way to feel what CSP is defending against is to break a page yourself: the free IDOR case needs no account, and the XSS exercises are part of the paid catalogue.
What did your first CSP report reveal: a script you forgot, a browser extension, or something injected by your own platform?
Sources
- MDN, "CSP: report-to" (Reporting-Endpoints mapping,
application/reports+json,csp-violationtype, Baseline 2026, sendingreport-urialongside). https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/report-to - MDN, "Content-Security-Policy-Report-Only". https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only
- MDN, "CSP: sandbox" (opaque origin without
allow-same-origin). https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/sandbox
Top comments (0)