Skip to content

Commit

Permalink
Merge pull request #2228 from bugsnag/browser-integrity
Browse files Browse the repository at this point in the history
set Bugsnag-Integrity header in delivery-xml-http-request
  • Loading branch information
djskinner authored Oct 18, 2024
2 parents af9731d + b1e0b1f commit c415a67
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 36 deletions.
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

0 comments on commit c415a67

Please sign in to comment.