Skip to content

Commit

Permalink
add sendPayloadChecksums config option and implement Bugsnag-Integrit…
Browse files Browse the repository at this point in the history
…y header (#2221)

* set Bugsnag-Integrity header in delivery-fetch

* add jest dir to docker copy

* try fix testEnvironment path resolution

* add jest dir to docker copy

* set jest env

* set Bugsnag-Integrity header in delivery-xml-http-request

* do not use async syntax

* handle no promises in ie11

* do not use promise finally

* add sendPayloadChecksums to browser

* fix types

* tidy test suite

* add integrity header to delivery-fetch sendSession

* add e2e tests for integrity headers

* respect sendPayloadChecksums in delivery-fetch

* move sendPayloadChecksums to core

* add web worker integration tests for sendPayloadChecksums

* add e2e tests for integrity header on web workers

* update changelog

* skip integrity tests on unsupported browsers

* rename fixture documents

* use ternary

* test: ✅ skip integrity check tests in http context

---------

Co-authored-by: Dan Skinner <[email protected]>
Co-authored-by: Ben Wilson <[email protected]>
  • Loading branch information
3 people authored Jan 24, 2025
1 parent 1d81348 commit 2596383
Show file tree
Hide file tree
Showing 26 changed files with 706 additions and 87 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Introduce `sendPayloadChecksums` option and set `Bugsnag-Integrity` headers on events and sessions [#2221](https://github.com/bugsnag/bugsnag-js/pull/2221)

### Changed

- (plugin-angular) Generate type definition using Angular 17 [#2275](https://github.com/bugsnag/bugsnag-js/pull/2275)
Expand Down
1 change: 1 addition & 0 deletions dockerfiles/Dockerfile.browser
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dockerfiles/Dockerfile.ci
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dockerfiles/Dockerfile.node
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ module.exports = {
],
projects: [
project('core', ['core']),
project('web workers', ['web-worker']),
project('web workers', ['web-worker'], {
testEnvironment: '<rootDir>/jest/FixJSDOMEnvironment.js'
}),
project('shared plugins', ['plugin-app-duration', 'plugin-stackframe-path-normaliser']),
project('browser', [
'browser',
Expand All @@ -49,7 +51,9 @@ module.exports = {
'plugin-simple-throttle',
'plugin-console-breadcrumbs',
'plugin-browser-session'
]),
], {
testEnvironment: '<rootDir>/jest/FixJSDOMEnvironment.js'
}),
project('react native', [
'react-native',
'delivery-react-native',
Expand Down
18 changes: 18 additions & 0 deletions jest/FixJSDOMEnvironment.js
Original file line number Diff line number Diff line change
@@ -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
176 changes: 143 additions & 33 deletions packages/browser/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,55 @@ 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<any, any>
send: jest.Mock<any, any>
setRequestHeader: jest.Mock<any, any>
}

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

return { session, notify }
}

describe('browser notifier', () => {
const onNotifySend = jest.fn()
const onSessionSend = jest.fn()

beforeAll(() => {
jest.spyOn(console, 'debug').mockImplementation(() => {})
jest.spyOn(console, 'warn').mockImplementation(() => {})
})

beforeEach(() => {
jest.resetModules()
mockFetch()
})

function getBugsnag (): typeof BugsnagBrowserStatic {
Expand All @@ -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) => {
Expand Down Expand Up @@ -175,7 +192,8 @@ describe('browser notifier', () => {
maxEvents: 10,
generateAnonymousId: false,
trackInlineScripts: true,
reportUnhandledPromiseRejectionsAsHandled: true
reportUnhandledPromiseRejectionsAsHandled: true,
sendPayloadChecksums: true
}

Bugsnag.start(completeConfig)
Expand Down Expand Up @@ -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)
}
})
})
})
})
1 change: 1 addition & 0 deletions packages/browser/types/bugsnag.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface BrowserConfig extends Config {
collectUserIp?: boolean
generateAnonymousId?: boolean
trackInlineScripts?: boolean
sendPayloadChecksums?: boolean
}

export interface BrowserBugsnagStatic extends BugsnagStatic {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
5 changes: 5 additions & 0 deletions packages/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
1 change: 1 addition & 0 deletions packages/core/types/common.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void | boolean>
Expand Down
Loading

0 comments on commit 2596383

Please sign in to comment.