Skip to content

Fix null deref in Bun.inspect when Proxy prototype traps throw#30030

Closed
robobun wants to merge 1 commit into
mainfrom
farm/48d1086a/fix-inspect-proxy-prototype
Closed

Fix null deref in Bun.inspect when Proxy prototype traps throw#30030
robobun wants to merge 1 commit into
mainfrom
farm/48d1086a/fix-inspect-proxy-prototype

Conversation

@robobun

@robobun robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a crash in Bun.inspect / console.log when formatting an object whose prototype chain contains a Proxy with throwing traps.

In JSC__JSValue__forEachPropertyImpl, the slow-path prototype walk had two issues:

  1. getPropertySlot can throw via a Proxy get trap and return false. The early continue skipped CLEAR_IF_EXCEPTION, leaving the exception pending for subsequent operations.
  2. getPrototype(globalObject) can throw via a Proxy getPrototypeOf trap and return an empty JSValue. Calling .getObject() on an empty value dereferences a null JSCell.

Both now clear the exception and stop walking the chain gracefully.

Repro

const obj = {};
Object.setPrototypeOf(obj, new Proxy({}, {
  getPrototypeOf() { throw new Error("nope"); }
}));
console.log(obj); // segfault before, prints "{}" after

How did you verify your code works?

  • Added regression tests in test/js/bun/util/inspect.test.js covering both throwing getPrototypeOf and throwing get traps in the prototype chain.
  • Verified tests segfault on main and pass with this fix.
  • Full inspect.test.js suite passes (74 tests).

Found by fuzzer (fingerprint 8d8ced8f16c8c4dd).

When an object's prototype chain contains a Proxy with a throwing
getPrototypeOf trap, or a throwing get trap, forEachProperty would
crash while walking the prototype chain.

- getPropertySlot can throw via a Proxy get trap and return false;
  the continue would skip CLEAR_IF_EXCEPTION, leaving the exception
  pending for the next iteration.
- getPrototype can throw via a Proxy getPrototypeOf trap and return
  an empty JSValue; calling .getObject() on an empty value dereferences
  a null JSCell.
@github-actions github-actions Bot added the claude label May 1, 2026
@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:30 PM PT - Apr 30th, 2026

@robobun, your commit 0ed851d has 1 failures in Build #49621 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30030

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

bun-30030 --bun

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1d647323-caa4-439d-8e62-e82349758fa1

📥 Commits

Reviewing files that changed from the base of the PR and between a03f69a and 0ed851d.

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

Walkthrough

The changes enhance exception handling in property enumeration during object inspection. The implementation now properly handles exceptions from Proxy traps (both Get and getPrototypeOf) without propagating them, with corresponding test coverage.

Changes

Cohort / File(s) Summary
Exception Handling in Property Enumeration
src/bun.js/bindings/bindings.cpp
Updated JSC__JSValue__forEachPropertyImpl to capture property slot results and explicitly clear exceptions from Proxy "Get" traps; added dedicated exception handling for Proxy getPrototypeOf traps during prototype chain traversal.
Test Coverage for Exception Resilience
test/js/bun/util/inspect.test.js
Added test assertions verifying that Bun.inspect produces "{}" and does not propagate errors when encountering throwing getPrototypeOf traps or throwing property getters via Proxy get traps.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing a null dereference in Bun.inspect when Proxy prototype traps throw exceptions.
Description check ✅ Passed The description comprehensively covers both required sections: explains what the PR does (the crash fix and root causes), provides a clear repro, and details verification through 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: 4/5 reviews remaining, refill in 12 minutes.

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

@github-actions

github-actions Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(inspect): don't crash when a Proxy in the prototype chain throws #29642 - Fixes the same null deref in forEachPropertyImpl from throwing Proxy prototype traps, plus extends fixes to napi bindings
  2. Fix null JSCell deref in Bun lazy property callbacks and forEachProperty #29671 - Fixes the forEachPropertyImpl null deref when getPrototype() returns empty due to throwing Proxy getPrototypeOf trap
  3. inspect: handle throwing Proxy getPrototypeOf in forEachProperty #29814 - Fixes the exact same two bugs: uncleaned exception after getPropertySlot and null deref from getPrototype() with throwing Proxy
  4. Fix null deref in forEachProperty with Proxy in prototype chain #29816 - Covers both root causes in forEachPropertyImpl: pending exception leak and empty JSValue from getPrototype()
  5. inspect: clear exceptions when walking Proxy prototype chain #29845 - Fixes the same two issues: clear exception on getPropertySlot failure and guard getPrototype() return for throwing Proxy traps

🤖 Generated with Claude Code

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29642 / #29814 / #29816 / #29845 — same fix for the same fuzzer fingerprint. Closing in favor of the earlier PRs.

@robobun robobun closed this May 1, 2026
@robobun robobun deleted the farm/48d1086a/fix-inspect-proxy-prototype branch May 1, 2026 04:29

@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 — straightforward crash fix that mirrors the existing exception-handling patterns in the same function, with regression tests.

Extended reasoning...

Overview

This PR fixes a null-pointer dereference in JSC__JSValue__forEachPropertyImpl (the slow-path property iterator used by Bun.inspect / console.log). Two small changes in src/bun.js/bindings/bindings.cpp:

  1. Reorder getPropertySlot so CLEAR_IF_EXCEPTION(scope) runs before the early continue (a Proxy get trap can throw and return false).
  2. Split iterating->getPrototype(globalObject).getObject() into two steps with an exception check in between, since a throwing Proxy getPrototypeOf trap returns an empty JSValue and .getObject() on that dereferences null.

Two regression tests added to test/js/bun/util/inspect.test.js covering both traps.

Security risks

None introduced. This is defensive hardening that removes a segfault triggerable from JS via a hostile Proxy. No new attack surface; exceptions are swallowed (consistent with the rest of this best-effort inspection routine, which already clears getter exceptions at lines 5337-5340 and has a catch-all clear at 5376-5379).

Level of scrutiny

Low-to-moderate. It's C++ in JSC bindings, but the change is purely mechanical and copies idioms already used a few lines away in the same function:

  • The hasProperty = ...; CLEAR_IF_EXCEPTION; if (!hasProperty) continue; shape now matches lines 5218 and 5253 exactly.
  • The if (scope.exception()) { tryClearException(); ... } shape matches lines 5337-5340.

There are no new control-flow paths beyond "stop walking the prototype chain on error," which is the obvious correct behavior for an inspection routine.

Other factors

  • Regression tests included; PR description states they segfault on main and pass with the fix, and the full inspect.test.js suite passes.
  • The one inline finding is explicitly pre-existing (same pattern in napi.cpp, not touched by this PR) and is a follow-up suggestion, not a blocker.
  • ~15 lines of C++ changed, no API or behavior changes beyond not crashing.

Comment on lines +5364 to +5370
JSValue proto = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" trap.
if (scope.exception()) [[unlikely]] {
(void)scope.tryClearException();
break;
}
iterating = proto.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 by this PR): the same getPrototype(globalObject).getObject() null-deref pattern fixed here also exists at src/bun.js/bindings/napi.cpp:1837 inside napi_get_all_property_names. A native addon calling that API with napi_key_include_prototypes + an enumerable/writable/configurable filter on an object whose prototype chain has a Proxy with a throwing getPrototypeOf (or getOwnPropertyDescriptor) trap will crash the same way. Might be worth applying the same guard there as a follow-up.

Extended reasoning...

What the bug is

This PR correctly fixes a null dereference in JSC__JSValue__forEachPropertyImpl where iterating->getPrototype(globalObject).getObject() crashes when a Proxy getPrototypeOf trap throws. The identical vulnerable pattern exists, untouched, at src/bun.js/bindings/napi.cpp:1837 inside napi_get_all_property_names:

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() and .getObject(), and none after getOwnPropertyDescriptor() either (lines 1829–1861 contain zero RETURN_IF_EXCEPTION / CLEAR_IF_EXCEPTION).

Why it crashes (same mechanism as this PR)

When a Proxy getPrototypeOf trap throws, JSObject::getPrototype() returns an empty JSValue (encoded bits = 0). On JSVALUE64 an empty value reports isCell() == true with asCell() == nullptr, so JSValue::getObject() evaluates isCell() && asCell()->isObject() and dereferences a null JSCell* in ->isObject(). The if (!proto) break; at line 1838 cannot help — the segfault happens inside .getObject() before proto is ever assigned.

Code path that triggers it

  1. A native addon calls napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_enumerable /* or writable/configurable */, ...).
  2. obj's prototype chain contains a Proxy whose handler has a throwing getPrototypeOf trap (or a throwing getOwnPropertyDescriptor trap, which makes the while condition return false with a pending exception and then getPrototype runs under that exception).
  3. allPropertyKeys at line 1818 succeeds (it uses ownKeys, not getPrototypeOf/getOwnPropertyDescriptor), so NAPI_RETURN_IF_EXCEPTION at 1823 passes.
  4. Because key_filter & (enumerable|writable|configurable) is set, the inner loop runs.
  5. For a key not present as an own property on object, getOwnPropertyDescriptor returns false and the body executes current_object->getPrototype(globalObject).
  6. The Proxy trap throws → getPrototype() returns empty JSValue.getObject() dereferences null → segfault.

Step-by-step proof

// JS side passed to a native addon:
const target = {};
const obj = Object.create(new Proxy(target, {
  getPrototypeOf() { throw new Error("nope"); },
  ownKeys() { return ["x"]; },                          // so allPropertyKeys yields a key
  getOwnPropertyDescriptor() { return { value: 1, enumerable: true, configurable: true }; },
}));
// Native addon:
// napi_get_all_property_names(env, obj, napi_key_include_prototypes,
//                              napi_key_enumerable, napi_key_numbers_to_strings, &result);
  • allPropertyKeys walks own keys of obj (none) then the proxy's ownKeys["x"]. No throw, line 1823 passes.
  • Loop iteration for "x": object (= obj) has no own "x"getOwnPropertyDescriptor returns false → enter loop body.
  • current_object is obj; obj->getPrototype(globalObject) returns the Proxy (ordinary [[GetPrototypeOf]] on obj, no throw). proto = Proxy, current_object = Proxy.
  • Next while test: getOwnPropertyDescriptor on the Proxy for "x" returns the descriptor → loop exits. Fine so far.
  • But change the handler to getOwnPropertyDescriptor() { throw new Error("gOPD"); }: now the second while test throws and returns false, body runs again, and current_object->getPrototype(globalObject) invokes the Proxy's getPrototypeOf trap → throws → empty JSValue.getObject() → null deref.
  • Alternatively, with a deeper chain (obj → plain → Proxy), the second body iteration calls getPrototype directly on the Proxy and crashes without needing the gOPD trap.

Either way the vulnerable line is reached with a throwing trap and no guard.

Why existing code doesn't prevent it

The only exception check in this function is NAPI_RETURN_IF_EXCEPTION(env) at line 1823, which runs before the descriptor-filtering loop. Inside the loop (1829–1861) there are no exception checks at all, so neither the throwing getOwnPropertyDescriptor nor the throwing getPrototype is caught.

Impact

Segfault of the Bun process when a native addon enumerates properties (with prototype walk + descriptor filter) on user-controlled objects whose prototype chain contains a hostile/broken Proxy. Lower exposure than the Bun.inspect case since it requires a native addon using this specific N-API call, but it's the same crash class this PR is hardening against.

Suggested fix (follow-up, not blocking)

Mirror this PR's fix in napi.cpp: after getOwnPropertyDescriptor and after getPrototype, check scope.exception() (or split out JSValue proto = current_object->getPrototype(globalObject); if (scope.exception() || !proto.isObject()) break;). Returning napi_pending_exception via NAPI_RETURN_IF_EXCEPTION inside the loop would also be reasonable.

This is pre-existingnapi.cpp is not touched by this PR and no new callers are added. Mentioning only because the PR is specifically about hardening this exact pattern.

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