diff --git a/CHANGELOG.md b/CHANGELOG.md index 890048c589..d89631161b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- Introduce `sendPayloadChecksums` option and set `Bugsnag-Integrity` headers on events and sessions [#2221](https://github.com/bugsnag/bugsnag-js/pull/2221) + ## [8.1.2] - 2024-10-25 ### Fixed diff --git a/dockerfiles/Dockerfile.browser b/dockerfiles/Dockerfile.browser index 12fb309c8b..efc427b6c2 100644 --- a/dockerfiles/Dockerfile.browser +++ b/dockerfiles/Dockerfile.browser @@ -7,6 +7,7 @@ WORKDIR /app COPY package*.json ./ COPY babel.config.js lerna.json .eslintignore .eslintrc.js jest.config.js tsconfig.json ./ +COPY jest ./jest ADD min_packages.tar . COPY bin ./bin COPY packages ./packages diff --git a/dockerfiles/Dockerfile.ci b/dockerfiles/Dockerfile.ci index 9548f64d5f..680dd3854e 100644 --- a/dockerfiles/Dockerfile.ci +++ b/dockerfiles/Dockerfile.ci @@ -7,6 +7,7 @@ WORKDIR /app COPY package*.json ./ COPY babel.config.js lerna.json .eslintignore .eslintrc.js jest.config.js tsconfig.json ./ +COPY jest ./jest ADD min_packages.tar . COPY bin ./bin COPY scripts ./scripts diff --git a/dockerfiles/Dockerfile.node b/dockerfiles/Dockerfile.node index f2e40ae999..268d8a79cb 100644 --- a/dockerfiles/Dockerfile.node +++ b/dockerfiles/Dockerfile.node @@ -7,6 +7,7 @@ WORKDIR /app COPY package*.json ./ COPY babel.config.js lerna.json .eslintignore .eslintrc.js jest.config.js tsconfig.json ./ +COPY jest ./jest ADD min_packages.tar . COPY bin ./bin COPY packages ./packages diff --git a/jest.config.js b/jest.config.js index 64bc93ba34..472b782d71 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,7 +26,9 @@ module.exports = { ], projects: [ project('core', ['core']), - project('web workers', ['web-worker']), + project('web workers', ['web-worker'], { + testEnvironment: '/jest/FixJSDOMEnvironment.js' + }), project('shared plugins', ['plugin-app-duration', 'plugin-stackframe-path-normaliser']), project('browser', [ 'browser', @@ -49,7 +51,9 @@ module.exports = { 'plugin-simple-throttle', 'plugin-console-breadcrumbs', 'plugin-browser-session' - ]), + ], { + testEnvironment: '/jest/FixJSDOMEnvironment.js' + }), project('react native', [ 'react-native', 'delivery-react-native', diff --git a/jest/FixJSDOMEnvironment.js b/jest/FixJSDOMEnvironment.js new file mode 100644 index 0000000000..8c8731b340 --- /dev/null +++ b/jest/FixJSDOMEnvironment.js @@ -0,0 +1,18 @@ +const { TextDecoder, TextEncoder } = require('node:util') +const crypto = require('crypto') + +const JSDOMEnvironment = require('jest-environment-jsdom') + +class FixJSDOMEnvironment extends JSDOMEnvironment { + constructor (...args) { + super(...args) + + this.global.TextEncoder = TextEncoder + this.global.TextDecoder = TextDecoder + this.global.crypto = { + subtle: crypto.webcrypto.subtle + } + } +} + +module.exports = FixJSDOMEnvironment diff --git a/packages/browser/test/index.test.ts b/packages/browser/test/index.test.ts index 7e39b8cb13..bfd9d47fc0 100644 --- a/packages/browser/test/index.test.ts +++ b/packages/browser/test/index.test.ts @@ -4,23 +4,38 @@ const DONE = window.XMLHttpRequest.DONE const API_KEY = '030bab153e7c2349be364d23b5ae93b5' -function mockFetch () { - const makeMockXHR = () => ({ - open: jest.fn(), - send: jest.fn(), - setRequestHeader: jest.fn(), - readyState: DONE, - onreadystatechange: () => {} - }) +interface MockXHR { + open: jest.Mock + send: jest.Mock + setRequestHeader: jest.Mock +} - const session = makeMockXHR() - const notify = makeMockXHR() +type SendCallback = (xhr: MockXHR) => void + +function mockFetch (onSessionSend?: SendCallback, onNotifySend?: SendCallback) { + const makeMockXHR = (onSend?: SendCallback) => { + const xhr = { + open: jest.fn(), + send: jest.fn(), + setRequestHeader: jest.fn(), + readyState: DONE, + onreadystatechange: () => {} + } + xhr.send.mockImplementation((...args) => { + xhr.onreadystatechange() + onSend?.(xhr) + }) + return xhr + } + + const session = makeMockXHR(onSessionSend) + const notify = makeMockXHR(onNotifySend) // @ts-ignore window.XMLHttpRequest = jest.fn() .mockImplementationOnce(() => session) .mockImplementationOnce(() => notify) - .mockImplementation(() => makeMockXHR()) + .mockImplementation(() => makeMockXHR(() => {})) // @ts-ignore window.XMLHttpRequest.DONE = DONE @@ -28,6 +43,9 @@ function mockFetch () { } describe('browser notifier', () => { + const onNotifySend = jest.fn() + const onSessionSend = jest.fn() + beforeAll(() => { jest.spyOn(console, 'debug').mockImplementation(() => {}) jest.spyOn(console, 'warn').mockImplementation(() => {}) @@ -35,7 +53,6 @@ describe('browser notifier', () => { beforeEach(() => { jest.resetModules() - mockFetch() }) function getBugsnag (): typeof BugsnagBrowserStatic { @@ -56,48 +73,48 @@ describe('browser notifier', () => { }) it('notifies handled errors', (done) => { - const { session, notify } = mockFetch() - const Bugsnag = getBugsnag() - Bugsnag.start(API_KEY) - Bugsnag.notify(new Error('123'), undefined, (err, event) => { - if (err) { - done(err) - } - expect(event.breadcrumbs[0]).toStrictEqual(expect.objectContaining({ - type: 'state', - message: 'Bugsnag loaded' - })) - expect(event.originalError.message).toBe('123') - + const onSessionSend = (session: MockXHR) => { expect(session.open).toHaveBeenCalledWith('POST', 'https://sessions.bugsnag.com') expect(session.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'application/json') expect(session.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Api-Key', '030bab153e7c2349be364d23b5ae93b5') expect(session.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Payload-Version', '1') expect(session.send).toHaveBeenCalledWith(expect.any(String)) + } + const onNotifySend = (notify: MockXHR) => { expect(notify.open).toHaveBeenCalledWith('POST', 'https://notify.bugsnag.com') expect(notify.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'application/json') expect(notify.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Api-Key', '030bab153e7c2349be364d23b5ae93b5') expect(notify.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Payload-Version', '4') expect(notify.send).toHaveBeenCalledWith(expect.any(String)) done() - }) + } - session.onreadystatechange() - notify.onreadystatechange() + mockFetch(onSessionSend, onNotifySend) + + const Bugsnag = getBugsnag() + Bugsnag.start(API_KEY) + Bugsnag.notify(new Error('123'), undefined, (err, event) => { + if (err) { + done(err) + } + expect(event.breadcrumbs[0]).toStrictEqual(expect.objectContaining({ + type: 'state', + message: 'Bugsnag loaded' + })) + expect(event.originalError.message).toBe('123') + }) }) it('does not send an event with invalid configuration', () => { - const { session, notify } = mockFetch() + mockFetch(onSessionSend, onNotifySend) + const Bugsnag = getBugsnag() // @ts-expect-error Bugsnag.start({ apiKey: API_KEY, endpoints: { notify: 'https://notify.bugsnag.com' } }) Bugsnag.notify(new Error('123'), undefined, (err, event) => { expect(err).toStrictEqual(new Error('Event not sent due to incomplete endpoint configuration')) }) - - session.onreadystatechange() - notify.onreadystatechange() }) it('does not send a session with invalid configuration', (done) => { @@ -175,7 +192,8 @@ describe('browser notifier', () => { maxEvents: 10, generateAnonymousId: false, trackInlineScripts: true, - reportUnhandledPromiseRejectionsAsHandled: true + reportUnhandledPromiseRejectionsAsHandled: true, + sendPayloadChecksums: true } Bugsnag.start(completeConfig) @@ -253,4 +271,96 @@ describe('browser notifier', () => { startSession.mockRestore() }) }) + + describe('payload checksum behavior (Bugsnag-Integrity header)', () => { + beforeEach(() => { + // @ts-ignore + window.isSecureContext = true + }) + + afterEach(() => { + // @ts-ignore + window.isSecureContext = false + }) + + it('includes the integrity header by default', (done) => { + const onSessionSend = (session: MockXHR) => { + expect(session.open).toHaveBeenCalledWith('POST', 'https://sessions.bugsnag.com') + expect(session.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Integrity', expect.any(String)) + expect(session.send).toHaveBeenCalledWith(expect.any(String)) + } + + const onNotifySend = (notify: MockXHR) => { + expect(notify.open).toHaveBeenCalledWith('POST', 'https://notify.bugsnag.com') + expect(notify.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Integrity', expect.any(String)) + expect(notify.send).toHaveBeenCalledWith(expect.any(String)) + done() + } + + mockFetch(onSessionSend, onNotifySend) + + const Bugsnag = getBugsnag() + Bugsnag.start(API_KEY) + + Bugsnag.notify(new Error('123'), undefined, (err, event) => { + if (err) { + done(err) + } + }) + }) + + it('does not include the integrity header if endpoint configuration is supplied', (done) => { + const onSessionSend = (session: MockXHR) => { + expect(session.open).toHaveBeenCalledWith('POST', 'https://sessions.custom.com') + expect(session.setRequestHeader).not.toHaveBeenCalledWith('Bugsnag-Integrity', expect.any(String)) + expect(session.send).toHaveBeenCalledWith(expect.any(String)) + } + + const onNotifySend = (notify: MockXHR) => { + expect(notify.open).toHaveBeenCalledWith('POST', 'https://notify.custom.com') + expect(notify.setRequestHeader).not.toHaveBeenCalledWith('Bugsnag-Integrity', expect.any(String)) + expect(notify.send).toHaveBeenCalledWith(expect.any(String)) + done() + } + + mockFetch(onSessionSend, onNotifySend) + + const Bugsnag = getBugsnag() + Bugsnag.start({ apiKey: API_KEY, endpoints: { notify: 'https://notify.custom.com', sessions: 'https://sessions.custom.com' } }) + Bugsnag.notify(new Error('123'), undefined, (err, event) => { + if (err) { + done(err) + } + }) + }) + + it('can be enabled for a custom endpoint configuration by using sendPayloadChecksums', (done) => { + const onSessionSend = (session: MockXHR) => { + expect(session.open).toHaveBeenCalledWith('POST', 'https://sessions.custom.com') + expect(session.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Integrity', expect.any(String)) + expect(session.send).toHaveBeenCalledWith(expect.any(String)) + } + + const onNotifySend = (notify: MockXHR) => { + expect(notify.open).toHaveBeenCalledWith('POST', 'https://notify.custom.com') + expect(notify.setRequestHeader).toHaveBeenCalledWith('Bugsnag-Integrity', expect.any(String)) + expect(notify.send).toHaveBeenCalledWith(expect.any(String)) + done() + } + + mockFetch(onSessionSend, onNotifySend) + + const Bugsnag = getBugsnag() + Bugsnag.start({ + apiKey: API_KEY, + endpoints: { notify: 'https://notify.custom.com', sessions: 'https://sessions.custom.com' }, + sendPayloadChecksums: true + }) + Bugsnag.notify(new Error('123'), undefined, (err, event) => { + if (err) { + done(err) + } + }) + }) + }) }) diff --git a/packages/browser/types/bugsnag.d.ts b/packages/browser/types/bugsnag.d.ts index d198264fb7..2b9fcfe37e 100644 --- a/packages/browser/types/bugsnag.d.ts +++ b/packages/browser/types/bugsnag.d.ts @@ -5,6 +5,7 @@ interface BrowserConfig extends Config { collectUserIp?: boolean generateAnonymousId?: boolean trackInlineScripts?: boolean + sendPayloadChecksums?: boolean } export interface BrowserBugsnagStatic extends BugsnagStatic { diff --git a/packages/core/client.js b/packages/core/client.js index 5e8a6988e5..be429a3c1b 100644 --- a/packages/core/client.js +++ b/packages/core/client.js @@ -121,6 +121,11 @@ class Client { return schema }, this._schema) + // sendPayloadChecksums is false by default unless custom endpoints are not specified + if (!opts.endpoints) { + opts.sendPayloadChecksums = 'sendPayloadChecksums' in opts ? opts.sendPayloadChecksums : true + } + // accumulate configuration and error messages const { errors, config } = reduce(keys(schema), (accum, key) => { const defaultValue = schema[key].defaultValue(opts[key]) diff --git a/packages/core/config.js b/packages/core/config.js index e47c1e3bba..04081a4acc 100644 --- a/packages/core/config.js +++ b/packages/core/config.js @@ -173,5 +173,10 @@ module.exports.schema = { defaultValue: () => false, message: 'should be true|false', validate: value => value === true || value === false + }, + sendPayloadChecksums: { + defaultValue: () => false, + message: 'should be true|false', + validate: value => value === true || value === false } } diff --git a/packages/core/types/common.d.ts b/packages/core/types/common.d.ts index 755dc79286..4fd333d235 100644 --- a/packages/core/types/common.d.ts +++ b/packages/core/types/common.d.ts @@ -29,6 +29,7 @@ export interface Config { plugins?: Plugin[] user?: User | null reportUnhandledPromiseRejectionsAsHandled?: boolean + sendPayloadChecksums?: boolean } export type OnErrorCallback = (event: Event, cb: (err: null | Error, shouldSend?: boolean) => void) => void | boolean | Promise diff --git a/packages/delivery-fetch/delivery.js b/packages/delivery-fetch/delivery.js index 6cace0352e..31616800db 100644 --- a/packages/delivery-fetch/delivery.js +++ b/packages/delivery-fetch/delivery.js @@ -1,18 +1,44 @@ import payload from '@bugsnag/core/lib/json-payload' -const delivery = (client, fetch = global.fetch) => ({ +function getIntegrityHeaderValue (sendPayloadChecksums, windowOrWorkerGlobalScope, requestBody, headers) { + if (sendPayloadChecksums && windowOrWorkerGlobalScope.isSecureContext && windowOrWorkerGlobalScope.crypto && windowOrWorkerGlobalScope.crypto.subtle && windowOrWorkerGlobalScope.crypto.subtle.digest && typeof TextEncoder === 'function') { + const msgUint8 = new TextEncoder().encode(requestBody) + return windowOrWorkerGlobalScope.crypto.subtle.digest('SHA-1', msgUint8).then((hashBuffer) => { + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + + return 'sha1 ' + hashHex + }) + } + + return Promise.resolve() +} + +const delivery = (client, fetch = global.fetch, windowOrWorkerGlobalScope = window) => ({ sendEvent: (event, cb = () => {}) => { const url = client._config.endpoints.notify - fetch(url, { - method: 'POST', - headers: { + const body = payload.event(event, client._config.redactedKeys) + + getIntegrityHeaderValue(client._config.sendPayloadChecksums, windowOrWorkerGlobalScope, body).then(integrityHeaderValue => { + const headers = { 'Content-Type': 'application/json', 'Bugsnag-Api-Key': event.apiKey || client._config.apiKey, 'Bugsnag-Payload-Version': '4', 'Bugsnag-Sent-At': (new Date()).toISOString() - }, - body: payload.event(event, client._config.redactedKeys) + } + + if (integrityHeaderValue) { + headers['Bugsnag-Integrity'] = integrityHeaderValue + } + + return fetch(url, { + method: 'POST', + headers, + body + }) }).then(() => { cb(null) }).catch(err => { @@ -23,15 +49,25 @@ const delivery = (client, fetch = global.fetch) => ({ sendSession: (session, cb = () => { }) => { const url = client._config.endpoints.sessions - fetch(url, { - method: 'POST', - headers: { + const body = payload.session(session, client._config.redactedKeys) + + getIntegrityHeaderValue(client._config.sendPayloadChecksums, windowOrWorkerGlobalScope, body).then((integrityHeaderValue) => { + const headers = { 'Content-Type': 'application/json', 'Bugsnag-Api-Key': client._config.apiKey, 'Bugsnag-Payload-Version': '1', 'Bugsnag-Sent-At': (new Date()).toISOString() - }, - body: payload.session(session, client._config.redactedKeys) + } + + if (integrityHeaderValue) { + headers['Bugsnag-Integrity'] = integrityHeaderValue + } + + return fetch(url, { + method: 'POST', + headers, + body + }) }).then(() => { cb(null) }).catch(err => { diff --git a/packages/delivery-fetch/test/delivery.test.ts b/packages/delivery-fetch/test/delivery.test.ts index 5fa9df030d..afd9911c11 100644 --- a/packages/delivery-fetch/test/delivery.test.ts +++ b/packages/delivery-fetch/test/delivery.test.ts @@ -6,6 +6,8 @@ const globalAny: any = global describe('delivery:fetch', () => { it('sends events successfully', done => { + window.isSecureContext = true + globalAny.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve() })) @@ -13,7 +15,8 @@ describe('delivery:fetch', () => { const config = { apiKey: 'aaaaaaaa', endpoints: { notify: '/echo/' }, - redactedKeys: [] + redactedKeys: [], + sendPayloadChecksums: true } const payload = { sample: 'payload' } as unknown as EventDeliveryPayload @@ -28,11 +31,59 @@ describe('delivery:fetch', () => { 'Bugsnag-Api-Key': 'aaaaaaaa', 'Bugsnag-Payload-Version': '4', 'Bugsnag-Sent-At': expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Bugsnag-Integrity': 'sha1 14faf2461b0519f9d9d62cfb8d79483fcc8f825c' }) })) done() }) + + window.isSecureContext = false + }) + + it('omits the bugsnag integrity header when not in a secure context', done => { + globalAny.fetch = jest.fn(() => Promise.resolve({ + json: () => Promise.resolve() + })) + + const config = { + apiKey: 'aaaaaaaa', + endpoints: { notify: '/echo/' }, + redactedKeys: [], + sendPayloadChecksums: true + } + + const payload = { sample: 'payload' } as unknown as EventDeliveryPayload + + delivery({ logger: { }, _config: config } as unknown as Client).sendEvent(payload, (err) => { + expect(err).toBeNull() + expect(globalAny.fetch.mock.calls[0][1].headers['Bugsnag-Integrity']).toBeUndefined() + done() + }) + }) + + it('omits the bugsnag integrity header when sendPayloadChecksums is false', done => { + window.isSecureContext = true + globalAny.fetch = jest.fn(() => Promise.resolve({ + json: () => Promise.resolve() + })) + + const config = { + apiKey: 'aaaaaaaa', + endpoints: { notify: '/echo/' }, + redactedKeys: [], + sendPayloadChecksums: false + } + + const payload = { sample: 'payload' } as unknown as EventDeliveryPayload + + delivery({ logger: { }, _config: config } as unknown as Client).sendEvent(payload, (err) => { + expect(err).toBeNull() + expect(globalAny.fetch.mock.calls[0][1].headers['Bugsnag-Integrity']).toBeUndefined() + done() + }) + + window.isSecureContext = false }) it('returns an error for failed event delivery', done => { @@ -56,6 +107,8 @@ describe('delivery:fetch', () => { }) it('sends sessions successfully', done => { + window.isSecureContext = true + globalAny.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve() })) @@ -63,7 +116,8 @@ describe('delivery:fetch', () => { const config = { apiKey: 'aaaaaaaa', endpoints: { sessions: '/echo/' }, - redactedKeys: [] + redactedKeys: [], + sendPayloadChecksums: true } const payload = { sample: 'payload' } as unknown as SessionDeliveryPayload @@ -77,11 +131,14 @@ describe('delivery:fetch', () => { 'Bugsnag-Api-Key': 'aaaaaaaa', 'Bugsnag-Payload-Version': '1', 'Bugsnag-Sent-At': expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Bugsnag-Integrity': 'sha1 14faf2461b0519f9d9d62cfb8d79483fcc8f825c' }) })) done() }) + + window.isSecureContext = false }) it('returns an error for failed sessions', done => { diff --git a/packages/delivery-xml-http-request/delivery.js b/packages/delivery-xml-http-request/delivery.js index 49f6c5c4e8..2714c1c8a4 100644 --- a/packages/delivery-xml-http-request/delivery.js +++ b/packages/delivery-xml-http-request/delivery.js @@ -1,5 +1,20 @@ const payload = require('@bugsnag/core/lib/json-payload') +function getIntegrityHeaderValue (windowOrWorkerGlobalScope, requestBody) { + if (windowOrWorkerGlobalScope.isSecureContext && windowOrWorkerGlobalScope.crypto && windowOrWorkerGlobalScope.crypto.subtle && windowOrWorkerGlobalScope.crypto.subtle.digest && typeof TextEncoder === 'function') { + const msgUint8 = new TextEncoder().encode(requestBody) + return windowOrWorkerGlobalScope.crypto.subtle.digest('SHA-1', msgUint8).then((hashBuffer) => { + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + + return 'sha1 ' + hashHex + }) + } + return Promise.resolve() +} + module.exports = (client, win = window) => ({ sendEvent: (event, cb = () => {}) => { try { @@ -32,7 +47,20 @@ module.exports = (client, win = window) => ({ req.setRequestHeader('Bugsnag-Api-Key', event.apiKey || client._config.apiKey) req.setRequestHeader('Bugsnag-Payload-Version', '4') req.setRequestHeader('Bugsnag-Sent-At', (new Date()).toISOString()) - req.send(body) + + if (client._config.sendPayloadChecksums && typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]') !== -1) { + getIntegrityHeaderValue(win, body).then((integrity) => { + if (integrity) { + req.setRequestHeader('Bugsnag-Integrity', integrity) + } + req.send(body) + }).catch((err) => { + client._logger.error(err) + req.send(body) + }) + } else { + req.send(body) + } } catch (e) { client._logger.error(e) } @@ -45,6 +73,7 @@ module.exports = (client, win = window) => ({ return cb(err) } const req = new win.XMLHttpRequest() + const body = payload.session(session, client._config.redactedKeys) req.onreadystatechange = function () { if (req.readyState === win.XMLHttpRequest.DONE) { @@ -64,7 +93,20 @@ module.exports = (client, win = window) => ({ req.setRequestHeader('Bugsnag-Api-Key', client._config.apiKey) req.setRequestHeader('Bugsnag-Payload-Version', '1') req.setRequestHeader('Bugsnag-Sent-At', (new Date()).toISOString()) - req.send(payload.session(session, client._config.redactedKeys)) + + if (client._config.sendPayloadChecksums && typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]') !== -1) { + getIntegrityHeaderValue(win, body).then((integrity) => { + if (integrity) { + req.setRequestHeader('Bugsnag-Integrity', integrity) + } + req.send(body) + }).catch((err) => { + client._logger.error(err) + req.send(body) + }) + } else { + req.send(body) + } } catch (e) { client._logger.error(e) } diff --git a/packages/delivery-xml-http-request/test/delivery.test.ts b/packages/delivery-xml-http-request/test/delivery.test.ts index 32620b316e..652ab458b6 100644 --- a/packages/delivery-xml-http-request/test/delivery.test.ts +++ b/packages/delivery-xml-http-request/test/delivery.test.ts @@ -54,10 +54,11 @@ describe('delivery:XMLHttpRequest', () => { const config = { apiKey: 'aaaaaaaa', endpoints: { notify: 'echo/' }, - redactedKeys: [] + redactedKeys: [], + sendPayloadChecksums: true } - delivery({ _logger: {}, _config: config } as unknown as Client, { XMLHttpRequest } as unknown as Window).sendEvent(payload, (err: any) => { + delivery({ _logger: {}, _config: config } as unknown as Client, { ...window, XMLHttpRequest, isSecureContext: true } as unknown as Window).sendEvent(payload, (err: any) => { expect(err).toBe(null) expect(requests.length).toBe(1) expect(requests[0].method).toBe('POST') @@ -66,6 +67,118 @@ describe('delivery:XMLHttpRequest', () => { expect(requests[0].headers['Bugsnag-Api-Key']).toEqual('aaaaaaaa') expect(requests[0].headers['Bugsnag-Payload-Version']).toEqual('4') expect(requests[0].headers['Bugsnag-Sent-At']).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + expect(requests[0].headers['Bugsnag-Integrity']).toEqual('sha1 14faf2461b0519f9d9d62cfb8d79483fcc8f825c') + expect(requests[0].data).toBe(JSON.stringify(payload)) + done() + }) + }) + + it('omits the bugsnag integrity header when sendPayloadChecksums is false', done => { + const requests: MockXMLHttpRequest[] = [] + + // mock XMLHttpRequest class + function XMLHttpRequest (this: MockXMLHttpRequest) { + this.method = null + this.url = null + this.data = null + this.headers = {} + this.readyState = XMLHttpRequest.UNSENT + requests.push(this) + } + XMLHttpRequest.UNSENT = 0 + XMLHttpRequest.OPENED = 1 + XMLHttpRequest.HEADERS_RECEIVED = 2 + XMLHttpRequest.LOADING = 3 + XMLHttpRequest.DONE = 4 + XMLHttpRequest.prototype.open = function (method: string, url: string) { + this.method = method + this.url = url + this.readyState = XMLHttpRequest.OPENED + this.onreadystatechange() + } + XMLHttpRequest.prototype.setRequestHeader = function (key: string, val: string) { + this.headers[key] = val + } + XMLHttpRequest.prototype.send = function (data: string) { + this.data = data + this.readyState = XMLHttpRequest.HEADERS_RECEIVED + this.onreadystatechange() + + setTimeout(() => { + this.status = 200 + this.readyState = XMLHttpRequest.DONE + this.onreadystatechange() + }, 500) + } + + const payload = { sample: 'payload' } as unknown as EventDeliveryPayload + const config = { + apiKey: 'aaaaaaaa', + endpoints: { notify: 'https/echo/' }, + redactedKeys: [], + sendPayloadChecksums: false + } + + delivery({ _logger: { }, _config: config } as unknown as Client, { ...window, XMLHttpRequest, isSecureContext: true } as unknown as Window).sendEvent(payload, (err: any) => { + expect(err).toBe(null) + expect(requests.length).toBe(1) + expect(requests[0].method).toBe('POST') + expect(requests[0].headers['Bugsnag-Integrity']).toBeUndefined() + expect(requests[0].data).toBe(JSON.stringify(payload)) + done() + }) + }) + + it('omits the bugsnag integrity header when not in a secure context', done => { + const requests: MockXMLHttpRequest[] = [] + + // mock XMLHttpRequest class + function XMLHttpRequest (this: MockXMLHttpRequest) { + this.method = null + this.url = null + this.data = null + this.headers = {} + this.readyState = XMLHttpRequest.UNSENT + requests.push(this) + } + XMLHttpRequest.UNSENT = 0 + XMLHttpRequest.OPENED = 1 + XMLHttpRequest.HEADERS_RECEIVED = 2 + XMLHttpRequest.LOADING = 3 + XMLHttpRequest.DONE = 4 + XMLHttpRequest.prototype.open = function (method: string, url: string) { + this.method = method + this.url = url + this.readyState = XMLHttpRequest.OPENED + this.onreadystatechange() + } + XMLHttpRequest.prototype.setRequestHeader = function (key: string, val: string) { + this.headers[key] = val + } + XMLHttpRequest.prototype.send = function (data: string) { + this.data = data + this.readyState = XMLHttpRequest.HEADERS_RECEIVED + this.onreadystatechange() + + setTimeout(() => { + this.status = 200 + this.readyState = XMLHttpRequest.DONE + this.onreadystatechange() + }, 500) + } + + const payload = { sample: 'payload' } as unknown as EventDeliveryPayload + const config = { + apiKey: 'aaaaaaaa', + endpoints: { notify: 'https/echo/' }, + redactedKeys: [] + } + + delivery({ _logger: {}, _config: config } as unknown as Client, { ...window, XMLHttpRequest, isSecureContext: false } as unknown as Window).sendEvent(payload, (err: any) => { + expect(err).toBe(null) + expect(requests.length).toBe(1) + expect(requests[0].method).toBe('POST') + expect(requests[0].headers['Bugsnag-Integrity']).toBeUndefined() expect(requests[0].data).toBe(JSON.stringify(payload)) done() }) @@ -232,9 +345,10 @@ describe('delivery:XMLHttpRequest', () => { const config = { apiKey: 'aaaaaaaa', endpoints: { notify: '/', sessions: '/echo/' }, - redactedKeys: [] + redactedKeys: [], + sendPayloadChecksums: true } - delivery({ _config: config, _logger: {} } as unknown as Client, { XMLHttpRequest } as unknown as Window).sendSession(payload, (err) => { + delivery({ _config: config, _logger: {} } as unknown as Client, { ...window, XMLHttpRequest, isSecureContext: true } as unknown as Window).sendSession(payload, (err) => { expect(err).toBe(null) expect(requests.length).toBe(1) expect(requests[0].method).toBe('POST') @@ -243,6 +357,7 @@ describe('delivery:XMLHttpRequest', () => { expect(requests[0].headers['Bugsnag-Api-Key']).toEqual('aaaaaaaa') expect(requests[0].headers['Bugsnag-Payload-Version']).toEqual('1') expect(requests[0].headers['Bugsnag-Sent-At']).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + expect(requests[0].headers['Bugsnag-Integrity']).toEqual('sha1 14faf2461b0519f9d9d62cfb8d79483fcc8f825c') expect(requests[0].data).toBe(JSON.stringify(payload)) done() }) diff --git a/packages/react-native/src/config.js b/packages/react-native/src/config.js index da2be13468..0dd03184b2 100644 --- a/packages/react-native/src/config.js +++ b/packages/react-native/src/config.js @@ -3,7 +3,21 @@ const stringWithLength = require('@bugsnag/core/lib/validators/string-with-lengt const rnPackage = require('react-native/package.json') const iserror = require('iserror') -const ALLOWED_IN_JS = ['onError', 'onBreadcrumb', 'logger', 'metadata', 'user', 'context', 'codeBundleId', 'plugins', 'featureFlags', 'reportUnhandledPromiseRejectionsAsHandled'] +const ALLOWED_IN_JS = [ + 'onError', + 'onBreadcrumb', + 'logger', + 'metadata', + 'user', + 'context', + 'codeBundleId', + 'plugins', + 'featureFlags', + 'reportUnhandledPromiseRejectionsAsHandled', + // sendPayloadChecksums gets set in core because its 'default value' depends on other config values and so cannot remain unset + 'sendPayloadChecksums' +] + const allowedErrorTypes = () => ({ unhandledExceptions: true, unhandledRejections: true, diff --git a/packages/web-worker/src/notifier.js b/packages/web-worker/src/notifier.js index bf8a549f97..cd92a9469d 100644 --- a/packages/web-worker/src/notifier.js +++ b/packages/web-worker/src/notifier.js @@ -36,7 +36,7 @@ export const Bugsnag = { // configure a client with user supplied options const bugsnag = new Client(opts, schema, internalPlugins, { name, version, url }) - bugsnag._setDelivery(delivery) + bugsnag._setDelivery(client => delivery(client, undefined, self)) bugsnag._logger.debug('Loaded!') diff --git a/packages/web-worker/test/notifier.test.ts b/packages/web-worker/test/notifier.test.ts index f0b9b2182f..36d26f744b 100644 --- a/packages/web-worker/test/notifier.test.ts +++ b/packages/web-worker/test/notifier.test.ts @@ -9,8 +9,11 @@ function getBugsnag (): typeof WorkerBugsnagStatic { return bugsnag } -function mockFetch () { - typedGlobal.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve() })) +function mockFetch (onFetch?: () => void) { + typedGlobal.fetch = jest.fn(() => { + onFetch?.() + return Promise.resolve({ json: () => Promise.resolve() }) + }) } const testConfig = { @@ -28,6 +31,7 @@ beforeAll(() => { beforeEach(() => { jest.resetModules() + jest.resetAllMocks() }) describe('worker notifier', () => { @@ -85,41 +89,125 @@ describe('worker notifier', () => { describe('session management', () => { it('successfully starts a session', (done) => { + expect.assertions(2) + + const onFetch = () => { + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://sessions.bugsnag.com', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Bugsnag-Api-Key': API_KEY, + 'Bugsnag-Payload-Version': '1', + 'Bugsnag-Sent-At': expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + 'Content-Type': 'application/json' + }) + })) + + done() + } + + mockFetch(onFetch) + const Bugsnag = getBugsnag() - Bugsnag.start(API_KEY) + Bugsnag.start({ apiKey: API_KEY }) expect(typedGlobal.fetch).not.toHaveBeenCalled() Bugsnag.startSession() + }) - expect(typedGlobal.fetch).toHaveBeenCalledWith('https://sessions.bugsnag.com', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Bugsnag-Api-Key': API_KEY, - 'Bugsnag-Payload-Version': '1', - 'Bugsnag-Sent-At': expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), - 'Content-Type': 'application/json' - }) - })) + it('automatically starts a session', (done) => { + expect.assertions(1) + const onFetch = () => { + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://sessions.bugsnag.com', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Bugsnag-Api-Key': API_KEY, + 'Bugsnag-Payload-Version': '1', + 'Bugsnag-Sent-At': expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + 'Content-Type': 'application/json' + }) + })) + done() + } - done() + mockFetch(onFetch) + const Bugsnag = getBugsnag() + Bugsnag.start({ apiKey: API_KEY, autoTrackSessions: true }) }) + }) - it('automatically starts a session', (done) => { + describe('payload checksum behavior (Bugsnag-Integrity header)', () => { + beforeEach(() => { + // @ts-ignore + window.isSecureContext = true + }) + + afterEach(() => { + // @ts-ignore + window.isSecureContext = false + }) + + it('includes the integrity header by default', (done) => { const Bugsnag = getBugsnag() Bugsnag.start({ apiKey: API_KEY, autoTrackSessions: true }) + Bugsnag.notify(new Error('123'), undefined, (err, event) => { + if (err) done(err) + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://sessions.bugsnag.com', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Bugsnag-Integrity': expect.any(String) + }) + })) + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://notify.bugsnag.com', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Bugsnag-Integrity': expect.any(String) + }) + })) + done() + }) + }) - expect(typedGlobal.fetch).toHaveBeenCalledWith('https://sessions.bugsnag.com', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Bugsnag-Api-Key': API_KEY, - 'Bugsnag-Payload-Version': '1', - 'Bugsnag-Sent-At': expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), - 'Content-Type': 'application/json' - }) - })) + it('does not include the integrity header if endpoint configuration is supplied', (done) => { + const Bugsnag = getBugsnag() + Bugsnag.start({ apiKey: API_KEY, autoTrackSessions: true, endpoints: { notify: 'https://notify.custom.com', sessions: 'https://sessions.custom.com' } }) + Bugsnag.notify(new Error('123'), undefined, (err, event) => { + if (err) done(err) + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://sessions.custom.com', expect.objectContaining({ + method: 'POST', + headers: expect.not.objectContaining({ + 'Bugsnag-Integrity': expect.any(String) + }) + })) + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://notify.custom.com', expect.objectContaining({ + method: 'POST', + headers: expect.not.objectContaining({ + 'Bugsnag-Integrity': expect.any(String) + }) + })) + done() + }) + }) - done() + it('can be enabled for a custom endpoint configuration by using sendPayloadChecksums', (done) => { + const Bugsnag = getBugsnag() + Bugsnag.start({ apiKey: API_KEY, autoTrackSessions: true, endpoints: { notify: 'https://notify.custom.com', sessions: 'https://sessions.custom.com' }, sendPayloadChecksums: true }) + Bugsnag.notify(new Error('123'), undefined, (err, event) => { + if (err) done(err) + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://sessions.custom.com', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Bugsnag-Integrity': expect.any(String) + }) + })) + expect(typedGlobal.fetch).toHaveBeenCalledWith('https://notify.custom.com', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Bugsnag-Integrity': expect.any(String) + }) + })) + done() + }) }) }) }) diff --git a/test/browser/features/fixtures/integrity/script/disabled.html b/test/browser/features/fixtures/integrity/script/disabled.html new file mode 100644 index 0000000000..02758b92c3 --- /dev/null +++ b/test/browser/features/fixtures/integrity/script/disabled.html @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/test/browser/features/fixtures/integrity/script/enabled.html b/test/browser/features/fixtures/integrity/script/enabled.html new file mode 100644 index 0000000000..8404b51cc1 --- /dev/null +++ b/test/browser/features/fixtures/integrity/script/enabled.html @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/test/browser/features/fixtures/web_worker/integrity/index.html b/test/browser/features/fixtures/web_worker/integrity/index.html new file mode 100644 index 0000000000..ec5460e9cf --- /dev/null +++ b/test/browser/features/fixtures/web_worker/integrity/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/test/browser/features/fixtures/web_worker/integrity/worker.js b/test/browser/features/fixtures/web_worker/integrity/worker.js new file mode 100644 index 0000000000..65d02b17d8 --- /dev/null +++ b/test/browser/features/fixtures/web_worker/integrity/worker.js @@ -0,0 +1,24 @@ +importScripts("/docs/node_modules/@bugsnag/web-worker/dist/bugsnag.web-worker.min.js") + +onmessage = function (e) { + var payload = e.data.payload + + switch (e.data.type) { + case 'bugsnag-start': + Bugsnag.start({ + apiKey: payload.API_KEY, + autoTrackSessions: true, + sendPayloadChecksums: true, + endpoints: { + notify: payload.NOTIFY, + sessions: payload.SESSIONS + } + }) + postMessage('bugsnag-ready') + break; + case 'bugsnag-notify': + Bugsnag.notify(new Error('I am an error')) + break; + default: + } +} diff --git a/test/browser/features/integrity.feature b/test/browser/features/integrity.feature new file mode 100644 index 0000000000..22060599e0 --- /dev/null +++ b/test/browser/features/integrity.feature @@ -0,0 +1,19 @@ +@skip_ie_11 @skip_ios_10 @skip_safari_10 @skip_safari_16 @skip_edge_17 @skip_chrome_43 +Feature: Bugsnag-Integrity header + +Scenario: Integrity headers are set when setPayloadChecksums is true + When I navigate to the test URL "/integrity/script/enabled.html" + And I wait to receive an error + And I wait to receive a session + Then the error is a valid browser payload for the error reporting API + And the session "bugsnag-integrity" header matches the regex "^sha1 (\d|[abcdef]){40}$" + And the error "bugsnag-integrity" header matches the regex "^sha1 (\d|[abcdef]){40}$" + +Scenario: Integrity headers are not set when setPayloadChecksums is false + When I navigate to the test URL "/integrity/script/disabled.html" + And I wait to receive an error + And I wait to receive a session + Then the error is a valid browser payload for the error reporting API + And the session "bugsnag-integrity" header is not present + And the error "bugsnag-integrity" header is not present + diff --git a/test/browser/features/support/env.rb b/test/browser/features/support/env.rb index 14a09082ca..1ec92bc8d2 100644 --- a/test/browser/features/support/env.rb +++ b/test/browser/features/support/env.rb @@ -10,11 +10,7 @@ def get_test_url(path) maze_runner = "#{ENV['HOST']}:9339" end - if Maze.config.https - protocol = 'https' - else - protocol = 'http' - end + protocol = Maze.config.https ? 'https' : 'http' notify = "#{protocol}://#{maze_runner}/notify" sessions = "#{protocol}://#{maze_runner}/sessions" diff --git a/test/browser/features/web_worker.feature b/test/browser/features/web_worker.feature index 662c5e7125..b00fb695fe 100644 --- a/test/browser/features/web_worker.feature +++ b/test/browser/features/web_worker.feature @@ -46,3 +46,12 @@ Feature: worker notifier When I navigate to the test URL "/web_worker/auto_track_sessions" And I wait to receive a session Then the session is a valid browser payload for the session tracking API + + @skip_safari_16 + Scenario: Integrity headers are set when setPayloadChecksums is true + When I navigate to the test URL "/web_worker/integrity" + And I wait to receive an error + And I wait to receive a session + Then the error is a valid browser payload for the error reporting API + And the session "bugsnag-integrity" header matches the regex "^sha1 (\d|[abcdef]){40}$" + And the error "bugsnag-integrity" header matches the regex "^sha1 (\d|[abcdef]){40}$"