diff --git a/dev-packages/node-integration-tests/suites/reporting-api/server.mjs b/dev-packages/node-integration-tests/suites/reporting-api/server.mjs new file mode 100644 index 000000000000..8359ac00fafd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/reporting-api/server.mjs @@ -0,0 +1,54 @@ +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { captureReportingApi } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +import express from 'express'; + +const app = express(); + +app.post('/reporting-api', express.json({ type: 'application/reports+json' }), async (req, res) => { + await captureReportingApi(req.body); + res.sendStatus(200); +}); + +startExpressServerAndSendPortToRunner(app); + +// Below is to support testing with a browser. We don't test this yet because we don't want to add the overhead of +// installing playwright to the node-integration-tests. We should consider this in the future. + +// const port = 9000; + +// app.get('/', (req, res) => { +// const file = readFileSync(join(__dirname, 'index.html'), { encoding: 'utf-8' }); + +// res.setHeader('Content-Type', 'text/html'); +// res.setHeader( +// 'Reporting-Endpoints', +// `csp-endpoint="https://localhost:${port}/reporting-api", default="https://localhost:${port}/reporting-api"`, +// ); +// res.setHeader('Content-Security-Policy', "default-src 'self'; report-to csp-endpoint"); +// res.setHeader('Document-Policy', 'include-js-call-stacks-in-crash-reports'); +// res.setHeader( +// 'Origin-Trial', +// 'ApD+E2izWNtaaRBeZ5BXu46aV0l1MSUzJTPERkU3yf+53pAOHj3rARpjb08itVJklPYx7iNEv5//s2dtXUFIvgMAAABzeyJvcmlnaW4iOiJodHRwczovL2xvY2FsaG9zdDo5MDAwIiwiZmVhdHVyZSI6IkRvY3VtZW50UG9saWN5SW5jbHVkZUpTQ2FsbFN0YWNrc0luQ3Jhc2hSZXBvcnRzIiwiZXhwaXJ5IjoxNzQyMzQyMzk5fQ==', +// ); +// res.send(file); +// }); + +// const options = { +// key: readFileSync(join(__dirname, 'localhost-key.pem')), +// cert: readFileSync(join(__dirname, 'localhost.pem')), +// }; + +// const server = https.createServer(options, app); + +// server.listen(port, () => { +// // eslint-disable-next-line no-console +// console.log(`{"port":${port}}`); +// }); diff --git a/dev-packages/node-integration-tests/suites/reporting-api/test.ts b/dev-packages/node-integration-tests/suites/reporting-api/test.ts new file mode 100644 index 000000000000..595d3a220471 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/reporting-api/test.ts @@ -0,0 +1,52 @@ +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +describe('Reporting API', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should forward Reporting API requests as raw CSP envelopes', done => { + const runner = createRunner(__dirname, 'server.mjs') + .expect({ + raw_security: { + 'csp-report': { + 'document-uri': 'https://localhost:9000/', + referrer: '', + 'blocked-uri': 'https://example.com/script.js', + 'effective-directive': 'script-src-elem', + 'original-policy': "default-src 'self'; report-to csp-endpoint", + disposition: 'enforce', + 'status-code': 200, + status: '200', + sample: '', + }, + }, + }) + .start(done); + + runner.makeRequest( + 'post', + '/reporting-api', + { 'Content-Type': 'application/reports+json' }, + JSON.stringify([ + { + age: 0, + body: { + blockedURL: 'https://example.com/script.js', + disposition: 'enforce', + documentURL: 'https://localhost:9000/', + effectiveDirective: 'script-src-elem', + originalPolicy: "default-src 'self'; report-to csp-endpoint", + referrer: '', + sample: '', + statusCode: 200, + }, + type: 'csp-violation', + url: 'https://localhost:9000/', + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', + }, + ]), + ); + }); +}); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index bde5bd06cd21..728a46ca945f 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { SDK_VERSION } from '@sentry/node'; import type { ClientReport, + DeprecatedCSPReport, Envelope, EnvelopeItemType, Event, @@ -165,6 +166,9 @@ type Expected = } | { client_report: Partial | ((event: ClientReport) => void); + } + | { + raw_security: Partial | ((event: DeprecatedCSPReport) => void); }; type ExpectedEnvelopeHeader = @@ -265,6 +269,7 @@ export function createRunner(...paths: string[]) { } } + // eslint-disable-next-line complexity function newEnvelope(envelope: Envelope): void { for (const item of envelope[1]) { const envelopeItemType = item[0].type; @@ -360,6 +365,17 @@ export function createRunner(...paths: string[]) { expectCallbackCalled(); } + + if ('raw_security' in expected) { + const rawSecurity = item[1] as DeprecatedCSPReport; + if (typeof expected.raw_security === 'function') { + expected.raw_security(rawSecurity); + } else { + expect(rawSecurity).toMatchObject(expected.raw_security); + } + + expectCallbackCalled(); + } } catch (e) { complete(e as Error); } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index d8b8c03e888b..64d25c780e64 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -1,10 +1,15 @@ import type { + CSPReport, + CSPReportPayload, Client, + DeprecatedCSPReport, DsnComponents, DynamicSamplingContext, Event, EventEnvelope, EventItem, + RawSecurityEnvelope, + RawSecurityItem, SdkInfo, SdkMetadata, Session, @@ -18,8 +23,10 @@ import type { import { createEnvelope, createEventEnvelopeHeaders, + dropUndefinedKeys, dsnToString, getSdkMetadataForEnvelopeHeader, + uuid4, } from '@sentry/utils'; import { createSpanEnvelopeItem } from '@sentry/utils'; import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext'; @@ -135,3 +142,44 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? return createEnvelope(headers, items); } + +function convertToDeprecatedPayload(report: CSPReportPayload): DeprecatedCSPReport { + return { + 'csp-report': { + 'document-uri': report.documentURI || report.documentURL, + referrer: report.referrer, + 'blocked-uri': report.blockedURI || report.blockedURL, + 'effective-directive': report.effectiveDirective, + 'original-policy': report.originalPolicy, + disposition: report.disposition, + 'status-code': report.statusCode, + status: report.statusCode.toString(), + 'script-sample': report.sourceFile, + sample: report.sample, + }, + }; +} + +/** + * Create an Envelope from a CSP report. + */ +export function createRawSecurityEnvelope( + report: CSPReport, + dsn: DsnComponents, + tunnel?: string, + release?: string, + environment?: string, +): RawSecurityEnvelope { + const envelopeHeaders = { + event_id: uuid4(), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + ...(report.user_agent && { user_agent: report.user_agent }), + }; + + const eventItem: RawSecurityItem = [ + { type: 'raw_security', sentry_release: release, sentry_environment: environment }, + dropUndefinedKeys(convertToDeprecatedPayload(report.body)), + ]; + + return createEnvelope(envelopeHeaders, [eventItem]); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 24cea1bea7ca..f3247d5488a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,3 +113,5 @@ export { captureFeedback } from './feedback'; export { getCurrentHubShim, getCurrentHub } from './getCurrentHubShim'; export { SDK_VERSION } from '@sentry/utils'; + +export { captureReportingApi } from './reporting'; diff --git a/packages/core/src/reporting.ts b/packages/core/src/reporting.ts new file mode 100644 index 000000000000..60ed2f7fc991 --- /dev/null +++ b/packages/core/src/reporting.ts @@ -0,0 +1,47 @@ +import type { Client, Event, Report } from '@sentry/types'; +import { getClient } from './currentScopes'; +import { createRawSecurityEnvelope } from './envelope'; + +/** Captures reports from the Reporting API */ +export async function captureReportingApi(reports: Report[], options?: { client?: Client }): Promise { + const client = options ? options.client : getClient(); + + if (!client) { + // eslint-disable-next-line no-console + console.warn('[Reporting API] No client available'); + return; + } + + const dsn = client.getDsn(); + if (!dsn) { + // eslint-disable-next-line no-console + console.warn('[Reporting API] No DSN set'); + return; + } + + for (const report of reports) { + if (report.type === 'crash') { + const event: Event = { + level: 'fatal', + message: 'Crashed', + request: { + url: report.url, + ...(report.user_agent && { headers: { 'User-Agent': report.user_agent } }), + }, + }; + + if (report.body.reason === 'oom') { + event.message = 'Crashed: Out of memory'; + } else if (report.body.reason === 'unresponsive') { + event.message = 'Crashed: Unresponsive'; + } + + client.captureEvent(event); + } else if (report.type === 'csp-violation') { + const options = client.getOptions(); + const envelope = createRawSecurityEnvelope(report, dsn, options.tunnel, options.release, options.environment); + + await client.sendEnvelope(envelope); + } + } +} diff --git a/packages/types/src/datacategory.ts b/packages/types/src/datacategory.ts index bd1c0b693e4d..860c9b501ad3 100644 --- a/packages/types/src/datacategory.ts +++ b/packages/types/src/datacategory.ts @@ -30,5 +30,7 @@ export type DataCategory = | 'metric_bucket' // Span | 'span' + // Security report + | 'security' // Unknown data category | 'unknown'; diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index 29e8fc123b16..ec376c5d53d2 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -6,6 +6,7 @@ import type { Event } from './event'; import type { FeedbackEvent, UserFeedback } from './feedback'; import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; +import type { DeprecatedCSPReport } from './reporting'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; import type { SpanJSON } from './span'; @@ -41,7 +42,8 @@ export type EnvelopeItemType = | 'replay_recording' | 'check_in' | 'statsd' - | 'span'; + | 'span' + | 'raw_security'; export type BaseEnvelopeHeaders = { [key: string]: unknown; @@ -84,6 +86,7 @@ type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type StatsdItemHeaders = { type: 'statsd'; length: number }; type SpanItemHeaders = { type: 'span' }; +type RawSecurityHeaders = { type: 'raw_security'; sentry_release?: string; sentry_environment?: string }; export type EventItem = BaseEnvelopeItem; export type AttachmentItem = BaseEnvelopeItem; @@ -100,6 +103,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; +export type RawSecurityItem = BaseEnvelopeItem; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: DynamicSamplingContext }; type SessionEnvelopeHeaders = { sent_at: string }; @@ -120,6 +124,7 @@ export type CheckInEnvelope = BaseEnvelope; export type StatsdEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; +export type RawSecurityEnvelope = BaseEnvelope; export type Envelope = | EventEnvelope @@ -129,6 +134,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | StatsdEnvelope - | SpanEnvelope; + | SpanEnvelope + | RawSecurityEnvelope; export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b100c1e9c26a..b4e352813207 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -48,6 +48,8 @@ export type { ProfileItem, ProfileChunkEnvelope, ProfileChunkItem, + RawSecurityEnvelope, + RawSecurityItem, SpanEnvelope, SpanItem, } from './envelope'; @@ -173,3 +175,4 @@ export type { export type { ParameterizedString } from './parameterize'; export type { ContinuousProfiler, ProfilingIntegration, Profiler } from './profiling'; export type { ViewHierarchyData, ViewHierarchyWindow } from './view-hierarchy'; +export type { Report, CSPReport, CSPReportPayload, DeprecatedCSPReport } from './reporting'; diff --git a/packages/types/src/reporting.ts b/packages/types/src/reporting.ts new file mode 100644 index 000000000000..0a391fbefc8e --- /dev/null +++ b/packages/types/src/reporting.ts @@ -0,0 +1,61 @@ +// Types from here +// https://www.w3.org/TR/CSP3/ + +interface Base { + readonly age?: number; + readonly body?: unknown; + readonly url: string; + readonly user_agent?: string; +} + +type CrashReportPayload = { + readonly crashId?: string; + readonly reason?: 'unresponsive' | 'oom'; + readonly stack?: string; +}; + +interface CrashReport extends Base { + readonly type: 'crash'; + readonly body: CrashReportPayload; +} + +type Disposition = 'enforce' | 'report' | 'reporting'; + +export interface CSPReportPayload { + readonly blockedURI?: string; + readonly blockedURL?: string; + readonly columnNumber?: number; + readonly disposition: Disposition; + readonly documentURI: string; + readonly documentURL: string; + readonly effectiveDirective: string; + readonly lineNumber?: number; + readonly originalPolicy: string; + readonly referrer?: string; + readonly sample?: string; + readonly sourceFile?: string; + readonly statusCode: number; +} + +export interface CSPReport extends Base { + readonly type: 'csp-violation'; + readonly body: CSPReportPayload; +} + +export type Report = CrashReport | CSPReport; + +export interface DeprecatedCSPReport { + readonly 'csp-report': { + readonly 'document-uri'?: string; + readonly referrer?: string; + readonly 'blocked-uri'?: string; + readonly 'effective-directive'?: string; + readonly 'violated-directive'?: string; + readonly 'original-policy'?: string; + readonly disposition: Disposition; + readonly 'status-code'?: number; + readonly status?: string; + readonly 'script-sample'?: string; + readonly sample?: string; + }; +} diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 8bf29788edf0..cad0fcf54926 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -224,6 +224,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { feedback: 'feedback', span: 'span', statsd: 'metric_bucket', + raw_security: 'security', }; /**