Skip to content

Check base64 output length before encoding and cover btoa, StringDecoder at the 2 GiB string limit - #37235

Open
robobun wants to merge 4 commits into
mainfrom
farm/ea20e439/btoa-stringdecoder-string-too-long
Open

Check base64 output length before encoding and cover btoa, StringDecoder at the 2 GiB string limit#37235
robobun wants to merge 4 commits into
mainfrom
farm/ea20e439/btoa-stringdecoder-string-too-long

Conversation

@robobun

@robobun robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

btoa() and StringDecoder#write with base64/hex used to abort the process (silent SIGABRT, ~4 GB RSS) when the encoded output reached 2^31 characters, while Buffer#toString("base64") on the same bytes threw ERR_STRING_TOO_LONG cleanly. Debug builds failed with:

ASSERTION FAILED: data.size() <= MaxLength
WTF/wtf/text/StringImpl.h(891) StringImplShape(uint32_t, span<const Latin1Character>)

Repro (rc 134 on 1.4.0 canary 5b98630, 3/3):

import { StringDecoder } from "node:string_decoder";
btoa("a".repeat(1610612734));                           // output 2147483648 = 2^31
new StringDecoder("base64").write(Buffer.alloc(1610612736)); // same
Buffer.alloc(1610612736).toString("base64");            // control: ERR_STRING_TOO_LONG (ok)

Cause

functionBTOA and JSStringDecoder call Bun__encoding__toString directly and skipped the output-size pre-checks that JSBuffer.cpp's toString performs (String::MaxLength, hex x2, base64 4/3). The abort itself was fixed by #37215, which clamped bun_core::String::max_length() to WTF::StringImpl::MaxLength: the encoders now produce a Dead string that surfaces as ERR_STRING_TOO_LONG. Verified on current main that btoa and StringDecoder base64/hex/latin1/ascii/utf8 all throw instead of aborting. But none of these callers had test coverage, and the base64 encoder only discovered the overflow after allocating and encoding the full oversized output (2 GiB of doomed work on the btoa path).

Fix

  • encode_base64_to_bun_string checks the computed output length against String::max_length() before allocating, mirroring how the hex path fails fast in create_uninitialized_latin1. Same observable behavior (ERR_STRING_TOO_LONG), minus the 2 GiB allocate-encode-discard. With the check, the failing btoa case drops from ~7s to ~2s under a debug build.
  • Tests for the callers Throw ERR_STRING_TOO_LONG instead of aborting for 2 GiB to 4 GiB strings #37215 did not cover, following the existing blob-oom.test.ts subprocess pattern (gated on os.totalmem() >= 10 GiB):
    • test/js/web/util/atob.test.js: btoa with output 2^31 throws ERR_STRING_TOO_LONG; the largest input whose output fits (2147483644 chars) still encodes.
    • test/js/node/string_decoder/string-decoder.test.js: write() with base64 and hex output over 2^31 - 1 throws ERR_STRING_TOO_LONG. This complements the existing test for oversized utf8 input: here the input buffers are well under the limit and only the encoded output crosses it.

Verification


[review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/string_decoder/string-decoder.test.js test/js/web/util/atob.test.js
bun test v1.4.0 (5b738423f)

test/js/web/util/atob.test.js:
(pass) atob [31.80ms]
(pass) btoa [8.99ms]
(pass) btoa at the 2 GiB string limit > throws ERR_STRING_TOO_LONG when the output would exceed 2^31 - 1 characters [7359.35ms]
(pass) btoa at the 2 GiB string limit > still encodes the largest input whose output fits [7351.22ms]

test/js/node/string_decoder/string-decoder.test.js:
(pass) require('string_decoder') [4.93ms]
(pass) Bun.inspect(StringDecoder) [3.96ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8 [336.37ms]
(pass) FakeStringDecoderCall > StringDecoder-ucs-2 [240.07ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le [4.60ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8-additional [8.24ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le-additional [6.89ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64 testbuf [24.96ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64url testbuf [17.59ms]
(pass) FakeStrin
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (9d519e8ca)

test/js/web/util/atob.test.js:
(pass) atob [0.34ms]
(pass) btoa [0.10ms]
 98 |         env: bunEnv,
 99 |         stdout: "pipe",
100 |         stderr: "pipe",
101 |       });
102 |       const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
103 |       expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual({
                                                                                              ^
error: expect(received).toEqual(expected)

  {
-   "code": "ERR_STRING_TOO_LONG",
-   "message": "Cannot create a string longer than 2147483647 characters",
-   "name": "Error",
+   "exitCode": 134,
+   "stderr": 
+ "============================================================
+ Bun Canary v1.4.0-canary.1 (9d519e8ca) Linux x64
+ Linux Kernel v6.17.0 | glibc v2.41
+ CPU: sse42 popcnt avx avx2 avx512
+ Args: "/workspace/bun/build/release/bun" "-e" "\n          // base64 output = ceil(1610612734 / 3) * 4 = 2147483648 = 2^31\n          const input = Buffer.alloc(1610612734, 0x61).toString();\n          try {\n    "...
+ Features: bunfig jsc tsco
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/string_decoder/string-decoder.test.js test/js/web/util/atob.test.js
bun test v1.4.0 (5b738423f)

test/js/web/util/atob.test.js:
(pass) atob [31.92ms]
(pass) btoa [8.90ms]
(pass) btoa at the 2 GiB string limit > throws ERR_STRING_TOO_LONG when the output would exceed 2^31 - 1 characters [2409.04ms]
(pass) btoa at the 2 GiB string limit > still encodes the largest input whose output fits [7315.48ms]

test/js/node/string_decoder/string-decoder.test.js:
(pass) require('string_decoder') [4.77ms]
(pass) Bun.inspect(StringDecoder) [3.88ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8 [331.18ms]
(pass) FakeStringDecoderCall > StringDecoder-ucs-2 [240.35ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le [4.76ms]
(pass) FakeStringDecoderCall > StringDecoder-utf8-additional [8.57ms]
(pass) FakeStringDecoderCall > StringDecoder-utf16le-additional [6.70ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64 testbuf [24.94ms]
(pass) FakeStringDecoderCall > StringDecoder.end > base64url testbuf [16.67ms]
(pass) FakeStrin
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 706ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
src/runtime/webcore/encoding.rs                    |  5 ++
 test/js/node/string_decoder/string-decoder.test.js | 45 ++++++++++++++
 test/js/web/util/atob.test.js                      | 69 +++++++++++++++++++++-
 3 files changed, 118 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/runtime/webcore/encoding.rs                         2      3      0
test/js/node/string_decoder/string-decoder.test.js      1      2      0
test/js/web/util/atob.test.js                           1      3      0

@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced the abort on 1.4.0 canary 5b98630 (rc 134, 3/3 for both btoa and StringDecoder base64). Confirmed current main already throws ERR_STRING_TOO_LONG for every encoding through this path after #37215; this PR adds the missing pre-encode length check and regression coverage for these callers. Tests verified failing on a pre-#37215 build and passing here.

CI (build 90827, final): 194 of 196 jobs passed, including the new tests on every lane that runs them. The two red jobs are unrelated to this diff:

  • ":darwin: 26 aarch64" died before running any tests (tart runner ssh failure, a fleet-wide infra issue hitting many PRs today, tracked separately).
  • ":darwin: 14 x64" has three unrelated reds on one box: a require-cache import() timeout, an http2 maxSessionMemory timeout (same test passed on aarch64 after retry), and napi.test.ts which is already failing on main. All three are reported for main-break triage.

This diff does not touch module caching, http2, or napi. Review threads are all resolved (two findings fixed, two withdrawn). Ready for a maintainer; I am not pushing further retriggers.

@github-actions github-actions Bot added the claude label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Encoding length safety

Layer / File(s) Summary
Base64 output length guard
src/runtime/webcore/encoding.rs
Base64 encoding checks BunString::max_length() before allocation. Oversized results return BunString::dead().
Oversized encoding boundary tests
test/js/node/string_decoder/string-decoder.test.js, test/js/web/util/atob.test.js
Memory-gated subprocess tests verify ERR_STRING_TOO_LONG for oversized Base64, hex, and btoa outputs. Tests also verify the largest fitting output succeeds.

Possibly related PRs

  • oven-sh/bun#37216: Addresses oversized string and Base64 conversion handling in a different conversion path.
  • oven-sh/bun#37232: Adds subprocess regression tests for oversized string conversions in different code paths.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the pre-encoding length check and regression coverage for btoa and StringDecoder at the 2 GiB limit.
Description check ✅ Passed The description explains the problem, cause, fix, test coverage, and verification results in sufficient detail.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/string_decoder/string-decoder.test.js`:
- Around line 426-429: Add the tracked regression issue URL to the comments at
test/js/node/string_decoder/string-decoder.test.js lines 426-429 and
test/js/web/util/atob.test.js lines 73-76, using the same URL in both locations
while preserving their existing explanations.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8df334e7-6ae5-470a-a853-ecf2fcb5a408

📥 Commits

Reviewing files that changed from the base of the PR and between 9008ae7 and 6fbdf70.

📒 Files selected for processing (3)
  • src/runtime/webcore/encoding.rs
  • test/js/node/string_decoder/string-decoder.test.js
  • test/js/web/util/atob.test.js

Comment thread test/js/node/string_decoder/string-decoder.test.js

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the 4-line pre-check in encode_base64_to_bun_string short-circuits before the 2 GiB allocate-encode-discard while preserving the exact observable behavior (Dead → ERR_STRING_TOO_LONG), and the new tests lock in the #37215 fix for the previously-uncovered btoa/StringDecoder callers. One inline nit about gating the atob.test.js timeout on isDebug || isASAN for consistency with the sibling test, but it's not blocking.

Extended reasoning...

Overview

This PR adds a 4-line early-return in encode_base64_to_bun_string (src/runtime/webcore/encoding.rs) that checks the computed base64 output length against BunString::max_length() before allocating and encoding, plus three subprocess-based regression tests covering btoa and StringDecoder#write at the 2 GiB WTF::StringImpl::MaxLength boundary.

Correctness of the production change

I verified against src/bun_core/string/mod.rs that create_external_globally_allocated_latin1 (the constructor the large-output branch calls after encoding) already performs bytes.len() > Self::max_length() and returns String::DEAD. So the new check is a strict prefix of existing behavior — same Dead result, just before the doomed try_reserve_exact(to_len) + full encode. to_len and max_length() are both usize, no signedness or overflow concern. The comment accurately notes the constructors re-check, so this is not the sole guard.

Tests

The tests follow established patterns in the repo: Buffer.alloc(n, fill).toString() instead of .repeat(), concurrent Promise.all pipe draining, stdout asserted before exitCode, os.totalmem() < 10 GiB skip gate matching blob-oom.test.ts, and the JSON.parse(stdout || diagnostic) fallback surfaces stderr/exitCode when the subprocess aborts (so a regression produces a useful failure, not "unexpected end of JSON"). I spot-checked the arithmetic: 1610612734 → ceil(/3)*4 = 2^31 (fails), 1610612733 → 2147483644 (succeeds), 1610612736 → base64 2^31 / hex 3.2e9 (both fail). The string-decoder test reuses the exact isDebug || isASAN ? 60_000 : undefined timeout convention already used twice in that file.

Security risks

None. This adds an upper-bound length check on the encode path; it only makes an already-failing operation fail earlier.

Level of scrutiny

Low-to-medium. The runtime change is 4 lines with no new control flow beyond an early return that mirrors a downstream check. The rest is test coverage for a crash class already fixed in #37215.

Other factors

The one inline nit (unconditional 90s timeout in atob.test.js vs. the gated form used in the sibling change) is a consistency issue only — an ungated higher ceiling is harmless in practice, and the tests are already memory-gated. Not worth blocking on.

Comment thread test/js/web/util/atob.test.js Outdated
Comment thread src/runtime/webcore/encoding.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/util/atob.test.js`:
- Line 79: Remove the ad hoc timeout override assigned to timeout in the
subprocess test. Run the test under the repository’s default timeout on debug
and ASAN builds; if it fails, reduce the test workload or use a documented,
measured exception rather than restoring a per-test 90-second timeout.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b742cc82-e006-4e88-a25b-696e12e26b79

📥 Commits

Reviewing files that changed from the base of the PR and between 6fbdf70 and aa975a8.

📒 Files selected for processing (1)
  • test/js/web/util/atob.test.js

Comment thread test/js/web/util/atob.test.js

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the timeout gating nit from the earlier pass was addressed in aa975a8, and the comment-cop note in 006877e.

What was reviewed:

  • The 5-line encoding.rs change: to_len (usize) vs BunString::max_length() (usize, clamped to WTF_STRING_MAX_LENGTH) — same observable dead() result as before #37215, just before the allocate+encode instead of after.
  • Boundary arithmetic in all three test cases (1610612734 → 2^31, 1610612733 → 2147483644, 1610612736 → 2^31 base64 / 3221225472 hex) — all correct.
  • Test structure matches the existing 2 GiB blocks in this file and blob-oom.test.ts/fs-oom.test.ts: same totalmem gate, subprocess isolation, concurrent pipe drain, exact error code/message assertions, Buffer.alloc(n, fill) over .repeat().
Extended reasoning...

Overview

Three files: a 5-line pre-allocation length check in src/runtime/webcore/encoding.rs::encode_base64_to_bun_string, plus memory-gated subprocess regression tests in test/js/web/util/atob.test.js and test/js/node/string_decoder/string-decoder.test.js covering the 2^31-char output boundary for btoa and StringDecoder base64/hex.

Security risks

None. The Rust change adds a bounds check that makes an already-correct failure path (dead string → ERR_STRING_TOO_LONG, established by #37215) fail earlier — before allocating and encoding ~2 GiB of doomed output. No new surface, no relaxed validation.

Level of scrutiny

Low-to-moderate. The native change is a single usize > usize comparison against BunString::max_length() (verified: returns usize, clamped to WTF_STRING_MAX_LENGTH), with identical observable behavior to the fallthrough path. The tests are the bulk of the diff and follow the exact pattern already present twice in string-decoder.test.js and in blob-oom.test.ts/fs-oom.test.ts (same os.totalmem() < 10 * 1024 ** 3 skip gate appears in 5 files).

Other factors

All prior review threads are resolved: my earlier nit about unconditional vs gated timeout was fixed in aa975a8 (now isDebug || isASAN ? 90_000 : undefined, matching the sibling file); the comment-cop paragraph-length note was addressed in 006877e (comment trimmed to one line); both CodeRabbit findings were withdrawn after author responses. Tests drain stdout/stderr/exited concurrently, assert exact {name, code, message}, cover both sides of the boundary (over → throws, at-limit → succeeds), and use Buffer.alloc(n, 0x61).toString() per repo guidance. The PR description states both new too-long tests were verified failing on a pre-#37215 build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants