diff --git a/app/csp_violation_event.html b/app/csp_violation_event.html new file mode 100644 index 00000000..055963df --- /dev/null +++ b/app/csp_violation_event.html @@ -0,0 +1,156 @@ + + + + RUM Integ Test + + + + + + + + +

This application is used for RUM integ testing.

+
+ + + + + +
+ + +
+ + + + + + + + + + + + + + + +
Request URL
Request Header
Request Body
+ + + + + + + + + + + + + +
Response Status Code
Response Header
Response Body
+ + diff --git a/package-lock.json b/package-lock.json index bcc4b329..98178948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7351,9 +7351,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001425", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001425.tgz", - "integrity": "sha512-/pzFv0OmNG6W0ym80P3NtapU0QEiDS3VuYAZMGoLLqiC7f6FJFe1MjpQDREGApeenD9wloeytmVDj+JLXPC6qw==", + "version": "1.0.30001668", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", + "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", "dev": true, "funding": [ { @@ -7363,6 +7363,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -27703,9 +27707,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001425", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001425.tgz", - "integrity": "sha512-/pzFv0OmNG6W0ym80P3NtapU0QEiDS3VuYAZMGoLLqiC7f6FJFe1MjpQDREGApeenD9wloeytmVDj+JLXPC6qw==", + "version": "1.0.30001668", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", + "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", "dev": true }, "case-sensitive-paths-webpack-plugin": { diff --git a/src/CommandQueue.ts b/src/CommandQueue.ts index 32b9970b..e4962aa1 100644 --- a/src/CommandQueue.ts +++ b/src/CommandQueue.ts @@ -14,6 +14,7 @@ interface CommandFunctions { addSessionAttributes: CommandFunction; recordPageView: CommandFunction; recordError: CommandFunction; + recordCspViolation: CommandFunction; registerDomEvents: CommandFunction; recordEvent: CommandFunction; dispatch: CommandFunction; @@ -68,6 +69,9 @@ export class CommandQueue { recordError: (payload: any): void => { this.orchestration.recordError(payload); }, + recordCspViolation: (payload: any): void => { + this.orchestration.recordCspViolation(payload); + }, registerDomEvents: (payload: any): void => { this.orchestration.registerDomEvents(payload); }, 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/index.ts b/src/index.ts index 68c7a34f..6963fa7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { PageAttributes } from './sessions/PageManager'; export { Plugin } from './plugins/Plugin'; export { PluginContext } from './plugins/types'; export { TTIPlugin } from './plugins/event-plugins/TTIPlugin'; +export * from './plugins/event-plugins/CspViolationPlugin'; export * from './plugins/event-plugins/DomEventPlugin'; export * from './plugins/event-plugins/JsErrorPlugin'; export * from './plugins/event-plugins/NavigationPlugin'; diff --git a/src/loader/loader-csp-violation-event.js b/src/loader/loader-csp-violation-event.js new file mode 100644 index 00000000..84eadd34 --- /dev/null +++ b/src/loader/loader-csp-violation-event.js @@ -0,0 +1,23 @@ +import { loader } from './loader'; +import { showRequestClientBuilder } from '../test-utils/mock-http-handler'; +import { CspViolationPlugin } from '../plugins/event-plugins/CspViolationPlugin'; +loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', { + allowCookies: true, + dispatchInterval: 0, + metaDataPluginsToLoad: [], + eventPluginsToLoad: [ + new CspViolationPlugin({ + ignore: (e) => { + const ignoredDocuments = ['http://ignoredDocumentURI']; + return ignoredDocuments.includes(e.documentURI); + } + }) + ], + telemetries: [], + clientBuilder: showRequestClientBuilder +}); +window.cwr('setAwsCredentials', { + accessKeyId: 'a', + secretAccessKey: 'b', + sessionToken: 'c' +}); diff --git a/src/orchestration/Orchestration.ts b/src/orchestration/Orchestration.ts index a94a4c15..96c4b3e8 100644 --- a/src/orchestration/Orchestration.ts +++ b/src/orchestration/Orchestration.ts @@ -13,6 +13,11 @@ import { JsErrorPlugin, JS_ERROR_EVENT_PLUGIN_ID } from '../plugins/event-plugins/JsErrorPlugin'; +import { + CspViolationPlugin, + CSP_VIOLATION_EVENT_PLUGIN_ID +} from '../plugins/event-plugins/CspViolationPlugin'; + import { EventCache } from '../event-cache/EventCache'; import { ClientBuilder, Dispatch } from '../dispatch/Dispatch'; import { @@ -342,6 +347,20 @@ export class Orchestration { this.pluginManager.record(JS_ERROR_EVENT_PLUGIN_ID, error); } + /** + * Record an SecurityPolicyViolationEvent using the CSP Violation plugin. + * + * @param securityPolicyViolationEvent a SecurityPolicyViolationEvent. + */ + public recordCspViolation( + securityPolicyViolationEvent: SecurityPolicyViolationEvent + ) { + this.pluginManager.record( + CSP_VIOLATION_EVENT_PLUGIN_ID, + securityPolicyViolationEvent + ); + } + /** * Update DOM plugin to record the (additional) provided DOM events. * @@ -464,7 +483,6 @@ export class Orchestration { ]; } }); - return plugins; } @@ -484,7 +502,10 @@ export class Orchestration { private telemetryFunctor(): TelemetriesFunctor { return { [TelemetryEnum.Errors]: (config: object): InternalPlugin[] => { - return [new JsErrorPlugin(config)]; + return [ + new JsErrorPlugin(config), + new CspViolationPlugin(config) + ]; }, [TelemetryEnum.Performance]: (config: object): InternalPlugin[] => { return [ 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/__integ__/CspViolationPlugin.test.ts b/src/plugins/event-plugins/__integ__/CspViolationPlugin.test.ts new file mode 100644 index 00000000..b8984424 --- /dev/null +++ b/src/plugins/event-plugins/__integ__/CspViolationPlugin.test.ts @@ -0,0 +1,105 @@ +import { Selector } from 'testcafe'; +import { REQUEST_BODY } from '../../../test-utils/integ-test-utils'; +import { CSP_VIOLATION_EVENT_TYPE } from '../../utils/constant'; + +const triggerSecurityPolicyViolationEvent: Selector = Selector( + `#triggerSecurityPolicyViolationEvent` +); +const triggerIgnoredSecurityPolicyViolationEvent: Selector = Selector( + `#triggerIgnoredSecurityPolicyViolationEvent` +); +const recordCspViolationEvent: Selector = Selector(`#recordCspViolationEvent`); +const dispatch: Selector = Selector(`#dispatch`); + +fixture('CSPViolationEvent Plugin').page( + 'http://localhost:8080/csp_violation_event.html' +); + +const removeUnwantedEvents = (json: any) => { + const newEventsList = []; + for (const event of json.RumEvents) { + if (/(dispatch)/.test(event.details)) { + // Skip + } else if (/(session_start_event)/.test(event.type)) { + // Skip + } else if (/(page_view_event)/.test(event.type)) { + // Skip + } else { + newEventsList.push(event); + } + } + + json.RumEvents = newEventsList; + return json; +}; + +test('when a SecurityPolicyViolationEvent is triggered then cspViolationEvent is recorded', async (t: TestController) => { + // If we click too soon, the client/event collector plugin will not be loaded and will not record the click. + // This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution. + await t + .wait(300) + .click(triggerSecurityPolicyViolationEvent) + .click(dispatch) + .expect(REQUEST_BODY.textContent) + .contains('BatchId'); + + const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter( + (e) => e.type === CSP_VIOLATION_EVENT_TYPE + ); + const eventType = events[0].type; + const eventDetails = JSON.parse(events[0].details); + + await t + .expect(eventType) + .eql(CSP_VIOLATION_EVENT_TYPE) + .expect(eventDetails.violatedDirective) + .match(/test:violatedDirective/) + .expect(eventDetails.documentURI) + .match(/http:\/\/documentURI/) + .expect(eventDetails.blockedURI) + .match(/https:\/\/blockedURI/) + .expect(eventDetails.originalPolicy) + .match(/test:originalPolicy/) + .expect(eventDetails.referrer) + .match(/test:referrer/) + .expect(eventDetails.statusCode) + .match(/200/) + .expect(eventDetails.effectiveDirective) + .match(/test:effectiveDirective/) + .expect(eventDetails.version) + .match(/1.0.0/); +}); + +test('when ignore function matches error then the plugin does not record the error', async (t: TestController) => { + // If we click too soon, the client/event collector plugin will not be loaded and will not record the click. + // This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution. + await t + .wait(300) + .click(triggerIgnoredSecurityPolicyViolationEvent) + .click(dispatch) + .expect(REQUEST_BODY.textContent) + .contains('BatchId'); + + const json = removeUnwantedEvents( + JSON.parse(await REQUEST_BODY.textContent) + ); + + await t.expect(json.RumEvents.length).eql(0); +}); + +test('when error invoked with record method then the plugin records the error', async (t: TestController) => { + // If we click too soon, the client/event collector plugin will not be loaded and will not record the click. + // This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution. + await t + .wait(300) + .click(recordCspViolationEvent) + .click(dispatch) + .expect(REQUEST_BODY.textContent) + .contains('BatchId'); + + const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter( + (e) => e.type === CSP_VIOLATION_EVENT_TYPE + ); + + await t.expect(events.length).eql(1); +}); 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..8aab298d --- /dev/null +++ b/src/plugins/event-plugins/__tests__/CspViolationPlugin.test.ts @@ -0,0 +1,162 @@ +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; + } + } +} + +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, { + violatedDirective: 'test:violatedDirective', + documentURI: 'http://documentURI', + blockedURI: 'https://blockedURI', + originalPolicy: 'test:originalPolicy', + referrer: 'test:referrer', + statusCode: 200, + effectiveDirective: 'test:effectiveDirective' + }); + + 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 a SecurityPolicyViolationEvent 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`; diff --git a/webpack/webpack.dev.js b/webpack/webpack.dev.js index ecd139bd..cfaef922 100644 --- a/webpack/webpack.dev.js +++ b/webpack/webpack.dev.js @@ -17,6 +17,8 @@ module.exports = merge(common, { './src/loader/loader-dom-event-mutation-observer-enabled.js', loader_ingestion: './src/loader/loader-ingestion.js', loader_js_error_event: './src/loader/loader-js-error-event.js', + loader_csp_violation_event: + './src/loader/loader-csp-violation-event.js', loader_http_fetch_event: './src/loader/loader-http-fetch-event.js', loader_http_xhr_event: './src/loader/loader-http-xhr-event.js', loader_web_vital_event: './src/loader/loader-web-vital-event.js',