Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add sendPayloadChecksums config option and implement Bugsnag-Integrity header #2221

Open
wants to merge 33 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
14dca7e
set Bugsnag-Integrity header in delivery-fetch
Oct 15, 2024
b2d4819
add jest dir to docker copy
Oct 15, 2024
4044c31
try fix testEnvironment path resolution
Oct 15, 2024
ebad38a
add jest dir to docker copy
Oct 15, 2024
af9731d
set jest env
Oct 15, 2024
c81a572
set Bugsnag-Integrity header in delivery-xml-http-request
Oct 16, 2024
7177ddf
do not use async syntax
Oct 16, 2024
9de0065
handle no promises in ie11
Oct 17, 2024
97f8402
handle no promises in ie11
Oct 17, 2024
a6d6e67
do not use promise finally
Oct 17, 2024
c06add0
bump jest
Oct 17, 2024
ff5cd1b
Revert "bump jest"
Oct 18, 2024
503eae3
add sendPayloadChecksums to browser
Oct 18, 2024
b1e0b1f
fix types
Oct 18, 2024
c415a67
Merge pull request #2228 from bugsnag/browser-integrity
djskinner Oct 18, 2024
a801c57
tidy test suite
Oct 18, 2024
6409517
Merge branch 'next' into web-worker-integrity
Oct 18, 2024
c8f48cc
add integrity header to delivery-fetch sendSession
Oct 18, 2024
277bdde
Merge branch 'next' into web-worker-integrity
Nov 13, 2024
cfd61b2
Merge branch 'https' into web-worker-integrity
Nov 15, 2024
79cac2d
add e2e tests for integrity headers
Nov 15, 2024
2db23b7
Merge branch 'next' into web-worker-integrity
Nov 20, 2024
59ec656
respect sendPayloadChecksums in delivery-fetch
Nov 20, 2024
983fcb4
move sendPayloadChecksums to core
Nov 20, 2024
5b3788c
move sendPayloadChecksums to core
Nov 20, 2024
acda730
add web worker integration tests for sendPayloadChecksums
Nov 20, 2024
283b77d
add e2e tests for integrity header on web workers
Nov 20, 2024
1eec649
update changelog
Nov 20, 2024
9d7c759
skip integrity tests on unsupported browsers
Nov 20, 2024
b46beae
skip integrity tests on unsupported browsers
Nov 20, 2024
190e226
skip integrity tests on unsupported browsers
Nov 20, 2024
b68c890
rename fixture documents
Nov 26, 2024
d38cc52
use ternary
Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
}
Comment on lines +6 to +16
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More info: https://mswjs.io/docs/migrations/1.x-to-2.x/#requestresponsetextencoder-is-not-defined-jest

We could also use https://github.com/mswjs/jest-fixed-jsdom but would still need to manually add the crypto APIs

I think this is fairly simple and clear

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference, when we come to upgrade jest, it will need rewriting like this:

const { TextDecoder, TextEncoder } = require('node:util')
const crypto = require('crypto')

const JSDOMEnvironment = require('jest-environment-jsdom').default

function FixJSDOMEnvironment (...args) {
  var _this = Reflect.construct(JSDOMEnvironment, args)

  _this.global.TextEncoder = TextEncoder
  _this.global.TextDecoder = TextDecoder

  Object.defineProperty(_this.global, 'crypto', {
    value: {
      subtle: crypto.webcrypto.subtle
    }
  })

  return _this
}

FixJSDOMEnvironment.prototype = Object.create(JSDOMEnvironment.prototype)
FixJSDOMEnvironment.prototype.constructor = FixJSDOMEnvironment

module.exports = FixJSDOMEnvironment

This is because JSDOMEnvironment is an ES6 class but we transpile classes in our code down to es5. This is how I managed to get it to work having an es5 class extend from an es6 class


module.exports = FixJSDOMEnvironment
4 changes: 4 additions & 0 deletions packages/browser/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ module.exports = {
(typeof console !== 'undefined' && typeof console.debug === 'function')
? getPrefixedConsole()
: undefined
}),
sendPayloadChecksums: assign({}, schema.sendPayloadChecksums, {
defaultValue: () => false,
validate: value => value === true || value === false
})
}

Expand Down
5 changes: 5 additions & 0 deletions packages/browser/src/notifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const Bugsnag = {
if (typeof opts === 'string') opts = { apiKey: opts }
if (!opts) opts = {}

// sendPayloadChecksums is false by default unless custom endpoints are not specified
if (!opts.endpoints) {
opts.sendPayloadChecksums = 'sendPayloadChecksums' in opts ? opts.sendPayloadChecksums : true
}

const internalPlugins = [
// add browser-specific plugins
pluginApp,
Expand Down
173 changes: 141 additions & 32 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reworked the mocking in this test suite to get a callback on the xhr send where I could call done. The existing mocking wasn't able to handle the awaiting of the getIntegrity promise.

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>
}

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()
const notify = makeMockXHR()
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 @@ -247,4 +264,96 @@ describe('browser notifier', () => {
startSession.mockRestore()
})
})

describe('payload checksum behaviour (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))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spent a lot of time here trying to mock out cuid to get a stable value but for reasons I don't understand jest.mock('@bugsnag/cuid') was just not doing anything. It does work if I update jest to v29 but that causes a load of other problems elsewhere and it was too much work to upgrade for this, hence the expect.any(String).

Note for when we upgrade jest, a stable value can be obtained with:

jest.mock('@bugsnag/cuid', () => () => 'abc123')
jest.useFakeTimers()
jest.setSystemTime(new Date(...))

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
40 changes: 29 additions & 11 deletions packages/delivery-fetch/delivery.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import payload from '@bugsnag/core/lib/json-payload'

const delivery = (client, fetch = global.fetch) => ({
async function addIntegrityHeader (windowOrWorkerGlobalScope, requestBody, headers) {
if (windowOrWorkerGlobalScope.isSecureContext && windowOrWorkerGlobalScope.crypto && windowOrWorkerGlobalScope.crypto.subtle && windowOrWorkerGlobalScope.crypto.subtle.digest && typeof TextEncoder === 'function') {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const msgUint8 = new TextEncoder().encode(requestBody)
const hashBuffer = await windowOrWorkerGlobalScope.crypto.subtle.digest('SHA-1', msgUint8)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


headers['Bugsnag-Integrity'] = 'sha1 ' + hashHex
}
}

const delivery = (client, fetch = global.fetch, windowOrWorkerGlobalScope = window) => ({
sendEvent: (event, cb = () => {}) => {
const url = client._config.endpoints.notify

fetch(url, {
method: 'POST',
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)
}).then(() => {
const body = payload.event(event, client._config.redactedKeys)
const headers = {
'Content-Type': 'application/json',
'Bugsnag-Api-Key': event.apiKey || client._config.apiKey,
'Bugsnag-Payload-Version': '4',
'Bugsnag-Sent-At': (new Date()).toISOString()
}

addIntegrityHeader(windowOrWorkerGlobalScope, body, headers).then(() =>
fetch(url, {
method: 'POST',
headers,
body
})
).then(() => {
cb(null)
}).catch(err => {
client._logger.error(err)
Expand Down
Loading
Loading