reporting-api
Version:
Roll your own Reporting API collector. Supports CSP, COEP, COOP, Document-Policy, Crash reports, Deprecation reports, Intervention reports and Network Error Logging
187 lines (183 loc) • 7.44 kB
JavaScript
;
var debug = require('debug');
var express = require('express');
var schemas = require('./schemas.cjs');
const log = debug('reporting-api:endpoint');
function filterReport(report, { ignoreBrowserExtensions, maxAge }) {
if (ignoreBrowserExtensions &&
'sourceFile' in report.body &&
typeof report.body.sourceFile === 'string') {
if (report.body.sourceFile.startsWith('chrome-extension') ||
report.body.sourceFile.startsWith('moz-extension') ||
report.body.sourceFile.startsWith('safari-web-extension')) {
return false;
}
}
// Reporting API v1 `age` is in milliseconds but our settings is in seconds
if (maxAge && report.age > maxAge * 1000) {
log('report is too old %O', report);
return false;
}
return true;
}
function isOriginAllowed(origin, allowedOrigin) {
if (Array.isArray(allowedOrigin)) {
return allowedOrigin.some((o) => isOriginAllowed(origin, o));
}
else if (allowedOrigin instanceof RegExp) {
return allowedOrigin.test(origin);
}
else if (typeof allowedOrigin === 'string') {
return allowedOrigin === origin;
}
return false;
}
function createReportingEndpoint(config) {
const { onReport, onValidationError, allowedOrigins } = config;
if (config.debug) {
debug.enable('reporting-api:*');
}
function handleReport(result, raw, req) {
if (result.success) {
const report = result.data;
if (filterReport(report, config)) {
onReport(report, req);
log('received report %j', report);
}
else {
log('filtered %j', report);
}
}
else {
log('parse error %j', {
raw,
err: result.error,
});
if (onValidationError) {
onValidationError(result.error, raw, req);
}
}
}
return (req, res) => {
if (req.method !== 'POST' && req.method !== 'OPTIONS') {
res.setHeader('Allow', 'POST, OPTIONS');
return res.sendStatus(405);
}
// If cross origin reports are allowed, setup CORS on both OPTIONS and POST.
if (allowedOrigins) {
const originHeader = req.headers.origin;
if (config.allowedOrigins === '*') {
res.setHeader('Access-Control-Allow-Origin', '*');
}
else if (originHeader &&
isOriginAllowed(originHeader, allowedOrigins)) {
res.setHeader('Access-Control-Allow-Origin', originHeader);
res.setHeader('Vary', 'Origin');
}
// Since reports are sent with a Content-Type header MIME type that is not considered 'simple' (https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests)
// we will always get a preflight request
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Methods', 'POST');
// Capped at 7200 in Chrome
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#delta-seconds
res.setHeader('Access-Control-Max-Age', '7200');
return res.sendStatus(200);
}
}
const version = typeof req.query.version === 'string'
? req.query.version
: undefined;
// CSP Level 2 Reports
// See MDN docs: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
if (req.headers['content-type'] === 'application/csp-report') {
// Newer safari sends reports in the format `body: {...}` with no `age`
if (typeof req.body.body === 'object') {
const { body, type, url } = req.body;
const result = schemas.Report.safeParse({
body,
type,
url,
age: 0,
user_agent: req.headers['user-agent'] || '',
report_format: 'report-to-safari',
version,
});
handleReport(result, req.body, req);
return res.sendStatus(200);
}
const body = req.body['csp-report'];
if (!body) {
log('application/csp-report without csp-report in body: %j', req.body);
if (onValidationError) {
onValidationError(new Error('Unknown application/csp-report report'), req.body, req);
}
return res.sendStatus(400);
}
const v2body = {
blockedURL: body['blocked-uri'],
columnNumber: body['column-number'],
// Older Firefox doesn't set the disposition so we track it in the query params
disposition: body['disposition'] || req.query.disposition,
documentURL: body['document-uri'],
// `violated-directive` is deprecated in favor of `effective-directive`
effectiveDirective: body['effective-directive'] || body['violated-directive'],
lineNumber: body['line-number'],
originalPolicy: body['original-policy'],
referrer: body['referrer'],
sample: body['script-sample'],
sourceFile: body['source-file'],
statusCode: body['status-code'],
};
const result = schemas.Report.safeParse({
body: v2body,
type: 'csp-violation',
url: body['document-uri'],
// CSP Level 2 reports are sent off directly by the browser
age: 0,
user_agent: req.headers['user-agent'] || '',
report_format: 'report-uri',
version,
});
handleReport(result, req.body, req);
return res.sendStatus(200);
}
// Modern reporting API
if (Array.isArray(req.body)) {
for (const raw of req.body) {
const result = schemas.Report.safeParse({
body: raw.body,
type: raw.type,
age: raw.age,
url: raw.url,
user_agent: raw.user_agent,
report_format: 'report-to',
version,
});
handleReport(result, raw, req);
}
}
return res.sendStatus(200);
};
}
const bodyParser = express.json({
type: [
// Reporting API v0, Reporting API v1
'application/reports+json',
// CSP Level 2 reports
// Does not rely on the reporting API and is set through the deprecated `report-uri` attribute on the CSP header
// https://developer.chrome.com/blog/reporting-api-migration#migration_steps_for_csp_reporting
'application/csp-report',
],
strict: true,
limit: '200kb',
});
/**
* Express route to collect reports
*/
const reportingEndpoint = (config) => [
bodyParser,
createReportingEndpoint(config),
];
exports.reportingEndpoint = reportingEndpoint;
//# sourceMappingURL=reporting-endpoint.cjs.map