Skip to content

Fix null deref in Bun.inspect when Proxy prototype trap throws#30225

Closed
robobun wants to merge 1 commit into
mainfrom
farm/b50abd56/inspect-proxy-prototype-null-deref
Closed

Fix null deref in Bun.inspect when Proxy prototype trap throws#30225
robobun wants to merge 1 commit into
mainfrom
farm/b50abd56/inspect-proxy-prototype-null-deref

Conversation

@robobun

@robobun robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a null pointer dereference in Bun.inspect() / console.log() when formatting an object whose prototype is a Proxy with throwing traps.

JSC__JSValue__forEachPropertyImpl walks the prototype chain to enumerate properties for formatting. When the prototype is a Proxy:

  1. If the Proxy get trap throws (or delegates to a getter that throws), getPropertySlot returns false with a pending exception. The CLEAR_IF_EXCEPTION was placed after the continue, so the exception was never cleared and leaked into later operations.

  2. iterating->getPrototype(globalObject).getObject() was called without an exception check. A throwing getPrototypeOf trap (or any pending exception) makes getPrototype return an empty JSValue; calling .getObject() on that dereferences a null JSCell.

Repro

const o = {};
Object.setPrototypeOf(o, new Proxy({ foo: 1 }, {
  getPrototypeOf() { throw new Error("nope"); },
}));
Bun.inspect(o); // segfault

Also reproducible via expect():

const { expect } = Bun.jest("");
const e = expect(1);
Object.setPrototypeOf(e, new Proxy(Object.getPrototypeOf(e), {}));
Bun.inspect(e); // segfault — accessing .rejects then .resolves throws

How did you verify your code works?

Added regression tests in test/js/bun/util/inspect.test.js covering all three crash variants (throwing get trap, throwing getPrototypeOf trap, stateful getter that throws on second access). Verified they segfault on the baked release build and pass on the debug build with this fix.

Fuzzilli fingerprint: 0decaebd1bf2d774

JSC__JSValue__forEachPropertyImpl walks the prototype chain to enumerate
properties for formatting. When the prototype is a Proxy:

- If the Proxy 'get' trap throws (or delegates to a getter that throws),
  getPropertySlot returns false with a pending exception. The CLEAR_IF_EXCEPTION
  was placed after the 'continue', so the exception was never cleared and
  leaked into later operations.

- iterating->getPrototype(globalObject).getObject() was called without an
  exception check. A throwing 'getPrototypeOf' trap (or any pending exception)
  makes getPrototype return an empty JSValue; calling .getObject() on that
  is a null JSCell dereference.

Clear the exception before deciding to skip the property, and split the
getPrototype call so we can clear the exception and bail on an empty result.

Fuzzilli fingerprint: 0decaebd1bf2d774
@github-actions github-actions Bot added the claude label May 4, 2026
@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:43 PM PT - May 3rd, 2026

@robobun, your commit 3134741 has 1 failures in Build #50815 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30225

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

bun-30225 --bun

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

The PR fixes exception-handling issues in property enumeration and prototype-chain traversal within the Bun JavaScript bindings. Exception states are now explicitly cleared during iteration over object properties and prototype chains. Test coverage is enhanced with parameterized subprocess-based tests that verify proxy-related prototype trap scenarios exit cleanly.

Changes

Proxy Exception Handling & Test Coverage

Layer / File(s) Summary
Core Fix
src/bun.js/bindings/bindings.cpp
Property enumeration now captures getPropertySlot result and explicitly clears exceptions before continuing. Prototype traversal calls getPrototype into a temporary, clears exceptions, checks validity, then assigns—preventing stale exception state from affecting control flow.
Test Improvements
test/js/bun/util/inspect.test.js
Crash-testing harness expands imports to include bunEnv and bunExe. Replaces fixture-iteration with concurrent parameterized tests that spawn separate Bun processes for proxy prototype trap scenarios, asserting clean exit (code 0, empty stderr, stdout is "OK\n").
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: fixing a null dereference in Bun.inspect when Proxy prototype traps throw.
Description check ✅ Passed The description comprehensively covers both required sections: detailed explanation of what the PR fixes (with technical details and two repro cases) and how it was verified (regression tests).
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 1/5 review remaining, refill in 45 minutes and 40 seconds.

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

@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 the current code and only fix it if needed.

Inline comments:
In `@test/js/bun/util/inspect.test.js`:
- Around line 506-509: The test currently asserts stderr is exactly empty which
flakes under ASAN; update the assertion to filter out lines beginning with
"WARNING: ASAN interferes" from the proc.stderr.text() result before asserting
emptiness. Concretely, after awaiting proc.stderr.text() (variable stderr) split
into lines, remove any lines that startWith "WARNING: ASAN interferes", rejoin
and then assert the filtered stderr is "". Keep the existing checks for stdout
("OK\n") and exitCode (0) unchanged.
🪄 Autofix (Beta)

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: 79953745-e8a8-4a47-9c53-de4d187953a4

📥 Commits

Reviewing files that changed from the base of the PR and between bab007c and 3134741.

📒 Files selected for processing (2)
  • src/bun.js/bindings/bindings.cpp
  • test/js/bun/util/inspect.test.js

Comment on lines +506 to +509
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("OK\n");
expect(exitCode).toBe(0);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter known ASAN startup noise before asserting empty stderr.

With bunEnv, ASAN can emit a known warning line on stderr; asserting stderr === "" can make this flaky on ASAN CI even when behavior is correct.

Suggested patch
-    expect(stderr).toBe("");
+    const stderrLines = stderr
+      .split("\n")
+      .filter(line => !line.startsWith("WARNING: ASAN interferes"))
+      .filter(Boolean);
+    expect(stderrLines).toEqual([]);

Based on learnings: In oven-sh/bun test files that spawn subprocesses using bunEnv, suppress known ASAN startup noise by filtering lines starting with "WARNING: ASAN interferes" before asserting stderr is empty.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/bun/util/inspect.test.js` around lines 506 - 509, The test currently
asserts stderr is exactly empty which flakes under ASAN; update the assertion to
filter out lines beginning with "WARNING: ASAN interferes" from the
proc.stderr.text() result before asserting emptiness. Concretely, after awaiting
proc.stderr.text() (variable stderr) split into lines, remove any lines that
startWith "WARNING: ASAN interferes", rejoin and then assert the filtered stderr
is "". Keep the existing checks for stdout ("OK\n") and exitCode (0) unchanged.

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix null deref in Bun.inspect when Proxy prototype throws #30200 - Exact duplicate: same two fixes (reorder CLEAR_IF_EXCEPTION, guard getPrototype) in forEachPropertyImpl
  2. fix(inspect): handle Proxy trap exceptions when walking prototype chain #30099 - Exact duplicate: same two fixes in forEachPropertyImpl plus fast-path prototype guard
  3. inspect: clear exceptions when walking Proxy prototype chain #29845 - Exact duplicate: same two fixes (clear exception before continue, split getPrototype chain) in forEachPropertyImpl
  4. Fix null deref in forEachProperty with Proxy in prototype chain #29816 - Exact duplicate: same two fixes in forEachPropertyImpl with identical code structure
  5. inspect: handle throwing Proxy getPrototypeOf in forEachProperty #29814 - Exact duplicate: same two fixes in forEachPropertyImpl (uses break on empty proto)
  6. fix(inspect): don't crash when a Proxy in the prototype chain throws #29642 - Same two core fixes in forEachPropertyImpl plus additional EnsureStillAliveScope and napi.cpp changes
  7. Fix null JSCell deref in Bun lazy property callbacks and forEachProperty #29671 - Partial overlap: fixes getPrototype null deref in forEachPropertyImpl but not the getPropertySlot exception reordering
  8. getPrototype exception checks #24985 - Same getPrototype null deref bug, different approach (propagates exceptions via RETURN_IF_EXCEPTION instead of clearing)

🤖 Generated with Claude Code

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30200, #30099, #29845, #29816, #29814, #29642 (and #24985 from dylan-conway). Closing in favor of the earlier PRs.

@robobun robobun closed this May 4, 2026
@robobun robobun deleted the farm/b50abd56/inspect-proxy-prototype-null-deref branch May 4, 2026 04:42

@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 — focused null-deref fix with regression tests; the napi.cpp note is pre-existing and can be a follow-up.

Extended reasoning...

Overview

This PR fixes a segfault in JSC__JSValue__forEachPropertyImpl (used by Bun.inspect / console.log) when walking the prototype chain of an object whose prototype is a Proxy with throwing traps. Two changes in bindings.cpp:

  1. Reorder CLEAR_IF_EXCEPTION before the continue after getPropertySlot, so a throwing get trap doesn't leave a pending exception that poisons subsequent JSC calls.
  2. Split iterating->getPrototype(globalObject).getObject() into separate steps with an exception clear and an empty-value check, so a throwing getPrototypeOf trap (which makes getPrototype return an empty JSValue) doesn't dereference a null JSCell.

Three subprocess-based regression tests are added in inspect.test.js covering each crash variant.

Security risks

None introduced. This hardens the inspector against adversarial Proxy traps — the change strictly reduces crash surface. No new inputs, no auth/crypto/permissions code touched.

Level of scrutiny

Low-to-moderate. The C++ change is ~10 lines, mechanical, and follows the existing CLEAR_IF_EXCEPTION(scope) pattern already used throughout this function. The control-flow change is easy to verify locally: the empty-JSValue check (if (!nextProto) break;) handles the throwing-trap case, and nextProto.getObject() returning nullptr for a non-object (e.g. null prototype) is already handled by the loop's iterating != nullptr condition, preserving prior behavior.

Other factors

  • Found via Fuzzilli; PR includes the fingerprint and a clear repro.
  • Regression tests spawn a child bun -e and assert clean exit + OK output, so they actually exercise the crash path rather than relying on in-process behavior.
  • The only finding from review is a pre-existing instance of the same pattern in napi.cpp (napi_get_all_property_names), which is out of scope here and noted as a follow-up.
  • No prior reviewer comments to address.

Comment on lines +5448 to +5453
JSValue nextProto = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" trap.
CLEAR_IF_EXCEPTION(scope);
if (!nextProto)
break;
iterating = nextProto.getObject();

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.

🟣 Heads-up (pre-existing, not introduced here): the same getPrototype(globalObject).getObject() pattern without an exception check still exists at src/bun.js/bindings/napi.cpp:1837 in napi_get_all_property_names. A throwing Proxy getPrototypeOf/getOwnPropertyDescriptor trap on the prototype chain will cause the same null deref there when called with napi_key_include_prototypes + a descriptor filter — might be worth a follow-up since it's the identical bug class.

Extended reasoning...

What the bug is

This PR fixes a null deref in JSC__JSValue__forEachPropertyImpl where iterating->getPrototype(globalObject).getObject() was called without checking for a pending exception. The exact same vulnerable pattern still exists in napi_get_all_property_names at src/bun.js/bindings/napi.cpp:1837:

while (!current_object->getOwnPropertyDescriptor(globalObject, key.toPropertyKey(globalObject), desc)) {
    JSObject* proto = current_object->getPrototype(globalObject).getObject();
    if (!proto) {
        break;
    }
    current_object = proto;
}

There is no exception check between getPrototype(globalObject) and .getObject(), and the preceding getOwnPropertyDescriptor (also a Proxy trap) is similarly unchecked.

Code path that triggers it

A native addon calls napi_get_all_property_names with:

  • key_mode = napi_key_include_prototypes
  • key_filter containing any of napi_key_enumerable | napi_key_writable | napi_key_configurable (so the descriptor-filtering branch at line 1827 runs)
  • An object whose prototype chain contains a Proxy with a throwing getPrototypeOf trap, or a throwing getOwnPropertyDescriptor trap.

The earlier allPropertyKeys call (line 1818) goes through the Proxy ownKeys/getPrototypeOf traps and is guarded by NAPI_RETURN_IF_EXCEPTION at line 1823. However, the inner loop at line 1836 invokes getOwnPropertyDescriptor — a different trap that allPropertyKeys does not exercise — so a Proxy whose ownKeys/getPrototypeOf succeed but whose getOwnPropertyDescriptor throws will sail past line 1823 and blow up here.

Why the existing guard doesn't help

When a Proxy trap throws (or there is already a pending exception), JSObject::getPrototype / ProxyObject::getPrototype returns an empty JSValue. On JSVALUE64, an empty JSValue is encoded as 0, which passes the isCell() check (0 & NotCellMask == 0). getObject() therefore evaluates asCell()->isObject() on a null JSCell* and segfaults before reaching the if (!proto) break; on line 1838 — that null check never gets a chance to run.

Step-by-step proof

  1. JS sets up obj whose prototype is new Proxy(target, { getOwnPropertyDescriptor() { throw new Error('nope'); } }) and target has a key foo.
  2. Native addon calls napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_enumerable, napi_key_numbers_to_strings, &result).
  3. allPropertyKeys walks the chain via ownKeys/getPrototypeOf (no throw), returns ['foo']. NAPI_RETURN_IF_EXCEPTION passes.
  4. Filter loop runs for 'foo'. object->getOwnPropertyDescriptor on obj itself returns false (own miss), enters loop body.
  5. obj->getPrototype(globalObject) returns the Proxy; .getObject() returns the Proxy object; current_object = proxy.
  6. Next iteration: proxy->getOwnPropertyDescriptor(...) invokes the trap, which throws. Returns false → loop body again with a pending exception.
  7. proxy->getPrototype(globalObject) runs ProxyObject::getPrototype, which begins with RETURN_IF_EXCEPTION(scope, { }) → returns empty JSValue.
  8. .getObject() on empty → asCell() is nullptrnullptr->isObject() → SIGSEGV.

(The simpler variant — a Proxy whose getPrototypeOf throws only on the second call — also reaches step 7 directly.)

Impact

Same as the bug this PR fixes: a hard crash (segfault) of the Bun process, but gated behind a native addon calling this specific N-API with prototype-walking + descriptor filtering on adversarial input. Less reachable than the Bun.inspect case, but still the same crash class.

Fix

Mirror what this PR does in bindings.cpp:

JSValue protoVal = current_object->getPrototype(globalObject);
NAPI_RETURN_IF_EXCEPTION(env);   // or break + report
JSObject* proto = protoVal.getObject();
if (!proto) break;

and add an exception check after getOwnPropertyDescriptor in the loop condition. This is pre-existing and untouched by this PR — flagging only because it's the identical bug class and a natural follow-up.

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.

1 participant