-
Notifications
You must be signed in to change notification settings - Fork 253
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
base: next
Are you sure you want to change the base?
Changes from 17 commits
14dca7e
b2d4819
4044c31
ebad38a
af9731d
c81a572
7177ddf
9de0065
97f8402
a6d6e67
c06add0
ff5cd1b
503eae3
b1e0b1f
c415a67
a801c57
6409517
c8f48cc
277bdde
cfd61b2
79cac2d
2db23b7
59ec656
983fcb4
5b3788c
acda730
283b77d
1eec649
9d7c759
b46beae
190e226
b68c890
d38cc52
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,38 +4,55 @@ const DONE = window.XMLHttpRequest.DONE | |
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
@@ -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) => { | ||
|
@@ -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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Note for when we upgrade jest, a stable value can be obtained with:
|
||
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) | ||
} | ||
}) | ||
}) | ||
}) | ||
}) |
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') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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('') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
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