fix(inspect): handle Proxy trap exceptions when walking prototype chain#30099
fix(inspect): handle Proxy trap exceptions when walking prototype chain#30099robobun wants to merge 4 commits into
Conversation
|
Updated 8:28 AM PT - May 4th, 2026
❌ @robobun, your commit c4eb9e3 has some failures in 🧪 To try this PR locally: bunx bun-pr 30099That installs a local version of the PR into your bun-30099 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughProperty enumeration in ChangesException Handling in Property Enumeration
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ 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 |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
| JSValue proto = iterating->getPrototype(globalObject); | ||
| // Ignore exceptions from Proxy "getPrototypeOf" trap. | ||
| CLEAR_IF_EXCEPTION(scope); | ||
| iterating = proto ? proto.getObject() : nullptr; |
There was a problem hiding this comment.
🟣 Note: the same getPrototype(globalObject).getObject() null-deref pattern still exists at src/bun.js/bindings/napi.cpp:1837, reachable via napi_get_all_property_names with napi_key_include_prototypes + an enumerable/writable/configurable filter on an object with a throwing Proxy in its prototype chain. This is pre-existing (the PR doesn't touch napi.cpp), but it's the exact same root cause and might be worth applying the same CLEAR_IF_EXCEPTION + null-check there as a follow-up.
Extended reasoning...
What the bug is
This PR correctly fixes the null dereference in JSC__JSValue__forEachPropertyImpl by splitting iterating->getPrototype(globalObject).getObject() into two steps, clearing any pending exception, and null-checking the returned JSValue before calling .getObject(). However, the identical unsafe pattern still exists in src/bun.js/bindings/napi.cpp:
// src/bun.js/bindings/napi.cpp:1833-1842
if (key_mode == napi_key_include_prototypes) {
// Climb up the prototype chain to find inherited properties
JSObject* current_object = object;
while (!current_object->getOwnPropertyDescriptor(globalObject, key.toPropertyKey(globalObject), desc)) {
JSObject* proto = current_object->getPrototype(globalObject).getObject(); // <-- same pattern
if (!proto) {
break;
}
current_object = proto;
}
}How it manifests
There are two ways for getPrototype() to return an empty JSValue here, both via Proxy traps:
- Throwing
getOwnPropertyDescriptortrap: The while-loop condition callscurrent_object->getOwnPropertyDescriptor(...). Ifcurrent_objectis a Proxy whosegetOwnPropertyDescriptortrap throws, JSC returnsfalseand leaves the exception pending. The loop body then callscurrent_object->getPrototype(globalObject)with a pending exception, which returns an emptyJSValue. - Throwing
getPrototypeOftrap: If the loop condition returnsfalsecleanly butcurrent_objectis a Proxy whosegetPrototypeOftrap throws,getPrototype()itself throws and returns an emptyJSValue.
In both cases, .getObject() is then called on an empty JSValue. An empty JSValue is encoded as 0, which has no NotCellMask bits set, so isCell() returns true, asCell() returns nullptr, and nullptr->getObject() reads m_type from a null pointer — the same UBSAN null deref this PR fixes in bindings.cpp. The if (!proto) break; on the next line doesn't help because the dereference has already happened inside .getObject().
Why existing code doesn't prevent it
The NAPI_RETURN_IF_EXCEPTION(env) at line 1823 only guards the initial key collection (allPropertyKeys / ownPropertyKeys). The per-key descriptor loop at lines 1829-1845 has no RETURN_IF_EXCEPTION, CLEAR_IF_EXCEPTION, or empty-value check around getOwnPropertyDescriptor or getPrototype, so exceptions thrown by Proxy traps during the prototype walk are neither observed nor cleared.
Step-by-step proof
- A native addon calls
napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_enumerable, napi_key_numbers_to_strings, &result)whereobjhasnew Proxy(proto, { getPrototypeOf() { throw new Error('boom'); } })somewhere in its prototype chain. allPropertyKeyscollects keys from the chain (this may or may not throw depending on trap order; assume it succeeds and returns at least one key not present as an own property ofobj).- Because
key_filter & filter_by_any_descriptoris truthy, we enter the filter loop. For a key not owned byobj,getOwnPropertyDescriptorreturnsfalseand we enter the while-loop body. object->getPrototype(globalObject)walks to the Proxy and invokes itsgetPrototypeOftrap, which throws.getPrototypereturns an emptyJSValue..getObject()→isCell()on encoded0→true→asCell()→nullptr→nullptr->getObject()readsJSCell::m_typeat offset from null → UBSAN null deref / crash.
(The throwing-getOwnPropertyDescriptor variant is even simpler: trap throws → returns false → loop body runs with pending exception → getPrototype returns empty → same crash.)
Impact and fix
This is reachable from any native addon that calls napi_get_all_property_names with napi_key_include_prototypes and an enumerable/writable/configurable filter on user-supplied objects. The fix is the same as what this PR applies in bindings.cpp:
JSValue protoValue = current_object->getPrototype(globalObject);
CLEAR_IF_EXCEPTION(scope); // or RETURN_IF_EXCEPTION if NAPI should propagate
JSObject* proto = protoValue ? protoValue.getObject() : nullptr;
if (!proto) break;plus an exception check after getOwnPropertyDescriptor in the loop condition.
Relationship to this PR
This PR does not touch napi.cpp, add callers to it, or otherwise interact with this code path — so this is strictly a pre-existing issue and should not block merging. Mentioning it only because it is the exact same root cause fixed here, found by the same fuzzer pattern, and the author may want to apply the same fix in a follow-up.
|
CI failure on |
When walking the prototype chain for property enumeration, exceptions thrown by Proxy traps could leak past the continue on a false getPropertySlot result, and getPrototype could return an empty JSValue which was then passed to getObject() causing a null dereference.
bdf6f84 to
fb85f69
Compare
There was a problem hiding this comment.
LGTM — small, defensive fix with regression tests; the napi.cpp note from my earlier review is a pre-existing issue and not a blocker.
Extended reasoning...
Overview
This PR touches two files: src/jsc/bindings/bindings.cpp (~10 net lines changed inside JSC__JSValue__forEachPropertyImpl) and test/js/bun/util/inspect.test.js (two new crash-test fixtures). The C++ change does three things: (1) clears exceptions after the initial fast-path getPrototype call, (2) reorders the slow-path getPropertySlot so CLEAR_IF_EXCEPTION runs before the continue instead of after, and (3) splits the slow-path iterating->getPrototype(globalObject).getObject() into separate steps with an exception clear and null-check. All three follow the existing CLEAR_IF_EXCEPTION(scope) pattern already used throughout this function.
Security risks
None introduced. This is strictly a hardening change — it converts a fuzzer-found null-pointer dereference (UBSAN abort) in Bun.inspect/console.log into graceful handling. Exceptions are intentionally swallowed here because forEachProperty is used for debugging/formatting output, where best-effort enumeration is the established behavior (the function already clears getter exceptions in several other places).
Level of scrutiny
Low-to-moderate. The change is small, mechanical, and confined to a single function whose purpose is best-effort property enumeration for inspection. The root-cause analysis in the PR description is precise and the fix matches it exactly. I verified against the current source that scope is a DECLARE_TOP_EXCEPTION_SCOPE in scope at all three insertion points, and that the null-check on proto correctly guards .getObject() (an empty JSValue is falsy, so the ternary short-circuits before the dereference).
Other factors
- Regression tests added to the existing "crash testing" fixture array in
inspect.test.js, covering both the throwing-getter-via-Proxy case and the throwing-getPrototypeOf-trap case. CI confirmsinspect.test.jspassed 74/74 on all platforms; the only CI failure is an unrelated pre-existing macOS flake (no-orphans.test.ts). - My earlier inline note about
napi.cpp:1837is explicitly a pre-existing issue in code this PR doesn't touch — it's a follow-up suggestion, not a blocker. - The duplicate-PR bot flagged five near-identical PRs; that's a dedup/workflow concern for maintainers and doesn't affect the correctness of this change.
There was a problem hiding this comment.
LGTM — targeted crash fix with regression tests; the napi.cpp note from my earlier comment is pre-existing and non-blocking.
Extended reasoning...
Overview
This PR fixes a null-pointer dereference in JSC__JSValue__forEachPropertyImpl (the C++ backing for Bun.inspect/console.log property enumeration) when a Proxy in the prototype chain throws from a trap. Three small changes in src/jsc/bindings/bindings.cpp: (1) clear exceptions after the fast-path getPrototype call, (2) reorder CLEAR_IF_EXCEPTION before the continue in the slow-path property loop so a throwing getPropertySlot doesn't leave a pending exception, and (3) split getPrototype(...).getObject() into a null-checked two-step in the slow-path prototype walk. Two regression fixtures are added to the existing "crash testing" block in test/js/bun/util/inspect.test.js.
Security risks
None. The change is purely defensive — it adds exception clearing and a null check on a code path that previously crashed. It does not introduce new data flows, parsing, or trust boundaries; if anything it removes a user-triggerable abort.
Level of scrutiny
Moderate. This touches JSC bindings C++, but the change is mechanical and follows the established CLEAR_IF_EXCEPTION(scope) pattern already used throughout this function (and matches what the sibling forEachPropertyOrdered already does for getPropertySlot). The diff is ~10 lines of logic, strictly narrowing behavior from "crash" to "skip and continue", with no change to the non-exceptional path.
Other factors
- The new test fixtures exercise both failure modes (throwing getter via Proxy forwarding, and throwing
getPrototypeOftrap) and pass on all CI platforms; the only CI failure is an unrelated pre-existing macOS flake inno-orphans.test.ts. - I previously left a 🟣 note that the same
getPrototype(...).getObject()pattern exists innapi.cpp— that is pre-existing, out of scope for this PR, and explicitly non-blocking. - The duplicate-PR bot flagged several near-identical fixes; that's a triage/process matter and doesn't affect the correctness of this change.
- No new commits with code changes since my prior review (only CI retriggers).
|
Build #51237: all 16 failures are |
What does this PR do?
Fixes a null pointer dereference in
JSC__JSValue__forEachPropertyImpl(used byBun.inspect/console.logformatting) when the object being inspected has aProxyin its prototype chain.Root cause
In the slow-path property enumeration loop:
object->getPropertySlot(globalObject, property, slot)can throw (e.g. a Proxy forwards[[Get]]to a native getter that throws). When it throws, JSC returnsfalsefromgetPropertySlot.if (!getPropertySlot(...)) continue;— skipping theCLEAR_IF_EXCEPTIONthat came after thecontinue, so the exception stayed pending.iterating->getPrototype(globalObject)is called. With an exception pending (or with a ProxygetPrototypeOftrap that throws), this returns an emptyJSValue..getObject()on an emptyJSValuecallsasCell()->getObject()on a null pointer.Fix
getPropertySlotbefore thecontinue(matching whatforEachPropertyOrderedalready does).getPrototypeand null-check the result before calling.getObject(), both in the slow-path iteration and in the initial fast-path prototype lookup.How did you verify your code works?
Minimal repro that crashed before and now works:
Added regression tests to
test/js/bun/util/inspect.test.jscovering both the throwing-getter case and the throwing-getPrototypeOfcase. Both tests crash the old binary with the same UBSAN report and pass on this branch.Found by Fuzzilli (fingerprint
6913eddd71ce20aa).