UNPKG

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
'use strict'; 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