diff --git a/src/runtime/webcore/encoding.rs b/src/runtime/webcore/encoding.rs index d7c2fb127210..f67b2a56bda1 100644 --- a/src/runtime/webcore/encoding.rs +++ b/src/runtime/webcore/encoding.rs @@ -444,6 +444,11 @@ fn encode_base64_to_bun_string(input: &[u8], url_safe: bool) -> BunString { bun_base64::encode_len(input) }; + // Checked here so an over-MaxLength output fails before the allocate+encode, not after. + if to_len > BunString::max_length() { + return BunString::dead(); + } + if to_len < EXTERNAL_MIN_LEN { let (str, chars) = BunString::create_uninitialized_latin1(to_len); if str.is_dead() { diff --git a/test/js/node/string_decoder/string-decoder.test.js b/test/js/node/string_decoder/string-decoder.test.js index f60fba3e6c57..b3a5c979328f 100644 --- a/test/js/node/string_decoder/string-decoder.test.js +++ b/test/js/node/string_decoder/string-decoder.test.js @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug, withoutAggressiveGC } from "harness"; +import os from "node:os"; const RealStringDecoder = require("string_decoder").StringDecoder; @@ -421,3 +422,47 @@ it( // Allocating a 2 GiB buffer under debug/ASAN is slow even when lazily committed. isDebug || isASAN ? 60_000 : undefined, ); + +// Output lengths above WTF::StringImpl::MaxLength (2^31 - 1) used to trip a +// RELEASE_ASSERT and abort the process instead of throwing. Runs in a +// subprocess because of the multi-GiB peak; skips on small machines (same +// gate as blob-oom.test.ts). +describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("write() at the 2 GiB string limit", () => { + it( + "throws ERR_STRING_TOO_LONG for base64 and hex instead of aborting", + async () => { + const src = ` + const { StringDecoder } = require("string_decoder"); + const report = e => ({ name: e.name, code: e.code, message: e.message }); + const results = []; + // 1610612736 = 3 * 2^29: base64 output is (len / 3) * 4 = 2147483648 = 2^31, + // hex output is len * 2 = 3221225472; both exceed 2^31 - 1. + const buf = Buffer.alloc(1610612736); + for (const encoding of ["base64", "hex"]) { + try { + results.push({ unexpectedLength: new StringDecoder(encoding).write(buf).length }); + } catch (e) { + results.push(report(e)); + } + } + console.log(JSON.stringify(results)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const tooLong = { + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot create a string longer than 2147483647 characters", + }; + expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual([tooLong, tooLong]); + expect(exitCode).toBe(0); + }, + // Allocating multi-GiB buffers under debug/ASAN is slow. + isDebug || isASAN ? 60_000 : undefined, + ); +}); diff --git a/test/js/web/util/atob.test.js b/test/js/web/util/atob.test.js index cceeb89ed55c..c9eca00b7cd8 100644 --- a/test/js/web/util/atob.test.js +++ b/test/js/web/util/atob.test.js @@ -1,4 +1,6 @@ -import { expect, it } from "bun:test"; +import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import os from "node:os"; function expectInvalidCharacters(val) { expect(() => atob(val)).toThrow("The string contains invalid characters."); @@ -67,3 +69,68 @@ it("btoa", () => { expect(btoa("\u0080\u0081")).toBe("gIE="); expect(btoa(Bun)).toBe(btoa("[object Bun]")); }); + +// btoa output lengths above WTF::StringImpl::MaxLength (2^31 - 1) used to trip +// a RELEASE_ASSERT and abort the process instead of throwing. These need real +// multi-GiB peaks, so each case runs in a subprocess and the block skips on +// small machines (same gate as blob-oom.test.ts). +describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("btoa at the 2 GiB string limit", () => { + // Building and encoding multi-GiB strings is slow under debug/ASAN. + const timeout = isDebug || isASAN ? 90_000 : undefined; + + it( + "throws ERR_STRING_TOO_LONG when the output would exceed 2^31 - 1 characters", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // base64 output = ceil(1610612734 / 3) * 4 = 2147483648 = 2^31 + const input = Buffer.alloc(1610612734, 0x61).toString(); + try { + console.log(JSON.stringify({ unexpectedLength: btoa(input).length })); + } catch (e) { + console.log(JSON.stringify({ name: e.name, code: e.code, message: e.message })); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual({ + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot create a string longer than 2147483647 characters", + }); + expect(exitCode).toBe(0); + }, + timeout, + ); + + it( + "still encodes the largest input whose output fits", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // base64 output = (1610612733 / 3) * 4 = 2147483644 <= 2^31 - 1 + const input = Buffer.alloc(1610612733, 0x61).toString(); + console.log(JSON.stringify({ length: btoa(input).length })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual({ length: 2147483644 }); + expect(exitCode).toBe(0); + }, + timeout, + ); +});