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

Conversation

djskinner
Copy link
Contributor

@djskinner djskinner commented Oct 15, 2024

Goal

Add Bugsnag-Integrity request header (where required APIs are available) and implement the sendPayloadChecksums core config option to allow opting out of this behavior.

Bugsnag-Integrity headers are set by default unless the endpoints configuration option is set, in which case they are disabled. This behavior can be overriden with the new sendPayloadChecksums config option.

Design

  • Bugsnag-Integrity headers are set where the environment supports it (requires native promises, subtle crypto, and a secure context)
  • The behaviour of sendPayloadChecksums was chosen to avoid possible CORS issues where custom endpoints are being used and they are not configured to accept the new Bugsnag-Integrity in requests.

Changeset

  • Add sendPayloadChecksums core config option
  • Set Bugsnag-Integrity in delivery-fetch, which is used in @bugsnag/web-worker.
  • Set Bugsnag-Integrity in delivery-xml-http-request, which is used in @bugsnag/browser.

Testing

  • unit tests on delivery-fetch and delivery-xml-http-request
  • manually tested using the web-worker example: event appears when integrity header is correct, event does not appear in the dashboard when the integrity header is wrong
  • integration tests for browser and web worker
  • e2e tests for browser and web worker

Comment on lines +6 to +16
class FixJSDOMEnvironment extends JSDOMEnvironment {
constructor (...args) {
super(...args)

this.global.TextEncoder = TextEncoder
this.global.TextDecoder = TextDecoder
this.global.crypto = {
subtle: crypto.webcrypto.subtle
}
}
}
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

Copy link

github-actions bot commented Oct 15, 2024

@bugsnag/browser bundle size diff

Minified Minfied + Gzipped
Before 45.89 kB 13.78 kB
After 46.96 kB 14.02 kB
± ⚠️ +1,073 bytes ⚠️ +242 bytes

code coverage diff

<temporarily disabled>

Generated by 🚫 dangerJS against d38cc52

@@ -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.

Comment on lines 6 to 10
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.

@@ -36,7 +36,7 @@ export const Bugsnag = {
// configure a client with user supplied options
const bugsnag = new Client(opts, schema, internalPlugins, { name, version, url })

bugsnag._setDelivery(delivery)
bugsnag._setDelivery(client => delivery(client, undefined, self))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same way the global self is passed to plugin, see above, e.g. pluginWindowUnhandledRejection(self)

@djskinner djskinner marked this pull request as ready for review October 15, 2024 15:56
@djskinner djskinner marked this pull request as draft October 18, 2024 12:18
Dan Skinner and others added 4 commits October 18, 2024 14:39
@djskinner djskinner changed the title set Bugsnag-Integrity header in delivery-fetch add sendPayloadChecksums config option and implement Bugsnag-Integrity header Oct 18, 2024
@@ -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.

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(...))

Comment on lines +7 to +10
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
Copy link
Contributor Author

@djskinner djskinner Oct 18, 2024

Choose a reason for hiding this comment

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

'featureFlags',
'reportUnhandledPromiseRejectionsAsHandled',
// sendPayloadChecksums gets set in core because its 'default value' depends on other config values and so cannot remain unset
'sendPayloadChecksums'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this doesn't seem ideal but I'm not sure there is a better way to get the "default value" behaviour of sendPayloadChecksums without actually setting its value when the client is created, i.e. this part:

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

I think it would be better if the defaultValue part of config could produce its value based on the rest of the config but that seemed way out of scope here,

Comment on lines +124 to +127
// sendPayloadChecksums is false by default unless custom endpoints are not specified
if (!opts.endpoints) {
opts.sendPayloadChecksums = 'sendPayloadChecksums' in opts ? opts.sendPayloadChecksums : true
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

the sendPayloadChecksums is added to core so the behaviour is the same for all clients i.e. browser and web worker. It doesn't apply to react native as that uses native delivery so they would need separate support and configuration in the native notifiers (which I believe they already have)

@@ -1,18 +1,44 @@
import payload from '@bugsnag/core/lib/json-payload'

const delivery = (client, fetch = global.fetch) => ({
function getIntegrityHeaderValue (sendPayloadChecksums, windowOrWorkerGlobalScope, requestBody, headers) {
if (sendPayloadChecksums && 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.

@djskinner djskinner marked this pull request as ready for review November 20, 2024 17:35
Copy link
Member

@gingerbenw gingerbenw left a comment

Choose a reason for hiding this comment

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

Do we also need to set this in the delivery-x-domain-request package? I suppose IE8 and 9 won't support an integrity header?

Comment on lines +79 to +112
// mock XMLHttpRequest class
function XMLHttpRequest (this: MockXMLHttpRequest) {
this.method = null
this.url = null
this.data = null
this.headers = {}
this.readyState = XMLHttpRequest.UNSENT
requests.push(this)
}
XMLHttpRequest.UNSENT = 0
XMLHttpRequest.OPENED = 1
XMLHttpRequest.HEADERS_RECEIVED = 2
XMLHttpRequest.LOADING = 3
XMLHttpRequest.DONE = 4
XMLHttpRequest.prototype.open = function (method: string, url: string) {
this.method = method
this.url = url
this.readyState = XMLHttpRequest.OPENED
this.onreadystatechange()
}
XMLHttpRequest.prototype.setRequestHeader = function (key: string, val: string) {
this.headers[key] = val
}
XMLHttpRequest.prototype.send = function (data: string) {
this.data = data
this.readyState = XMLHttpRequest.HEADERS_RECEIVED
this.onreadystatechange()

setTimeout(() => {
this.status = 200
this.readyState = XMLHttpRequest.DONE
this.onreadystatechange()
}, 500)
}
Copy link
Member

Choose a reason for hiding this comment

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

can this code be shared between tests?

Feature: Bugsnag-Integrity header

Scenario: Integrity headers are set when setPayloadChecksums is true
When I navigate to the test URL "/integrity/script/a.html"
Copy link
Member

Choose a reason for hiding this comment

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

could we name the html something closer to what the test actually does? otherwise we have to reference between the feature and the fixture to figure out what a.html does

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 was following the precedent of the majority of other fixtures here. I suppose it can be sometimes difficult to come up with a suitable description in a filename format. how about sendpayloadchecksums-true.html and sendpayloadchecksums-false.html?

Copy link
Member

Choose a reason for hiding this comment

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

yeah, that sounds good

@djskinner
Copy link
Contributor Author

Do we also need to set this in the delivery-x-domain-request package? I suppose IE8 and 9 won't support an integrity header?

No, XDomainRequest is only available on ie8 and ie9 neither which support the necessary APIs for calculating and setting the integrity header. even ie11 is excluded because it doesn't have full native promise support, hence the typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]') !== -1 check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants