From 6fbdf707d2af6c0ca3dffd0f23cb4b17c5ef609b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:33:13 +0000 Subject: [PATCH 1/4] Check base64 output length before encoding; cover btoa and StringDecoder at the 2 GiB string limit --- src/runtime/webcore/encoding.rs | 7 ++ .../string_decoder/string-decoder.test.js | 45 ++++++++++++ test/js/web/util/atob.test.js | 69 ++++++++++++++++++- 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/encoding.rs b/src/runtime/webcore/encoding.rs index d7c2fb127210..a4a08d315025 100644 --- a/src/runtime/webcore/encoding.rs +++ b/src/runtime/webcore/encoding.rs @@ -444,6 +444,13 @@ fn encode_base64_to_bun_string(input: &[u8], url_safe: bool) -> BunString { bun_base64::encode_len(input) }; + // An over-MaxLength output can never become a string; fail here instead of + // allocating and encoding `to_len` bytes first (the create_* constructors + // re-check, and callers surface Dead as ERR_STRING_TOO_LONG). + 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..78945429ce93 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 } 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 = 90_000; + + 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, + ); +}); From aa975a876ed9fd9914f0db021adc5e0a893adaaf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:42:58 +0000 Subject: [PATCH 2/4] Gate the 2 GiB btoa test timeout on debug/ASAN builds --- test/js/web/util/atob.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/web/util/atob.test.js b/test/js/web/util/atob.test.js index 78945429ce93..c9eca00b7cd8 100644 --- a/test/js/web/util/atob.test.js +++ b/test/js/web/util/atob.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; import os from "node:os"; function expectInvalidCharacters(val) { @@ -76,7 +76,7 @@ it("btoa", () => { // 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 = 90_000; + const timeout = isDebug || isASAN ? 90_000 : undefined; it( "throws ERR_STRING_TOO_LONG when the output would exceed 2^31 - 1 characters", From 006877e72c12244623756b0f5b5ed01629390c5a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:44:19 +0000 Subject: [PATCH 3/4] Shorten the base64 length-check comment --- src/runtime/webcore/encoding.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/webcore/encoding.rs b/src/runtime/webcore/encoding.rs index a4a08d315025..f67b2a56bda1 100644 --- a/src/runtime/webcore/encoding.rs +++ b/src/runtime/webcore/encoding.rs @@ -444,9 +444,7 @@ fn encode_base64_to_bun_string(input: &[u8], url_safe: bool) -> BunString { bun_base64::encode_len(input) }; - // An over-MaxLength output can never become a string; fail here instead of - // allocating and encoding `to_len` bytes first (the create_* constructors - // re-check, and callers surface Dead as ERR_STRING_TOO_LONG). + // Checked here so an over-MaxLength output fails before the allocate+encode, not after. if to_len > BunString::max_length() { return BunString::dead(); } From 5b738423f992473263a672bd52074b3d05d22bc4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:53:20 +0000 Subject: [PATCH 4/4] ci: retrigger