Skip to content

Commit 271f7ec

Browse files
authored
fix(build): Prevent Node's Buffer module from being included in browser bundles (#3372)
1 parent 0e446d6 commit 271f7ec

File tree

3 files changed

+115
-26
lines changed

3 files changed

+115
-26
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { base64ToUnicode, unicodeToBase64 } from '@sentry/utils';
3+
import { expect } from 'chai';
4+
5+
// See https://tools.ietf.org/html/rfc4648#section-4 for base64 spec
6+
// eslint-disable-next-line no-useless-escape
7+
const BASE64_REGEX = /([a-zA-Z0-9+/]{4})*(|([a-zA-Z0-9+/]{3}=)|([a-zA-Z0-9+/]{2}==))/;
8+
9+
// NOTE: These tests are copied (and adapted for chai syntax) from `string.test.ts` in `@sentry/utils`. The
10+
// base64-conversion functions have a different implementation in browser and node, so they're copied here to prove they
11+
// work in a real live browser. If you make changes here, make sure to also port them over to that copy.
12+
describe('base64ToUnicode/unicodeToBase64', () => {
13+
const unicodeString = 'Dogs are great!';
14+
const base64String = 'RG9ncyBhcmUgZ3JlYXQh';
15+
16+
it('converts to valid base64', () => {
17+
expect(BASE64_REGEX.test(unicodeToBase64(unicodeString))).to.be.true;
18+
});
19+
20+
it('works as expected', () => {
21+
expect(unicodeToBase64(unicodeString)).to.equal(base64String);
22+
expect(base64ToUnicode(base64String)).to.equal(unicodeString);
23+
});
24+
25+
it('conversion functions are inverses', () => {
26+
expect(base64ToUnicode(unicodeToBase64(unicodeString))).to.equal(unicodeString);
27+
expect(unicodeToBase64(base64ToUnicode(base64String))).to.equal(base64String);
28+
});
29+
30+
it('can handle and preserve multi-byte characters in original string', () => {
31+
['🐶', 'Καλό κορίτσι, Μάιζεϊ!', 'Of margir hundar! Ég geri ráð fyrir að ég þurfi stærra rúm.'].forEach(orig => {
32+
expect(() => {
33+
unicodeToBase64(orig);
34+
}).not.to.throw;
35+
expect(base64ToUnicode(unicodeToBase64(orig))).to.equal(orig);
36+
});
37+
});
38+
39+
it('throws an error when given invalid input', () => {
40+
expect(() => {
41+
unicodeToBase64(null as any);
42+
}).to.throw('Unable to convert to base64');
43+
expect(() => {
44+
unicodeToBase64(undefined as any);
45+
}).to.throw('Unable to convert to base64');
46+
expect(() => {
47+
unicodeToBase64({} as any);
48+
}).to.throw('Unable to convert to base64');
49+
50+
expect(() => {
51+
base64ToUnicode(null as any);
52+
}).to.throw('Unable to convert from base64');
53+
expect(() => {
54+
base64ToUnicode(undefined as any);
55+
}).to.throw('Unable to convert from base64');
56+
expect(() => {
57+
base64ToUnicode({} as any);
58+
}).to.throw('Unable to convert from base64');
59+
60+
// Note that by design, in node base64 encoding and decoding will accept any string, whether or not it's valid
61+
// base64, by ignoring all invalid characters, including whitespace. Therefore, no wacky strings have been included
62+
// here because they don't actually error.
63+
});
64+
});

packages/utils/src/string.ts

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ export function isMatchingPattern(value: string, pattern: RegExp | string): bool
104104
return false;
105105
}
106106

107+
type GlobalWithBase64Helpers = {
108+
// browser
109+
atob?: (base64String: string) => string;
110+
btoa?: (utf8String: string) => string;
111+
// Node
112+
Buffer?: { from: (input: string, encoding: string) => { toString: (encoding: string) => string } };
113+
};
114+
107115
/**
108116
* Convert a Unicode string to a base64 string.
109117
*
@@ -112,42 +120,49 @@ export function isMatchingPattern(value: string, pattern: RegExp | string): bool
112120
* @returns A base64-encoded version of the string
113121
*/
114122
export function unicodeToBase64(plaintext: string): string {
115-
const global = getGlobalObject();
116-
117-
// Cast to a string just in case we're given something else
118-
const stringifiedInput = String(plaintext);
119-
const errMsg = `Unable to convert to base64: ${
120-
stringifiedInput.length > 256 ? `${stringifiedInput.slice(0, 256)}...` : stringifiedInput
121-
}`;
123+
const globalObject = getGlobalObject<GlobalWithBase64Helpers>();
122124

123125
// To account for the fact that different platforms use different character encodings natively, our `tracestate`
124126
// spec calls for all jsonified data to be encoded in UTF-8 bytes before being passed to the base64 encoder.
125127
try {
128+
if (typeof plaintext !== 'string') {
129+
throw new Error(`Input must be a string. Received input of type '${typeof plaintext}'.`);
130+
}
131+
126132
// browser
127-
if ('btoa' in global) {
133+
if ('btoa' in globalObject) {
128134
// encode using UTF-8
129135
const bytes = new TextEncoder().encode(plaintext);
130136

131137
// decode using UTF-16 (JS's native encoding) since `btoa` requires string input
132138
const bytesAsString = String.fromCharCode(...bytes);
133139

134-
return btoa(bytesAsString);
140+
// TODO: if TS ever learns about "in", we can get rid of the non-null assertion
141+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
142+
return globalObject.btoa!(bytesAsString);
135143
}
136144

137145
// Node
138-
if ('Buffer' in global) {
146+
if ('Buffer' in globalObject) {
139147
// encode using UTF-8
140-
const bytes = Buffer.from(plaintext, 'utf-8');
148+
// TODO: if TS ever learns about "in", we can get rid of the non-null assertion
149+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
150+
const bytes = globalObject.Buffer!.from(plaintext, 'utf-8');
141151

142152
// unlike the browser, Node can go straight from bytes to base64
143153
return bytes.toString('base64');
144154
}
155+
156+
// we shouldn't ever get here, because one of `btoa` and `Buffer` should exist, but just in case...
157+
throw new SentryError('Neither `window.btoa` nor `global.Buffer` is defined.');
145158
} catch (err) {
159+
// Cast to a string just in case we're given something else
160+
const stringifiedInput = JSON.stringify(plaintext);
161+
const errMsg = `Unable to convert to base64: ${
162+
stringifiedInput?.length > 256 ? `${stringifiedInput.slice(0, 256)}...` : stringifiedInput
163+
}.`;
146164
throw new SentryError(`${errMsg}\nGot error: ${err}`);
147165
}
148-
149-
// we shouldn't ever get here, because one of `btoa` and `Buffer` should exist, but just in case...
150-
throw new SentryError(errMsg);
151166
}
152167

153168
/**
@@ -158,22 +173,22 @@ export function unicodeToBase64(plaintext: string): string {
158173
* @returns A Unicode string
159174
*/
160175
export function base64ToUnicode(base64String: string): string {
161-
const globalObject = getGlobalObject();
162-
163-
// we cast to a string just in case we're given something else
164-
const stringifiedInput = String(base64String);
165-
const errMsg = `Unable to convert from base64: ${
166-
stringifiedInput.length > 256 ? `${stringifiedInput.slice(0, 256)}...` : stringifiedInput
167-
}`;
176+
const globalObject = getGlobalObject<GlobalWithBase64Helpers>();
168177

169178
// To account for the fact that different platforms use different character encodings natively, our `tracestate` spec
170179
// calls for all jsonified data to be encoded in UTF-8 bytes before being passed to the base64 encoder. So to reverse
171180
// the process, decode from base64 to bytes, then feed those bytes to a UTF-8 decoder.
172181
try {
182+
if (typeof base64String !== 'string') {
183+
throw new Error(`Input must be a string. Received input of type '${typeof base64String}'.`);
184+
}
185+
173186
// browser
174187
if ('atob' in globalObject) {
175188
// `atob` returns a string rather than bytes, so we first need to encode using the native encoding (UTF-16)
176-
const bytesAsString = atob(base64String);
189+
// TODO: if TS ever learns about "in", we can get rid of the non-null assertion
190+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
191+
const bytesAsString = globalObject.atob!(base64String);
177192
const bytes = [...bytesAsString].map(char => char.charCodeAt(0));
178193

179194
// decode using UTF-8 (cast the `bytes` arry to a Uint8Array just because that's the format `decode()` expects)
@@ -183,15 +198,22 @@ export function base64ToUnicode(base64String: string): string {
183198
// Node
184199
if ('Buffer' in globalObject) {
185200
// unlike the browser, Node can go straight from base64 to bytes
186-
const bytes = Buffer.from(base64String, 'base64');
201+
// TODO: if TS ever learns about "in", we can get rid of the non-null assertion
202+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
203+
const bytes = globalObject.Buffer!.from(base64String, 'base64');
187204

188205
// decode using UTF-8
189206
return bytes.toString('utf-8');
190207
}
208+
209+
// we shouldn't ever get here, because one of `atob` and `Buffer` should exist, but just in case...
210+
throw new SentryError('Neither `window.atob` nor `global.Buffer` is defined.');
191211
} catch (err) {
212+
// we cast to a string just in case we're given something else
213+
const stringifiedInput = JSON.stringify(base64String);
214+
const errMsg = `Unable to convert from base64: ${
215+
stringifiedInput?.length > 256 ? `${stringifiedInput.slice(0, 256)}...` : stringifiedInput
216+
}.`;
192217
throw new SentryError(`${errMsg}\nGot error: ${err}`);
193218
}
194-
195-
// we shouldn't ever get here, because one of `atob` and `Buffer` should exist, but just in case...
196-
throw new SentryError(errMsg);
197219
}

packages/utils/test/string.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ describe('isMatchingPattern()', () => {
5050
});
5151
});
5252

53+
// NOTE: These tests are copied (and adapted for chai syntax) to `string.test.ts` in `@sentry/browser`. The
54+
// base64-conversion functions have a different implementation in browser and node, so they're copied there to prove
55+
// they work in a real live browser. If you make changes here, make sure to also port them over to that copy.
5356
describe('base64ToUnicode/unicodeToBase64', () => {
5457
const unicodeString = 'Dogs are great!';
5558
const base64String = 'RG9ncyBhcmUgZ3JlYXQh';

0 commit comments

Comments
 (0)