CSP Reporting
A Content Security Policy isn’t deploy-and-forget. report-to / report-uri ship browser-side CSP violation reports to your endpoint — the only way to find blocked third-party scripts, inline-script regressions, and probe attempts before users complain.
report-to, endpoint, triage, dashboards
EXAMPLE
// 1) Set up CSP with report endpoint (report-only first)
// Headers from your origin (Express)
import express from 'express';
const app = express();
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
// Report-Only — observe without blocking
res.setHeader('Content-Security-Policy-Report-Only', [
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "' https://cdn.example.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://cdn.example.com",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"object-src 'none'",
"report-to csp-endpoint",
"report-uri /csp-report", // legacy fallback for older browsers
].join('; '));
// Report-To group (Reporting API)
res.setHeader('Report-To', JSON.stringify({
group: 'csp-endpoint',
max_age: 10886400, // 126 days
endpoints: [{ url: 'https://reports.example.com/csp' }],
include_subdomains: true,
}));
next();
});
// 2) Receive reports — JSON body parser
app.use('/csp-report', express.json({ type: ['application/csp-report', 'application/reports+json'] }));
app.post('/csp-report', (req, res) => {
// Legacy report-uri format: { 'csp-report': { ... } }
// New Report-To format: array of reports
const reports = Array.isArray(req.body) ? req.body : [req.body['csp-report'] ?? req.body];
for (const r of reports) {
const body = r.body ?? r; // new format wraps body
log.warn({
documentUri: body['document-uri'] ?? body.documentURL,
blockedUri: body['blocked-uri'] ?? body.blockedURL,
violatedDir: body['violated-directive'] ?? body.effectiveDirective,
disposition: body.disposition, // 'enforce' or 'report'
sourceFile: body['source-file'] ?? body.sourceFile,
lineNumber: body['line-number'] ?? body.lineNumber,
sample: body['script-sample'],
referrer: body.referrer,
ip: req.ip,
ua: req.get('user-agent'),
}, 'csp violation');
}
res.sendStatus(204);
});
// 3) Sample report payload (browser sends)
// {
// "csp-report": {
// "document-uri": "https://app.example.com/page",
// "referrer": "https://app.example.com/dashboard",
// "violated-directive": "script-src 'self' 'nonce-...'",
// "effective-directive": "script-src-elem",
// "original-policy": "...full CSP...",
// "blocked-uri": "https://evil.example.com/x.js",
// "status-code": 200,
// "script-sample": "alert(1)"
// }
// }
// 4) report-uri.com — managed service
// • Free tier + paid; aggregates reports, dedupes, dashboards
// • Add: report-uri https://yourgroup.report-uri.com/r/d/csp/enforce
// • Saves you building + scaling your own collector
// Alternatives: Sentry, Datadog RUM, Cloudflare Page Shield
// 5) Real-world report categories
// (a) Blocked scripts from EXTENSIONS — chrome-extension:// origins
// → ignore; user-installed extensions inject scripts
// (b) Browser-injected inline scripts on iOS Safari
// → 'inline' source; check user-agent
// (c) Third-party widgets you forgot to allow (analytics, payment)
// → add to allowlist after security review
// (d) Genuine attack probes — eval, data:text/html, javascript:
// → these are the signal you're looking for
// 6) Filter the noise (real-world stats: 80-95% is browser extensions)
function isNoise(report) {
const blocked = report['blocked-uri'] ?? report.blockedURL ?? '';
return blocked.startsWith('chrome-extension://')
|| blocked.startsWith('moz-extension://')
|| blocked.startsWith('safari-extension://')
|| blocked === 'inline'
|| blocked.includes('about:blank');
}
app.post('/csp-report', (req, res) => {
const reports = Array.isArray(req.body) ? req.body : [req.body['csp-report'] ?? req.body];
for (const r of reports) {
if (isNoise(r.body ?? r)) continue;
metrics.cspViolation.inc({ directive: (r.body ?? r)['effective-directive'] });
log.warn(r.body ?? r, 'csp violation (filtered)');
}
res.sendStatus(204);
});
// 7) Aggregating: bucket by directive + blocked-uri
// Use Prometheus counters with low-cardinality labels
const cspViolation = new promClient.Counter({
name: 'csp_violations_total',
help: 'CSP violations',
labelNames: ['directive', 'origin', 'environment'],
});
function labelify(uri) {
try { return new URL(uri).origin; } catch { return 'unknown'; }
}
// 8) Promoting CSP from report-only to enforce
// • Run report-only for 1-2 weeks; analyse + add missing allowlist entries
// • Switch to Content-Security-Policy (enforce); keep report-to active
// • Continue monitoring for new third-party scripts / regressions
// 9) The Reporting API — broader than just CSP
// Same endpoint receives Network Error Logging, Crash Reports, Intervention, Deprecation, etc.
res.setHeader('Report-To', JSON.stringify([
{ group: 'csp-endpoint', max_age: 10886400, endpoints: [{ url: 'https://reports.example.com/csp' }] },
{ group: 'errors-endpoint', max_age: 10886400, endpoints: [{ url: 'https://reports.example.com/errors' }] },
]));
res.setHeader('NEL', JSON.stringify({
report_to: 'errors-endpoint', max_age: 10886400, include_subdomains: true,
}));
// 10) Validate your CSP
// • https://csp-evaluator.withgoogle.com — analyses risky directives
// • https://observatory.mozilla.org — grades your headers
// • Browser devtools → Application → Reporting API → see issued reports
// • Test policy with: header tool, curl, then in real browser sessions
// 11) Common pitfalls
// • Listing both report-uri and report-to — modern browsers prefer report-to; old fall back
// • Same-origin endpoint — browsers may not send when fetch is also blocked
// • CORS on the reports endpoint — accept * from any origin (browsers send no auth headers anyway)
// • Heavy report volume bringing down your endpoint — rate limit + drop sampling for very chatty origins
// • Enforcing a policy without going through report-only first → outages
// • Forgetting frame-ancestors equivalent to X-Frame-Options → clickjacking still possible
// 12) Dashboarding
// Grafana panels to build:
// • Violations / minute (line)
// • Top blocked URIs (bar)
// • By directive (pie)
// • Environment x site (heatmap)
// • Alert: new directive appearing for first time
// 13) Production tips
// • Sample at the edge (1-10%) if volume is huge
// • Tag reports by app version (CSP includes original-policy → derive)
// • Retain raw reports 30-90 days for forensics
// • Don't store full HTML in samples — PII risk; truncate
// 14) Common bugs
// • Sending CSP from CDN but not origin — fragmented coverage
// • Reports never arrive → check 'Content-Type' on POST; some browsers send application/csp-report
// • report-uri but no Report-To group → only legacy browsers report
// • Inline scripts blocked but CSP says 'unsafe-inline' — nonce mismatch; trust the CSP, fix the script
// • Forgetting connect-src for your reporting domain — reports about reports!
// • Going straight to enforce → silent breakages; ALWAYS start with report-only
// • Tracking nonce mismatch in production → rotate nonce per response, not per session
Why it matters
CSP without reporting is half-deployed: set Report-To + report-uri, start in Content-Security-Policy-Report-Only mode, and triage the reports for 1–2 weeks (filtering browser-extension noise) before enforcing. Counters by directive plus alerts on new violations turn CSP from “set and forget” into a continuous defense.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
Content-Security-Policy-Report-Only:
default-src 'self';
report-to csp-endpoint;
Reporting-Endpoints: csp-endpoint="https://example.com/csp"
// Collect violations from real users, then ratchet the policy.
Try it Yourself »
Discussion
Loading…