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

set Bugsnag-Integrity header in delivery-xml-http-request #2228

Merged
merged 9 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
176 changes: 144 additions & 32 deletions packages/browser/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,58 @@ 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>
}

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()
// jest.mock('@bugsnag/cuid', () => () => 'abc123')

mockFetch(onNotifySend, onSessionSend)
})

function getBugsnag (): typeof BugsnagBrowserStatic {
Expand All @@ -56,48 +76,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 +267,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))
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
46 changes: 44 additions & 2 deletions packages/delivery-xml-http-request/delivery.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
const payload = require('@bugsnag/core/lib/json-payload')

function getIntegrity (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 {
Expand Down Expand Up @@ -35,7 +50,20 @@ module.exports = (client, win = window) => ({
if (url.substring(0, 5) === 'https') {
req.setRequestHeader('Access-Control-Max-Age', 86400)
}
req.send(body)

if (client._config.sendPayloadChecksums && typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]') !== -1) {
getIntegrity(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)
}
Expand All @@ -48,6 +76,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) {
Expand All @@ -67,7 +96,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) {
getIntegrity(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)
}
Expand Down
Loading
Loading