diff --git a/src/event-schemas/csp-violation-event.json b/src/event-schemas/csp-violation-event.json new file mode 100644 index 00000000..36304dfb --- /dev/null +++ b/src/event-schemas/csp-violation-event.json @@ -0,0 +1,64 @@ +{ + "$id": "com.amazon.rum.csp_violation_event", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CspViolationEvent", + "type": "object", + "properties": { + "version": { + "const": "1.0.0", + "type": "string", + "description": "Schema version." + }, + "blockedURI": { + "type": "string", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/blockedURI" + }, + "columnNumber": { + "type": "number", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/columnNumber" + }, + "disposition": { + "type": "string", + "enum": ["enforce", "report"], + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/disposition" + }, + "documentURI": { + "type": "string", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/documentURI" + }, + "effectiveDirective": { + "type": "string", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/effectiveDirective" + }, + "lineNumber": { + "type": "number", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/lineNumber" + }, + "originalPolicy": { + "type": "string", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/originalPolicy" + }, + "referrer": { + "type": ["string", "null"], + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/referrer" + }, + "sample": { + "type": "string", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/sample" + }, + "sourceFile": { + "type": ["string", "null"], + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/sourceFile" + }, + "statusCode": { + "type": "number", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/statusCode" + }, + "violatedDirective": { + "type": "string", + "description": "https://developer.mozilla.org/docs/Web/API/SecurityPolicyViolationEvent/violatedDirective" + } + }, + "additionalProperties": false, + "required": ["version"] +} diff --git a/src/plugins/event-plugins/CspViolationPlugin.ts b/src/plugins/event-plugins/CspViolationPlugin.ts new file mode 100644 index 00000000..ab878595 --- /dev/null +++ b/src/plugins/event-plugins/CspViolationPlugin.ts @@ -0,0 +1,77 @@ +import { InternalPlugin } from '../InternalPlugin'; +import { CSP_VIOLATION_EVENT_TYPE } from '../utils/constant'; + +export const CSP_VIOLATION_EVENT_PLUGIN_ID = 'csp-violation'; + +export type CspViolationPluginConfig = { + ignore: (error: SecurityPolicyViolationEvent) => boolean; +}; + +export type PartialCspViolationPluginConfig = { + ignore?: (error: SecurityPolicyViolationEvent) => boolean; +}; + +const defaultConfig: CspViolationPluginConfig = { + ignore: () => false +}; + +export class CspViolationPlugin extends InternalPlugin { + private config: CspViolationPluginConfig; + + constructor(config?: PartialCspViolationPluginConfig) { + super(CSP_VIOLATION_EVENT_PLUGIN_ID); + this.config = { ...defaultConfig, ...config }; + } + + enable(): void { + if (this.enabled) { + return; + } + this.addEventHandler(); + this.enabled = true; + } + + disable(): void { + if (!this.enabled) { + return; + } + this.removeEventHandler(); + this.enabled = false; + } + + record(cspViolationEvent: any): void { + this.recordCspViolationEvent(cspViolationEvent); + } + + protected onload(): void { + this.addEventHandler(); + } + + private eventHandler = ( + cspViolationEvent: SecurityPolicyViolationEvent + ) => { + if (!this.config.ignore(cspViolationEvent)) { + this.recordCspViolationEvent(cspViolationEvent); + } + }; + + private recordCspViolationEvent( + cspViolationEvent: SecurityPolicyViolationEvent + ) { + this.context?.record(CSP_VIOLATION_EVENT_TYPE, { + ...cspViolationEvent, + version: '1.0.0' + }); + } + + private addEventHandler(): void { + window.addEventListener('securitypolicyviolation', this.eventHandler); + } + + private removeEventHandler(): void { + window.removeEventListener( + 'securitypolicyviolation', + this.eventHandler + ); + } +} diff --git a/src/plugins/event-plugins/__tests__/CspViolationPlugin.test.ts b/src/plugins/event-plugins/__tests__/CspViolationPlugin.test.ts new file mode 100644 index 00000000..99d37a64 --- /dev/null +++ b/src/plugins/event-plugins/__tests__/CspViolationPlugin.test.ts @@ -0,0 +1,164 @@ +import { context, getSession, record } from '../../../test-utils/test-utils'; +import { CSP_VIOLATION_EVENT_TYPE } from '../../utils/constant'; +import { CspViolationPlugin } from '../CspViolationPlugin'; + +declare global { + namespace jest { + interface Expect { + toBePositive(): any; + } + } +} + +const eventDetails: Partial = { + violatedDirective: 'test:violatedDirective', + documentURI: 'http://documentURI', + blockedURI: 'https://blockedURI', + originalPolicy: 'test:originalPolicy', + referrer: 'test:referrer', + statusCode: 200, + effectiveDirective: 'test:effectiveDirective' +}; + +function dispatchCspViolationEvent() { + const event = new Event( + 'securitypolicyviolation' + ) as SecurityPolicyViolationEvent; + // its important to apply our expected event details to the event before dispatching it. + Object.assign(event, eventDetails); + + dispatchEvent(event); +} + +expect.extend({ + toBePositive(recieved) { + const pass = recieved > 0; + if (pass) { + return { + message: () => + `expected ${recieved} not to be a positive integer`, + pass: true + }; + } else { + return { + message: () => `expected ${recieved} to be a positive integer`, + pass: false + }; + } + } +}); + +describe('CspViolationPlugin tests', () => { + beforeEach(() => { + record.mockClear(); + getSession.mockClear(); + }); + + test('when an CspViolationEvent is triggered then the plugin records cspViolationEvent', async () => { + // Init + const plugin: CspViolationPlugin = new CspViolationPlugin(); + + // Run + plugin.load(context); + dispatchCspViolationEvent(); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalledTimes(1); + expect(record.mock.calls[0][0]).toEqual(CSP_VIOLATION_EVENT_TYPE); + expect(record.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + version: '1.0.0', + blockedURI: 'https://blockedURI', + documentURI: 'http://documentURI', + effectiveDirective: 'test:effectiveDirective', + originalPolicy: 'test:originalPolicy', + referrer: 'test:referrer', + statusCode: 200 + }) + ); + }); + + test('when plugin disabled then plugin does not record events', async () => { + // Init + const plugin: CspViolationPlugin = new CspViolationPlugin(); + + // Run + plugin.load(context); + plugin.disable(); + + dispatchCspViolationEvent(); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalledTimes(0); + }); + + test('when enabled then plugin records events', async () => { + // Init + const plugin: CspViolationPlugin = new CspViolationPlugin(); + + // Run + plugin.load(context); + plugin.disable(); + plugin.enable(); + dispatchCspViolationEvent(); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalledTimes(1); + }); + + test('when record is used then errors are not passed to the ignore function', async () => { + // Init + const mockIgnore = jest.fn(); + const plugin: CspViolationPlugin = new CspViolationPlugin({ + ignore: mockIgnore + }); + + // Run + plugin.load(context); + const event = new Event( + 'securitypolicyviolation' + ) as SecurityPolicyViolationEvent; + plugin.record(event); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalled(); + expect(mockIgnore).not.toHaveBeenCalled(); + }); + + test('by default SecurityPolicyViolationEvents are not ignored', async () => { + // Init + const plugin: CspViolationPlugin = new CspViolationPlugin(); + + // Run + plugin.load(context); + dispatchCspViolationEvent(); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalled(); + }); + + test('when a specific documentUri is ignored then SecurityPolicyViolationEvents are not recorded', async () => { + // Init + const plugin: CspViolationPlugin = new CspViolationPlugin({ + ignore: (e) => { + const ignoredDocuments = ['http://documentURI']; + return ignoredDocuments.includes( + (e as SecurityPolicyViolationEvent).documentURI + ); + } + }); + + // Run + plugin.load(context); + dispatchCspViolationEvent(); + plugin.disable(); + + // Assert + expect(record).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/utils/constant.ts b/src/plugins/utils/constant.ts index 19049fc1..e0896e59 100644 --- a/src/plugins/utils/constant.ts +++ b/src/plugins/utils/constant.ts @@ -29,3 +29,6 @@ export const SESSION_START_EVENT_TYPE = `${RUM_AMZ_PREFIX}.session_start_event`; // Time to interactive event export const TIME_TO_INTERACTIVE_EVENT_TYPE = `${RUM_AMZ_PREFIX}.time_to_interactive_event`; + +// CSP violation event schemas +export const CSP_VIOLATION_EVENT_TYPE = `${RUM_AMZ_PREFIX}.csp_violation_event`;