From e8e93df0e5404961acbe6c285e4e032dc4097fd8 Mon Sep 17 00:00:00 2001 From: "sasjs-dev[bot]" Date: Thu, 15 Oct 2026 20:11:09 +0000 Subject: [PATCH] fix(utils): encode and decode base64 without Buffer Closes #276. `encodeToBase64` and `decodeFromBase64` used `Buffer`, which does not exist in a browser. `utils/index.ts` re-exports both from the package root, so every consumer that imports `@sasjs/utils` carried the reference into its browser bundle. While those bundles shipped a Node polyfill tree the reference was silently shimmed; now that consumers are dropping that tree - the adapter's browser bundle no longer carries one - it is a ReferenceError waiting for the first caller. Both functions now use the platform globals: - `TextEncoder` / `TextDecoder` carry the UTF-8 step. `btoa` is latin1, so encoding a password through it directly corrupts anything non-ASCII, and these values are stored. - The binary string is built in chunks rather than spreading a whole byte array into `String.fromCharCode`, which overflows the call stack on long input. - Whitespace is stripped before `atob`, because Buffer ignores it when decoding and `atob` does not - a wrapped or padded stored value still decodes. The stored format is unchanged: the `{sasjs_encoded}` prefix, 200 hex characters of obfuscation padding, and the fixed 200-character offset that `decodeFromBase64` strips. Credentials written by earlier releases keep decoding. Tests, because the failure mode is invisible in Node: - a non-ASCII password round-trips, which is what a naive swap to `btoa` would break - a round trip with the Buffer global deleted, which is the browser case - the module code contains no `Buffer` reference, comments aside Proven by reintroducing the Buffer implementation: two of them fail with `ReferenceError: Buffer is not defined`, and pass with the platform APIs. Full suite: 40 suites, 448 tests passing. tsc and prettier clean. --- src/utils/base64.ts | 37 ++++++++++++++++++++++++++++++++--- src/utils/spec/base64.spec.ts | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/utils/base64.ts b/src/utils/base64.ts index fd69e9f..dd10557 100644 --- a/src/utils/base64.ts +++ b/src/utils/base64.ts @@ -11,16 +11,47 @@ const randomHexPrefix = (): string => .map((byte) => byte.toString(16).padStart(2, '0')) .join('') +// Buffer does not exist in a browser, and this module is reachable from the +// package root, so the encoding uses the platform globals instead. TextEncoder +// and TextDecoder carry the UTF-8 step: btoa is latin1, so encoding a password +// through it directly corrupts anything non-ASCII. The binary string is built +// in chunks because spreading a whole byte array into String.fromCharCode +// overflows the call stack on long input. +const CHUNK_SIZE = 0x8000 + +const toBase64 = (bytes: Uint8Array): string => { + let binary = '' + + for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) { + binary += String.fromCharCode( + ...bytes.subarray(offset, offset + CHUNK_SIZE) + ) + } + + return btoa(binary) +} + +const fromBase64 = (str: string): string => { + // Buffer ignores whitespace when decoding base64 and atob does not, so a + // wrapped or padded stored value still decodes. + const binary = atob(str.replace(/\s/g, '')) + + return new TextDecoder().decode( + Uint8Array.from(binary, (char) => char.charCodeAt(0)) + ) +} + export const encodeToBase64 = (str: string) => { - const encodedPassword = Buffer.from(randomHexPrefix() + str).toString( - 'base64' + const encodedPassword = toBase64( + new TextEncoder().encode(randomHexPrefix() + str) ) return PREFIX + encodedPassword } + export const decodeFromBase64 = (str: string) => { if (str.startsWith(PREFIX)) { str = str.replace(/^{sasjs_encoded}/, '') - const decodedPassword = Buffer.from(str, 'base64').toString() + const decodedPassword = fromBase64(str) return decodedPassword.substring(200) } return str diff --git a/src/utils/spec/base64.spec.ts b/src/utils/spec/base64.spec.ts index 4c65cbb..de6e1bd 100644 --- a/src/utils/spec/base64.spec.ts +++ b/src/utils/spec/base64.spec.ts @@ -1,7 +1,15 @@ +import { readFileSync } from 'fs' +import { join } from 'path' import { encodeToBase64, decodeFromBase64 } from '../base64' describe('base64', () => { const originalPassword = 'hello-world' + const realBuffer = global.Buffer + + afterEach(() => { + global.Buffer = realBuffer + }) + it('should return a base64 encoded string with prefix', () => { expect(encodeToBase64(originalPassword)).toMatch(/^{sasjs_encoded}/) }) @@ -34,4 +42,32 @@ describe('base64', () => { 'SabirByd3VQOWJSRzZkRUpqSnBuMnprcU5ONzlHcUNYMXpVQUtWYzFKRWF0MVlSWFBzRzdHRjJUM1I1VW8yRWRpUW05dVh2MHp5MGJ5WWVqeEQwbVJtQjZzWGZTY3dQSlJBMkJWQ3NhWndReFdGTlNIZVVNVmtha1NuQVpoUHgyWTczMEJiNFRINVNhQ0dXczI0ODNHM1VQdFZMWDJDb1VaTDRkYm1BMVVmU21VYVgzc3VHR2VaejE4UUZCNjZrZTIxUG9OSTBDd2FoZWxsby13b3JsZA==' expect(decodeFromBase64(rawPassword)).toEqual(rawPassword) }) + + it('should round-trip a non-ASCII password', () => { + // btoa is latin1, so a naive swap to the platform APIs corrupts this. The + // password is stored, so a round trip has to survive the UTF-8 step. + const password = 'passwoerd-日本語-ünïcode' + + expect(decodeFromBase64(encodeToBase64(password))).toEqual(password) + }) + + it('should round-trip without a Buffer global', () => { + // The browser case: Buffer does not exist, and this module is reachable + // from the package root, so no polyfill can be assumed. + // @ts-expect-error - simulating a browser, which has no Buffer global + delete global.Buffer + + const password = 'passwoerd-日本語' + + expect(decodeFromBase64(encodeToBase64(password))).toEqual(password) + }) + + it('should reference no Buffer in the module code', () => { + const source = readFileSync(join(__dirname, '..', 'base64.ts'), 'utf8') + const code = source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, '') + + expect(code).not.toMatch(/\bBuffer\b/) + }) })