-
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?
Conversation
class FixJSDOMEnvironment extends JSDOMEnvironment { | ||
constructor (...args) { | ||
super(...args) | ||
|
||
this.global.TextEncoder = TextEncoder | ||
this.global.TextDecoder = TextDecoder | ||
this.global.crypto = { | ||
subtle: crypto.webcrypto.subtle | ||
} | ||
} | ||
} |
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:
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
packages/delivery-fetch/delivery.js
Outdated
@@ -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 comment
The reason will be displayed to describe this comment to others. Learn more.
packages/delivery-fetch/delivery.js
Outdated
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 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)) |
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.
Same way the global self
is passed to plugin, see above, e.g. pluginWindowUnhandledRejection(self)
This reverts commit c06add0.
set Bugsnag-Integrity header in delivery-xml-http-request
@@ -4,38 +4,55 @@ const DONE = window.XMLHttpRequest.DONE | |||
|
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.
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)) |
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.
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(...))
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 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' |
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.
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,
// sendPayloadChecksums is false by default unless custom endpoints are not specified | ||
if (!opts.endpoints) { | ||
opts.sendPayloadChecksums = 'sendPayloadChecksums' in opts ? opts.sendPayloadChecksums : true | ||
} |
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.
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') { |
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.
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.
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?
// 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) | ||
} |
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.
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" |
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.
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
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.
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
?
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.
yeah, that sounds good
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 |
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 newsendPayloadChecksums
config option.Design
sendPayloadChecksums
was chosen to avoid possible CORS issues where custom endpoints are being used and they are not configured to accept the newBugsnag-Integrity
in requests.Changeset
sendPayloadChecksums
core config optionBugsnag-Integrity
in delivery-fetch, which is used in@bugsnag/web-worker
.Bugsnag-Integrity
in delivery-xml-http-request, which is used in@bugsnag/browser
.Testing