Skip to content

Fix null deref in forEachProperty when Proxy prototype throws#29668

Closed
robobun wants to merge 1 commit into
mainfrom
farm/6d05dbf3/fix-foreachproperty-proxy-exception
Closed

Fix null deref in forEachProperty when Proxy prototype throws#29668
robobun wants to merge 1 commit into
mainfrom
farm/6d05dbf3/fix-foreachproperty-proxy-exception

Conversation

@robobun

@robobun robobun commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a null pointer dereference in JSC__JSValue__forEachPropertyImpl (used by Bun.inspect, console.log, expect error formatting, etc.) when inspecting an object whose prototype chain contains a Proxy.

Root cause

When an object's prototype chain contains a Proxy, forEachProperty takes the slow path. Two cases were mishandled:

  1. getPropertySlot() with InternalMethodType::Get on a Proxy invokes performProxyGet, which calls the getter immediately (via performDefaultGetslot.getValue()). If the getter throws, getPropertySlot returns false. The early continue then skipped over CLEAR_IF_EXCEPTION(scope), leaving a pending exception for the remainder of the loop.

  2. iterating->getPrototype(globalObject) on a Proxy can throw (either from a getPrototypeOf trap, or because of the pending exception from (1) hitting an internal RETURN_IF_EXCEPTION). When it throws it returns an empty JSValue, and calling .getObject() on that is asCell()->getObject() with asCell() == nullptr.

Fix

  • Clear the exception from getPropertySlot() before the continue.
  • Check the result of getPrototype() for an empty value and clear the exception before dereferencing, matching how the fast path already handles this.

Repro

class Foo {
  get bar() { throw new Error("getter throws"); }
}
const v = new Foo();
Object.setPrototypeOf(v, new Proxy(Object.getPrototypeOf(v), {}));
Bun.inspect(v); // previously: UBSAN null member call / crash
const v = {};
Object.setPrototypeOf(v, new Proxy({}, {
  getPrototypeOf() { throw new Error("nope"); }
}));
Bun.inspect(v); // previously: UBSAN null member call / crash

How did you verify your code works?

  • Original fuzzer repro no longer crashes (throws proper expect().toBe() error instead).
  • Added regression tests in test/js/bun/util/inspect.test.js.
  • Full inspect.test.js passes (77 tests).

When an object's prototype chain contains a Proxy, forEachProperty
takes the slow path. Two cases could leave a pending exception or
produce an empty JSValue that was then dereferenced:

- getPropertySlot() with InternalMethodType::Get on a Proxy invokes
  the getter immediately. If it throws, getPropertySlot returns false
  and the early continue skipped over CLEAR_IF_EXCEPTION, leaving a
  pending exception for the rest of the loop.
- getPrototype() on a Proxy can throw (getPrototypeOf trap, or when
  entered with a pending exception). It returns an empty JSValue, and
  calling .getObject() on that is a null deref.
@robobun

robobun commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:50 PM PT - Apr 23rd, 2026

@robobun, your commit 9229ff8 has 1 failures in Build #47668 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29668

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

bun-29668 --bun

@coderabbitai

coderabbitai Bot commented Apr 24, 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: 0eeec5ff-463b-42c8-a806-60ad516b60ca

📥 Commits

Reviewing files that changed from the base of the PR and between def5676 and 9229ff8.

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

Walkthrough

Updates property and prototype chain traversal logic in JSC bindings to properly handle exception clearing and Proxy objects, accompanied by new test cases verifying Bun.inspect behavior with proxied prototypes.

Changes

Cohort / File(s) Summary
Bindings Implementation
src/bun.js/bindings/bindings.cpp
Decouples property-slot existence from exception clearing by capturing getPropertySlot success before clearing exceptions. Adds explicit prototype fetching with exception handling for Proxy getPrototypeOf traps and safely stops iteration if prototype retrieval yields invalid values.
Inspect Proxy Tests
test/js/bun/util/inspect.test.js
Adds test fixtures and unit tests for Bun.inspect behavior with proxied prototype chains, covering cases where accessing prototype getters throws, getPrototypeOf traps throw, and successful getter value inclusion in inspection output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: addressing a null dereference when a Proxy appears in the prototype chain during property enumeration.
Description check ✅ Passed The description fully addresses both required template sections with clear explanations of the problem, root causes, implemented fix, and verification methods including reproduction cases and test coverage.
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.


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

@github-actions

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 when a Proxy in the prototype chain throws (getter or getPrototypeOf trap), plus GC safety and napi fixes
  2. getPrototype exception checks #24985 - Adds getPrototype exception checks including in forEachPropertyImpl, with broader Zig-side binding changes

🤖 Generated with Claude Code

@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 null-deref guard that mirrors the existing fast-path pattern, with regression tests.

Extended reasoning...

Overview

This PR touches two files: a ~10-line defensive change in JSC__JSValue__forEachPropertyImpl (src/bun.js/bindings/bindings.cpp) and added regression tests in test/js/bun/util/inspect.test.js. The C++ change does two things: (1) reorders CLEAR_IF_EXCEPTION to run before the early continue so a throwing Proxy get trap doesn't leave a pending exception, and (2) checks getPrototype() for an empty JSValue (and clears the exception) before calling .getObject(), which previously dereferenced null when a Proxy getPrototypeOf trap threw.

Security risks

None. This is a robustness fix — it converts a user-JS-triggerable null deref / UBSAN crash in Bun.inspect/console.log into graceful handling. No auth, crypto, permissions, or untrusted-input parsing is involved.

Level of scrutiny

Low-to-moderate. The change is purely additive guards with no behavior change on the success path. The new pattern (if (JSValue proto = getPrototype(...)) → check empty → .getObject()) is identical to how the fast path in the same function already handles this at lines ~5180 and ~5257, and how the REPL completion code at ~6402 handles it. The CLEAR_IF_EXCEPTION reorder is a textbook fix for an exception-leak-before-continue bug.

Other factors

  • Regression tests cover both crash repros (throwing getter through Proxy prototype, throwing getPrototypeOf trap) plus a positive test that non-throwing getters still produce correct output, and the cases are also added to the existing crash-testing fixture array.
  • No CODEOWNERS apply to the touched files.
  • The inline comment about napi_get_all_property_names having the same pattern is pre-existing and explicitly flagged as a follow-up — it doesn't block this fix.
  • No outstanding reviewer comments on the timeline.

Comment on lines +5378 to +5383
JSValue proto = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" trap.
CLEAR_IF_EXCEPTION(scope);
if (!proto)
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.

🟣 Pre-existing, but the same getPrototype(globalObject).getObject() null-deref pattern still lives in napi_get_all_property_names at src/bun.js/bindings/napi.cpp:1837. A native addon calling it with napi_key_include_prototypes + an enumerable/writable/configurable filter on an object whose prototype chain contains a Proxy with a throwing getPrototypeOf (or getOwnPropertyDescriptor) trap will crash the same way. Might be worth applying the same empty-value + RETURN_IF_EXCEPTION guard there as a follow-up.

Extended reasoning...

What the bug is

This PR correctly fixes a null deref in forEachPropertyImpl where getPrototype(globalObject).getObject() was called without checking for an empty JSValue after a Proxy trap throws. However, the identical unfixed pattern still exists in napi_get_all_property_names:

// src/bun.js/bindings/napi.cpp:1833-1842
if (key_mode == napi_key_include_prototypes) {
    JSObject* current_object = object;
    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 getOwnPropertyDescriptor (which can throw via a Proxy trap) and getPrototype (which can also throw via a Proxy getPrototypeOf trap, or short-circuit on a pending exception). When either throws, getPrototype() returns an empty JSValue, and .getObject() on an empty value is the same asCell()->getObject() with asCell() == nullptr that this PR fixes in bindings.cpp.

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 — e.g. Object.setPrototypeOf(obj, new Proxy({}, { getPrototypeOf() { throw new Error(); } })).
  3. Because a key filter is set, the code enters the per-key loop at line 1829.
  4. The inner while-loop walks the prototype chain. When current_object reaches the Proxy:
    • Path A: the Proxy's getOwnPropertyDescriptor trap throws → getOwnPropertyDescriptor returns false → loop body executes → ProxyObject::getPrototype hits an internal RETURN_IF_EXCEPTION on the pending exception and returns JSValue().
    • Path B: getOwnPropertyDescriptor returns false normally, then the Proxy's getPrototypeOf trap throws → getPrototype returns JSValue().
  5. .getObject() is called on the empty value. On 64-bit JSC an empty JSValue is encoded as 0, which passes the NotCellMask check, so isCell() is true, asCell() is nullptr, and nullptr->getObject() is a null member call → UBSAN crash / segfault.

Why existing code doesn't prevent it

The NAPI_RETURN_IF_EXCEPTION(env) at line 1823 only guards the earlier allPropertyKeys call (which exercises ownKeys/getPrototypeOf, not getOwnPropertyDescriptor). There is no exception check inside the per-key loop, and the if (!proto) break; check at line 1838 only runs after .getObject() has already dereferenced null.

Impact

Any N-API addon that enumerates properties (with prototype inclusion + attribute filtering) on a user-supplied object can be crashed by a hostile or buggy Proxy in the prototype chain. Same severity as the bug this PR fixes — process crash rather than a catchable JS exception.

How to fix

Apply the same guard this PR adds to bindings.cpp:

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

(Or split the empty check from the exception check, whichever matches local convention.)

Step-by-step proof

Concrete walkthrough with Path B:

  1. JS: const obj = {}; Object.setPrototypeOf(obj, new Proxy({}, { getPrototypeOf() { throw new Error('nope'); } })); then a native addon calls napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_enumerable, napi_key_numbers_to_strings, &result).
  2. allPropertyKeys at line ~1821 invokes the Proxy's getPrototypeOf once and throws — but wait, that would be caught by the line-1823 check. So use a trap that succeeds the first time and throws the second: let n=0; getPrototypeOf(){ if(n++) throw new Error(); return {}; }. Now allPropertyKeys succeeds.
  3. Loop iteration for some key k: current_object = obj. obj->getOwnPropertyDescriptor(k) → false (own property not found). Enter loop body.
  4. obj->getPrototype(globalObject) returns the Proxy (no throw — ordinary [[GetPrototypeOf]] on a plain object). current_object = proxy.
  5. Next iteration: proxy->getOwnPropertyDescriptor(k) → no trap defined, forwards to target {}, returns false. Enter loop body.
  6. proxy->getPrototype(globalObject) → invokes getPrototypeOf trap → throws → returns JSValue() (empty).
  7. JSValue().getObject(): isCell() checks (u.asInt64 & NotCellMask) == 00 & mask == 0 → true. asCell() returns reinterpret_cast<JSCell*>(0). nullptr->getObject() reads this->structure()->typeInfo()null deref.

This is pre-existing — the PR doesn't touch napi.cpp, add callers to it, or change its behavior — so it shouldn't block merge. Flagging because it's the exact same root cause and the author may want to sweep it in the same pass.

@robobun

robobun commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29642, which already covers this crash with additional fixes (napi.cpp getPrototype exception handling and GC safety).

@robobun robobun closed this Apr 24, 2026
@robobun robobun deleted the farm/6d05dbf3/fix-foreachproperty-proxy-exception branch April 24, 2026 03:49
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