node:util: fix isError crash on revoked proxies and getPrototypeOf traps

Merged
RO
3mo ago
+53-1

Problem

  • util.isError() crashes the process instead of returning or throwing when given a revoked Proxy or a Proxy with a getPrototypeOf trap. Release builds die with panic(main thread): Segmentation fault at address 0x0; debug builds abort in VM::checkVMEntryPermission().

    const r = Proxy.revocable({}, {});
    r.revoke();
    require("node:util").isError(r.proxy);                                          // segfault
    require("node:util").isError(new Proxy({}, { getPrototypeOf() { throw 1; } }));  // segfault
    require("node:util").isError(new Proxy({}, { getPrototypeOf: () => Error.prototype })); // segfault too
    
  • The deprecated API is not the only way in: internal/streams/iter/utils.ts (wrapError) runs every value a source throws through util.isError, so a program that throws such a Proxy into node:stream/iter segfaults too (probe in the details block below).

  • Cause 1, jsFunctionIsError in src/jsc/modules/NodeUtilTypesModule.cpp: the result of object->getPrototype(globalObject) is used with no exception check. When the call throws (revoked proxy, throwing trap) it returns the empty JSValue, which passes isCell(), so proto.inherits<ErrorInstance>() reads the structure of a null cell.

  • Cause 2, same function: the VMInquiry PropertySlot used a few lines earlier for the Symbol.toStringTag lookup is still alive when getPrototype() runs, so its DisallowVMEntry is still in force when the Proxy tries to call the trap. Debug builds crash there; release builds skip the trap and get undefined back, which the Proxy turns into a TypeError, which then feeds cause 1. This is why even a well behaved trap (returning Error.prototype or null) crashed.

Fix

  • slot.disallowVMEntry.reset() once the toStringTag lookup has returned. This is the idiom JSC itself uses when a VMInquiry slot is followed by code that may run JS (JSScope.cpp, JSGlobalObject.cpp, JSInjectedScriptHost.cpp). The only later use of the slot is getValue() on a slot that isValue(), which reads a stored value and does not enter the VM.
  • RETURN_IF_EXCEPTION after getPrototype(), so a revoked proxy throws its TypeError and a throwing trap rethrows the trap's own value.
  • Why this is the right behavior: Node's legacy util.isError(e) ends in e instanceof Error, which performs the same [[GetPrototypeOf]] on the proxy. It throws on a revoked proxy, propagates the trap's exception unchanged, and honors a trap's return value (null is not an Error, Error.prototype is). The new test asserts exactly those outcomes.
  • Verified with test/js/node/util/util.test.js, util > isError > handles revoked proxies and getPrototypeOf traps. The crashing inputs run in a child process, one line of output per case: revoked proxy throws a TypeError, a throwing trap propagates its Error, a trap throwing a plain object rethrows that same object (caught === thrown), null trap gives false, Error.prototype trap gives true, a Proxy wrapping an Error gives true.
    • Debug build with NodeUtilTypesModule.cpp reverted to main: the test fails (the child aborts); the same-object case run on its own aborts with exit 134.
    • Debug build with the fix: the file passes (209 tests).
    • USE_SYSTEM_BUN=1 (release 1.4.0 canary): the test fails, the child segfaults.

This PR replaces #37202, which fixed the same two problems in the same function; its extra test case (the trap's thrown value comes back as the same object) has been folded into the test here.

Not changed here: jsFunctionIsError still only looks one prototype level deep and at Symbol.toStringTag, which differs from Node's toString(e) === "[object Error]" || e instanceof Error for some non-crashing inputs. That is a behavior question separate from this crash and is left as is.

Background

  • PropertySlot with InternalMethodType::VMInquiry asks JSC for a lookup with no observable side effects (no getters, no Proxy traps). To enforce that, the slot holds a DisallowVMEntry token for its whole lifetime; while any such token exists, JSC refuses to enter the VM to run JavaScript (debug: crash, release: the call is skipped and yields undefined).
  • A throwing JSC host call does not unwind. It records the exception on the VM and returns a placeholder (the empty JSValue here); the caller has to check the throw scope (RETURN_IF_EXCEPTION) before touching the result.
  • The empty JSValue is encoded as 0, which the isCell() fast check accepts, so using it as a cell is a null dereference.
Probe: reaching the crash through node:stream/iter
// bun --experimental-stream-iter repro.js
const { share } = require("node:stream/iter");
const poison = new Proxy({}, { getPrototypeOf() { throw new Error("trap"); } });
async function* src() { throw poison; }
(async () => {
  try {
    for await (const x of share(src()).pull()) {}
  } catch (e) {
    console.log("caught:", String(e));
  }
})();

bun 1.4.0 canary (8326d1bd3): panic(main thread): Segmentation fault at address 0x0, exit 139.
With this PR: caught: Error: trap, exit 0.


[review] gate passed · iteration 5 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/util/util.test.js
bun test v1.4.0 (8326d1bd3)

test/js/node/util/util.test.js:
(pass) util > toUSVString [5.02ms]
(pass) util > inherits [5.33ms]
(pass) util > isArray > all cases [7.18ms]
(pass) util > isRegExp > all cases [4.38ms]
(pass) util > isDate > all cases [165.31ms]
(pass) util > isError > all cases [15.19ms]
185 |         env: bunEnv,
186 |         stdout: "pipe",
187 |         stderr: "pipe",
188 |       });
189 |       const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
190 |       expect(stderr).toBe("");
                           ^
error: expect(received).toBe(expected)

- ""
+ "/root/.bun/build-cache/webkit-c6cfe90c6064bd80-debug-asan/include/JavaScriptCore/JSCJSValueStructure.h:45:34: runtime error: member call on null pointer of type 'JSC::JSCell'
+ SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /root/.bun/build-cache/webkit-c6cfe90c6064bd80-debug-asan/include/JavaScriptCore/JSCJSValueStructure.h:45:34 
+ "

- Expected  - 1
+ Received  + 3
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (28977160c)

test/js/node/util/util.test.js:
(pass) util > toUSVString [0.11ms]
(pass) util > inherits [0.10ms]
(pass) util > isArray > all cases [0.10ms]
(pass) util > isRegExp > all cases [0.05ms]
(pass) util > isDate > all cases [6.04ms]
(pass) util > isError > all cases [0.32ms]
(pass) util > isError > handles revoked proxies and getPrototypeOf traps [17.19ms]
(pass) util > isObject > all cases [0.11ms]
(pass) util > isPrimitive > all cases [0.11ms]
(pass) util > isBuffer > all cases [0.05ms]
(pass) util > _extend > all cases [0.13ms]
(pass) util > isBoolean > all cases [0.04ms]
(pass) util > isNull > all cases [0.04ms]
(pass) util > isUndefined > all cases [0.03ms]
(pass) util > isNullOrUndefined > all cases [0.03ms]
(pass) util > isNumber > all cases [0.03ms]
(pass) util > isString > all cases [0.02ms]
(pass) util > isSymbol > all cases [0.03ms]
(pass) util > isFunction > all cases [0.04ms]
(pass) util > types.isNativeError > all cases [0.07ms]
(pass) util > TextEncoder > is same as global TextEncoder [0.02ms]
(pass) util > TextDecoder > is same as global TextDecoder [0.02ms]
(pass) util > format [0.23ms]
(pass) util > formatWithOption
... (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/util/util.test.js
bun test v1.4.0 (8326d1bd3)

test/js/node/util/util.test.js:
(pass) util > toUSVString [4.96ms]
(pass) util > inherits [5.58ms]
(pass) util > isArray > all cases [7.00ms]
(pass) util > isRegExp > all cases [3.70ms]
(pass) util > isDate > all cases [175.40ms]
(pass) util > isError > all cases [17.81ms]
(pass) util > isError > handles revoked proxies and getPrototypeOf traps [852.71ms]
(pass) util > isObject > all cases [4.70ms]
(pass) util > isPrimitive > all cases [9.14ms]
(pass) util > isBuffer > all cases [4.43ms]
(pass) util > _extend > all cases [7.81ms]
(pass) util > isBoolean > all cases [2.75ms]
(pass) util > isNull > all cases [2.91ms]
(pass) util > isUndefined > all cases [2.82ms]
(pass) util > isNullOrUndefined > all cases [2.81ms]
(pass) util > isNumber > all cases [2.70ms]
(pass) util > isString > all cases [2.64ms]
(pass) util > isSymbol > all cases [2.67ms]
(pass) util > isFunction > all cases [3.09ms]
(pass) util > types.isNativeError > all cases [4.29ms]
(pass) util > TextEncoder > is same
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     871f486d72
  features     baseline

22 deps, 120 codegen, 1175 objects in 637ms

ninja: Entering directory `/workspace/bun/build/release'
[1/127] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 241 extern-C blocks audited
[2/127] gen cpp.rs (cppbind)
[3/127] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[4/127] gen JS modules (bundle-modules)
Preprocess modules (8084ms)
Bundle modules (45ms)
Postprocesss modules (32ms)
Bundle Functions (651ms)
Generate Code (19ms)

[8.85s] Bundled "src/js" for production
  2631 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/127] 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 std v0.0.0 (/root/.rustup/toolchains/nigh
... (truncated)
diff hotspot
src/jsc/modules/NodeUtilTypesModule.cpp |  3 ++
 test/js/node/util/util.test.js          | 51 ++++++++++++++++++++++++++++++++-
 2 files changed, 53 insertions(+), 1 deletion(-)

gate history · 4 passed · 1 rejected · iteration 5

evidence per changed file
file                                     reads  edits  tests
src/jsc/modules/NodeUtilTypesModule.cpp      5      6      0
test/js/node/util/util.test.js               5      7      0