Skip to content

crypto,util: coerce later arguments before capturing input buffers in Bun.* hash/UUID/indexOfLine/verifySync - #34964

Open
robobun wants to merge 5 commits into
mainfrom
farm/83bd2751/fix-buffer-detach-coercion-order
Open

robobun wants to merge 5 commits into
mainfrom
farm/83bd2751/fix-buffer-detach-coercion-order

Conversation

@robobun

@robobun robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Bun.CryptoHasher.hash, Bun.SHA*.hash / Bun.MD5.hash, Bun.sha, Bun.password.verifySync, Bun.randomUUIDv5 and Bun.indexOfLine all snapshot an ArrayBuffer-backed input as a raw (ptr, len) and only afterwards coerce a later argument that can run JS: StringOrBuffer::from_js(allow_string_object=true) calls a boxed String's toString, and coerce_to_int64 calls valueOf. That JS can input.buffer.transfer(0) and spray a same-size allocation, after which the captured slice points at a recycled foreign heap block. ASan does not flag this because the backing store lives in bmalloc.

const N = 1 << 20, keep = [];
const mk = (b = 0x41) => Buffer.from(new ArrayBuffer(N)).fill(b);
const evil = (v, s) =>
  Object.assign(new String(s), {
    toString() {
      v.buffer.transfer(0);
      for (let i = 0; i < 64; i++) keep.push(new Uint8Array(N).fill(0x5a));
      return s;
    },
  });
const H = x => require("crypto").createHash("sha256").update(x).digest("hex");
const b = mk();
const got = Bun.CryptoHasher.hash("sha256", b, evil(b, "hex"));
console.log(got === H(mk()), got === H(mk(0x5a)));
// before: false true   (digest of the recycled foreign block)
// after:  false false  (digest of the detached, length-0 input)

Fix

Coerce the later argument before capturing the input buffer's slice:

  • Bun.CryptoHasher.hash / Bun.SHA*.hash / Bun.sha: decode the output argument first, then the input via BlobOrStringOrBuffer::from_js_no_string_object so the input decode never calls user toString and the already-captured output buffer stays valid.
  • Bun.password.verifySync: decode the hash argument first, then the password with allow_string_object = false.
  • Bun.randomUUIDv5: decode the namespace before the name; the namespace is copied into a local [u8; 16].
  • Bun.indexOfLine: coerce offset before calling as_array_buffer on the first argument.

BlobOrStringOrBuffer::from_js_maybe_file_maybe_async now takes allow_string_object and from_js_no_string_object is added as a convenience wrapper. Boxed String inputs to the hash/verifySync entry points are now rejected with "expected string or buffer" (matching Node's ERR_INVALID_ARG_TYPE for crypto.Hash#update); primitive string inputs are unchanged.

CryptoHasher#update(input, encoding) and Bun.password.hashSync(password, algorithm) were audited and already decode the later argument before capturing the input buffer.

Tests

New tests in test/js/bun/util/bun-cryptohasher.test.ts, test/js/bun/util/password.test.ts, test/js/bun/util/randomUUIDv5.test.ts and test/js/bun/util/index-of-line.test.ts detach the input during the later argument's coercion and assert the result matches the detached (empty) input rather than the recycled spray. Each test fails on current main and passes with this change.


[review] gate passed · iteration 1 · 9 files touched

fails on main (without fix)
ASAN without fix: 7 failed, 8 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/bun-cryptohasher.test.ts test/js/bun/util/index-of-line.test.ts test/js/bun/util/password.test.ts "test/js/bun/util/randomUUIDv5.test.ts"
bun test v1.4.0 (3557f2a21)

test/js/bun/util/bun-cryptohasher.test.ts:
18 | 
19 |   test("Bun.CryptoHasher.hash", () => {
20 |     const b = mk();
21 |     const got = Bun.CryptoHasher.hash("sha256", b, evilEncoding(b, "hex"));
22 |     expect(b.byteLength).toBe(0);
23 |     expect(got).toBe(Bun.CryptoHasher.hash("sha256", new Uint8Array(0), "hex"));
                     ^
error: expect(received).toBe(expected)

Expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
Received: "944044fe482bc4e91085c15c5a923a1b9e02eac98d3bce04997d6dbecd2a5b8d"

      at <anonymous> (/workspace/bun/test/js/bun/util/bun-cryptohasher.test.ts:23:17)
(fail) input buffer detached by output argument's toString > Bun.CryptoHasher.hash [18.20ms]
25 | 
26 |   test("Bun.SHA256.hash", () => {
27 |     const b = mk();
28 |     const got = Bun.SHA256.hash(b, evilEncoding(b, "hex"));
29 |     e
... (truncated)

release without fix: 7 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/util/bun-cryptohasher.test.ts:
18 | 
19 |   test("Bun.CryptoHasher.hash", () => {
20 |     const b = mk();
21 |     const got = Bun.CryptoHasher.hash("sha256", b, evilEncoding(b, "hex"));
22 |     expect(b.byteLength).toBe(0);
23 |     expect(got).toBe(Bun.CryptoHasher.hash("sha256", new Uint8Array(0), "hex"));
                     ^
error: expect(received).toBe(expected)

Expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
Received: "944044fe482bc4e91085c15c5a923a1b9e02eac98d3bce04997d6dbecd2a5b8d"

      at <anonymous> (/workspace/bun/test/js/bun/util/bun-cryptohasher.test.ts:23:17)
(fail) input buffer detached by output argument's toString > Bun.CryptoHasher.hash [0.88ms]
25 | 
26 |   test("Bun.SHA256.hash", () => {
27 |     const b = mk();
28 |     const got = Bun.SHA256.hash(b, evilEncoding(b, "hex"));
29 |     expect(b.byteLength).toBe(0);
30 |     expect(got).toBe(Bun.SHA256.hash(new Uint8Array(0), "hex"));
                     ^
error: expect(received).toBe(expected)

Expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
Received: "944044fe482bc4e91085c15c5a923a
... (truncated)
passes on PR (with fix)
ASAN with fix: 8 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/bun-cryptohasher.test.ts test/js/bun/util/index-of-line.test.ts test/js/bun/util/password.test.ts "test/js/bun/util/randomUUIDv5.test.ts"
bun test v1.4.0 (3557f2a21)

test/js/bun/util/bun-cryptohasher.test.ts:
(pass) input buffer detached by output argument's toString > Bun.CryptoHasher.hash [11.00ms]
(pass) input buffer detached by output argument's toString > Bun.SHA256.hash [3.88ms]
(pass) input buffer detached by output argument's toString > Bun.sha [3.85ms]
(pass) input buffer detached by output argument's toString > Bun.CryptoHasher.hash rejects boxed String as input [8.13ms]
(pass) Bun.file in CryptoHasher is not supported yet [10.95ms]
(pass) CryptoHasher update should throw when no parameter/null/undefined is passed [6.61ms]
(pass) CryptoHasher throws on non-latin1 algorithm names instead of crashing [9.78ms]
(pass) HMAC > sha1 (key: String) [15.60ms]
(pass) HMAC > sha256 (key: String) [6.79ms]
(pass) HMAC > sha384 (key: String) [4.06ms]
(pass) HMAC > sha512 (key: String) [4.14ms]
(pass) HMAC > blake2b512 (key
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 931ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/139] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/139] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/139] gen cpp.rs (cppbind)
[4/139] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[5/139] gen JS modules (bundle-modules)
Preprocess modules (11790ms)
Bundle modules (40ms)
Postprocesss modules (153ms)
Bundle Functions (964ms)
Generate Code (155ms)

[13.12s] Bundled "src/js" for production
  2041 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[5/139] 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    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_output_tags v0.0.0 (/workspace/bu
... (truncated)
diff hotspot
src/runtime/api/BunObject.rs              |  24 +++---
 src/runtime/crypto/CryptoHasher.rs        | 119 ++++++++++++++----------------
 src/runtime/crypto/PasswordObject.rs      |  16 ++--
 src/runtime/node/types.rs                 |  22 +++++-
 src/runtime/webcore/Crypto.rs             |  45 +++++------
 test/js/bun/util/bun-cryptohasher.test.ts |  53 +++++++++++++
 test/js/bun/util/index-of-line.test.ts    |  19 +++++
 test/js/bun/util/password.test.ts         |  19 +++++
 test/js/bun/util/randomUUIDv5.test.ts     |  18 +++++
 9 files changed, 232 insertions(+), 103 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                       reads  edits  tests
src/runtime/api/BunObject.rs                   2      3      0
src/runtime/crypto/CryptoHasher.rs             9      4      0
src/runtime/crypto/PasswordObject.rs           3      2      0
src/runtime/node/types.rs                      6      6      0
src/runtime/webcore/Crypto.rs                  3      2      0
test/js/bun/util/bun-cryptohasher.test.ts      2      5      0
test/js/bun/util/index-of-line.test.ts         1      2      0
test/js/bun/util/password.test.ts              1      1      0
test/js/bun/util/randomUUIDv5.test.ts          1      2      0

Bun.CryptoHasher.hash, Bun.SHA*.hash, Bun.sha, Bun.randomUUIDv5 and
Bun.indexOfLine all captured an ArrayBuffer-backed input slice and then
coerced a later argument that can run JS (a boxed String's toString or
an object's valueOf). That JS can transfer(0) the input's backing
ArrayBuffer and spray a same-size allocation, leaving the captured
ptr/len pointing at a recycled foreign block. The digest/scan then runs
over freed memory.

For the hash entry points, add StringOrBuffer::refresh_buffer and
re-snapshot the input's ArrayBuffer from its JSValue after the output
argument has been coerced, so a detached input is observed as length 0.
For randomUUIDv5 and indexOfLine, decode the later argument (namespace /
offset) before snapshotting the buffer; the namespace result is copied
into a local [u8; 16] and offset is a plain integer, so the reverse
ordering is not exposed to the same hazard.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR updates argument coercion order and boxed-string handling to prevent user-code-driven ArrayBuffer detachment issues across hashing, password verification, UUIDv5 generation, SHA helpers, and indexOfLine, with regression tests for each affected path.

Buffer-detachment safety

Layer / File(s) Summary
String-object coercion contract
src/runtime/node/types.rs
Blob-or-string-or-buffer decoding now accepts an explicit string-object policy, with no-string-object callers rejecting boxed strings.
Hashing and password coercion flows
src/runtime/crypto/CryptoHasher.rs, src/runtime/crypto/PasswordObject.rs, src/runtime/api/BunObject.rs, test/js/bun/util/bun-cryptohasher.test.ts, test/js/bun/util/password.test.ts
Hashing and password verification reorder coercion, use no-string-object input decoding where applicable, and test detached-buffer and boxed-string behavior.
Detachment-sensitive API ordering
src/runtime/api/BunObject.rs, src/runtime/webcore/Crypto.rs, test/js/bun/util/index-of-line.test.ts, test/js/bun/util/randomUUIDv5.test.ts
indexOfLine resolves offsets before buffer snapshots, while UUIDv5 processes the namespace before converting the name; regression tests cover both cases.

Possibly related PRs

  • oven-sh/bun#34966: Updates related StringOrBuffer pinning behavior and overlapping verifySync detachment coverage.

Suggested reviewers: jarred-sumner

🚥 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 accurately summarizes the main fix: reordering coercion to avoid capturing inputs too early across hash, UUID, and indexOfLine paths.
Description check ✅ Passed The description covers the problem, fix, and verification, though it uses custom headings instead of the template's exact section names.

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

@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:11 PM PT - Jul 21st, 2026

✅ @autofix-ci[bot], your commit 3557f2a216c532d33461da839298d882a4ae8ce5 passed in Build #77312! 🎉


🧪   To try this PR locally:

bunx bun-pr 34964

That installs a local version of the PR into your bun-34964 executable, so you can run:

bun-34964 --bun

Comment thread src/runtime/node/types.rs Outdated
Comment thread test/js/bun/util/bun-cryptohasher.test.ts Outdated
Comment thread test/js/bun/util/bun-cryptohasher.test.ts Outdated
Same pattern as the hash entry points: password's ArrayBuffer is
snapshotted via StringOrBuffer::from_js, then the hash argument is
coerced (a boxed String's toString can detach the password buffer),
then password.slice() is read. Re-snapshot password after the hash
argument has been decoded.

Also tighten the new tests: trim comments to the invariant being
asserted and check the specific error message in the reverse-direction
case.

@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.

Thanks — all three items from my earlier pass are addressed in 8dda462. I didn't find anything further, but since this is a native memory-safety fix touching crypto argument marshaling across five files, I'll leave the final sign-off to a human.

What was reviewed:

  • refresh_buffer correctly re-reads ArrayBuffer fields via as_array_buffer on the stored JSValue; the else-branch zeroes ptr/len/byte_len for the None case, and MarkedArrayBuffer::{owns_buffer,pinned} are preserved.
  • randomUUIDv5 reorder: confirmed JSValue::is_string() is is_string_like() (includes StringObject), so to_bun_string on a boxed namespace does invoke user toString; namespace copied to local [u8;16] makes the reverse direction safe. Error-message ordering changes only when both name and namespace are invalid — no existing test asserts that.
  • indexOfLine reorder: existing "handles non-number offset" tests still hold ({}/"2" coerce without side effects before the buffer is read).
Extended reasoning...

Overview

This PR closes a use-after-free bug class where several Bun.* entry points snapshot an ArrayBuffer-backed input as a raw (ptr, len) and only afterwards coerce a later argument that can run user JS (a boxed String's toString, or valueOf via coerce_to_int64). That JS can transfer(0) the input's backing store and spray a same-size allocation, leaving the captured slice pointing at foreign memory. The fix adds StringOrBuffer::refresh_buffer / BlobOrStringOrBuffer::refresh_buffer (re-snapshot the Buffer variant from its stored JSValue) and applies it after the later-arg coercion in CryptoHasher::hash, StaticCryptoHasher::hash, the Bun.sha adapter, and Bun.password.verifySync; Bun.randomUUIDv5 and Bun.indexOfLine are fixed by reordering the coercion before the buffer snapshot.

Security risks

The PR removes a UAF; the concern is whether the fix itself introduces anything. refresh_buffer calls JSValue::as_array_buffer, which is a pure C++ field read (no JS re-entry), and overwrites only the ArrayBuffer sub-struct of MarkedArrayBuffer (which is Copy; .value is preserved because as_array_buffer writes out.value = self). The reorders in randomUUIDv5/indexOfLine change only error-path observable ordering for Bun-native APIs. No new attack surface is added.

Level of scrutiny

High — native memory safety in crypto-adjacent code. I traced each call site's coercion order (including the reverse direction: input's toString detaching the output buffer, which was already safe and is now pinned by a test), verified is_string() ⇒ is_string_like() so the boxed-String hazard is real for randomUUIDv5, checked that js_password_object_hash_sync and CryptoHasher::update decode their later arg before snapshotting the input (already safe, as the PR description states), and confirmed the existing indexOfLine non-number-offset tests still hold under the new ordering.

Other factors

All three findings from my earlier review were addressed in 8dda462: the missed verifySync sibling now calls refresh_buffer with a matching test, the 4-line before/after comments were trimmed to the invariant, and the reverse-direction test's bare .toThrow() now asserts /TypedArray must be at least 32 bytes/. Tests are added in existing files per CLAUDE.md conventions and assert against the empty-input digest (the correct post-detach observable). Given the guideline to defer on security-sensitive/crypto paths even when the change is mechanical, I'm leaving final approval to a maintainer rather than shadow-approving.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This approach is incorrect. We should do the arg coercion first.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reordering so the later argument is coerced before the input buffer is captured fixes the reported direction cleanly. The wrinkle is the reverse: with output decoded first and input decoded second, a boxed String input whose toString detaches the output buffer leaves hash_to_bytes writing into a stale (ptr, len) (and verifySync reading a stale hash slice). On main that case is incidentally safe because the output buffer is captured after the input's toString has already run and is seen as length 0.

I can see two ways to keep both directions safe without a post-coercion re-snapshot:

  1. Decode the input with allow_string_object = false so its decode never runs JS (a boxed String input would throw, matching Node's ERR_INVALID_ARG_TYPE for crypto.Hash#update).
  2. Split decode into a coerce-strings pass over all args followed by a capture-buffers pass, so every buffer slice is taken after all user JS has run.

Happy to go with either; I'll default to (1) since it's the smaller change and aligns with Node, unless you'd prefer (2) or are fine leaving the reverse case as-is.

Replace the post-coercion re-snapshot with a decode order that runs all
user JS before any ArrayBuffer slice is captured:

* Bun.CryptoHasher.hash / Bun.SHA*.hash / Bun.sha: decode the output
  argument first, then the input via
  BlobOrStringOrBuffer::from_js_no_string_object so the input decode
  never calls user toString and the already-captured output buffer
  stays valid.
* Bun.password.verifySync: decode the hash argument first, then the
  password with allow_string_object = false.
* Bun.randomUUIDv5 / Bun.indexOfLine were already reordered in the
  previous commits.

BlobOrStringOrBuffer::from_js_maybe_file_maybe_async now takes
allow_string_object and from_js_no_string_object is added as a
convenience wrapper. Boxed String inputs to the hash/verifySync entry
points are now rejected (matching Node's ERR_INVALID_ARG_TYPE for
crypto.Hash#update).
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 03d272c: the output/hash/namespace/offset argument is coerced first, then the input buffer is captured last, and the post-coercion re-snapshot helper is gone.

To keep the reverse direction safe (a boxed String input whose toString detaches the output buffer), the input is now decoded with allow_string_object = false via a new BlobOrStringOrBuffer::from_js_no_string_object wrapper, so the input decode never runs user JS and the already-captured output buffer stays valid. That means Bun.CryptoHasher.hash('sha256', new String('x'), 'hex') (and the Bun.SHA*.hash / Bun.sha / Bun.password.verifySync equivalents) now throw the same "expected string or buffer" they do for any other object; primitive strings are unchanged. Node's crypto.Hash#update already rejects boxed Strings with ERR_INVALID_ARG_TYPE.

@robobun robobun changed the title crypto,util: re-snapshot input buffers after later-arg coercion in Bun.* hash/UUID/indexOfLine crypto,util: coerce later arguments before capturing input buffers in Bun.* hash/UUID/indexOfLine/verifySync Jul 22, 2026

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/crypto/CryptoHasher.rs (1)

1219-1258: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Correct ordering, but duplicated across 3 call sites.

Same "coerce output first, then decode input via from_js_no_string_object" pattern is repeated verbatim here, in CryptoHasher::hash (lines 264-291), and in BunObject.rs's sha(). Extracting a shared helper (e.g. decode_hash_args(global, input_value, output_value) -> JsResult<(BlobOrStringOrBuffer, Option<StringOrBuffer>)>) would reduce the risk that a future hasher API forgets the ordering and reintroduces this UAF class.

♻️ Sketch of a shared helper
fn decode_hash_input_output(
    global: &JSGlobalObject,
    input_value: JSValue,
    output_value: JSValue,
) -> JsResult<(BlobOrStringOrBuffer, Option<StringOrBuffer>)> {
    let output = if output_value.is_undefined_or_null() {
        None
    } else {
        StringOrBuffer::from_js(global, output_value)?
    };
    let Some(input) = BlobOrStringOrBuffer::from_js_no_string_object(global, input_value)? else {
        return Err(global.throw_invalid_arguments(format_args!("expected blob, string or buffer")));
    };
    Ok((input, output))
}
🤖 Prompt for 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.

In `@src/runtime/crypto/CryptoHasher.rs` around lines 1219 - 1258, Extract the
repeated output-first/input-second decoding logic into a shared helper such as
decode_hash_input_output, reusing the existing StringOrBuffer and
BlobOrStringOrBuffer conversions and invalid-argument handling. Update
CryptoHasher::hash and the other CryptoHasher hash call site plus BunObject::sha
to call this helper, preserving the ordering that keeps output coercion ahead of
input decoding.
🤖 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.

Outside diff comments:
In `@src/runtime/crypto/CryptoHasher.rs`:
- Around line 1219-1258: Extract the repeated output-first/input-second decoding
logic into a shared helper such as decode_hash_input_output, reusing the
existing StringOrBuffer and BlobOrStringOrBuffer conversions and
invalid-argument handling. Update CryptoHasher::hash and the other CryptoHasher
hash call site plus BunObject::sha to call this helper, preserving the ordering
that keeps output coercion ahead of input decoding.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1ffcdb4-2dd7-48a3-ae6c-23c9ff6e09aa

📥 Commits

Reviewing files that changed from the base of the PR and between e550f2c and 03d272c.

📒 Files selected for processing (9)
  • src/runtime/api/BunObject.rs
  • src/runtime/crypto/CryptoHasher.rs
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/node/types.rs
  • src/runtime/webcore/Crypto.rs
  • test/js/bun/util/bun-cryptohasher.test.ts
  • test/js/bun/util/index-of-line.test.ts
  • test/js/bun/util/password.test.ts
  • test/js/bun/util/randomUUIDv5.test.ts

@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.

The rework in 03d272c looks correct — I traced both directions (later-arg toString detaching the input, and boxed-String input detaching the output) through all five entry points and each buffer is now captured after every user-JS coercion. Deferring only because the boxed-String-input rejection is a user-visible API change that the author explicitly flagged for maintainer preference (option 1 vs 2) and proceeded on the default; a quick sign-off on that trade-off would be good.

What was reviewed:

  • Reordered decode in CryptoHasher::hash/StaticCryptoHasher::hash/Bun.sha/verifySync/randomUUIDv5/indexOfLine: input buffer is captured last and its decode (from_js_no_string_object/allow_string_object=false) never runs JS, so the already-captured output/hash buffer stays valid.
  • randomUUIDv5 reverse direction: namespace is copied into a local [u8;16] before name decode, so a boxed-String name detaching the namespace buffer is harmless.
  • arguments_old→arguments_undef refactor preserves the "Missing argument" vs "expected blob, string or buffer" error ordering; arguments.len gates every ptr[i] access.
  • verifySync now validates hash before password; checked existing password.test.ts coercion tests — none assert which arg name appears first, so they still pass.
Extended reasoning...

Overview

Memory-safety fix across five Rust host functions (CryptoHasher::hash, StaticCryptoHasher::hash, Bun.sha, Bun.password.verifySync, Bun.randomUUIDv5) plus Bun.indexOfLine: each previously snapshotted an ArrayBuffer-backed input as a raw (ptr, len) and then coerced a later argument that can run user JS (StringOrBuffer::from_js on a boxed String, coerce_to_int64's valueOf), letting that JS detach the input's backing store. The fix reorders decoding so the JS-running coercion happens first and the input buffer is captured last, and — to keep the reverse direction safe — the input decode now uses allow_string_object=false so it never runs user JS. BlobOrStringOrBuffer::from_js_maybe_file_maybe_async gains an allow_string_object parameter with a from_js_no_string_object convenience wrapper. Four new regression tests exercise the detach-during-coercion path.

This is the third revision; my three earlier findings (missing verifySync fix, 4-line comments, bare .toThrow()) were addressed in 8dda462, then the approach was reworked in 03d272c from "re-snapshot after coercion" to "coerce first, capture last, reject boxed-String inputs". I re-reviewed the rework end-to-end.

Security risks

The bug being fixed is a use-after-free reading recycled bmalloc memory into a hash digest / password comparison / UUID / line search — reachable from any JS that can pass a boxed String with an overridden toString. The fix closes it by ensuring no raw buffer pointer is held across a call that can enter JS. I verified from_js_no_string_object → from_js_maybe_async_into(.., allow_string_object=false) returns Ok(false) for StringObject/DerivedStringObject before ever calling String::from_js, so the input decode genuinely never runs user JS. Primitive-string inputs still go through String::from_js, which on JSType::String reads the WTF string directly without invoking JS.

Level of scrutiny

High — memory safety in crypto/native bindings. The reorder itself is mechanical, but it composes with a next_eat-closure → arguments_undef refactor in CryptoHasher.rs and swaps validation order in verifySync, so I traced argument-count edge cases (len==0, len==1) and confirmed is_string() = is_string_like() (includes StringObject) so the randomUUIDv5 test's boxed-String namespace does reach to_bun_string.

Other factors

The one thing I'd want a maintainer to glance at is the API-surface trade-off: boxed String inputs to Bun.CryptoHasher.hash/Bun.SHA*.hash/Bun.sha/Bun.password.verifySync are now rejected with "expected string or buffer" (matching Node's ERR_INVALID_ARG_TYPE for Hash#update). The author explicitly surfaced two options and asked for a preference before defaulting to this one; nobody weighed in. It's the smaller and Node-aligned choice, but it's still a behavior change on Bun-native APIs that deserves a human ack rather than a bot approval.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #35757, which consolidates all four sites into one change: StringOrBuffer::from_js now pins ArrayBuffer-backed inputs on the sync path (so every caller that goes through it is covered structurally), plus the two reorders for Bun.indexOfLine and Bun.randomUUIDv5 which snapshot the buffer directly.

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

The crypto/KDF/indexOfLine/verifySync sites this PR targets were fixed on main in #36165 (post-coercion buffer re-snapshot). The remaining sites (Bun.Transpiler, Bun.randomUUIDv5, Bun.RedisClient) are covered by #35757.

This branch has not been deployed

No deployments
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