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',