Skip to content

Fix null deref in forEachProperty when Proxy getPrototype throws#30457

Open
robobun wants to merge 3 commits into
mainfrom
farm/8b83d80e/fix-foreachproperty-proxy-getprototype
Open

Fix null deref in forEachProperty when Proxy getPrototype throws#30457
robobun wants to merge 3 commits into
mainfrom
farm/8b83d80e/fix-foreachproperty-proxy-getprototype

Conversation

@robobun

@robobun robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Found by Fuzzilli. Fingerprint: f363b9d446e2156f

What

JSC__JSValue__forEachPropertyImpl walks the prototype chain to enumerate properties for Bun.inspect / console.log formatting. At the end of each iteration it advances via:

iterating = iterating->getPrototype(globalObject).getObject();

When iterating is a ProxyObject, getPrototype() can throw (via a getPrototypeOf trap) or return early due to an already-pending exception, returning an empty JSValue. An empty JSValue reports isCell() == true but asCell() == nullptr, so .getObject() dereferences a null pointer.

The fast path in the same function already guards against this (checking if (JSValue proto = ...) and clearing the exception). This applies the same guard to the slow path.

Repro

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

Also reproduces with an empty Proxy handler wrapping a native prototype that has throwing accessors (e.g. expect() instances).

When walking the prototype chain during property enumeration for
formatting, getPrototype() on a ProxyObject can throw (via a
getPrototypeOf trap) or return early due to a pending exception,
returning an empty JSValue. Calling .getObject() on an empty JSValue
dereferences a null JSCell*.

Guard the result of getPrototype() the same way the fast path already
does: clear any pending exception and treat an empty result as the end
of the chain.
@robobun

robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:10 PM PT - May 10th, 2026

@robobun, your commit 87ac0b8 has 1 failures in Build #53150 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30457

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

bun-30457 --bun

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack
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: c16c82e2-506d-4ee4-93ab-a6b362738fb1

📥 Commits

Reviewing files that changed from the base of the PR and between 0931891 and b03795d.

📒 Files selected for processing (1)
  • src/jsc/bindings/bindings.cpp

Walkthrough

Hardens prototype-chain iteration in JSC__JSValue__forEachPropertyImpl by capturing getPrototype results in a JSValue, clearing exceptions from proxy getPrototypeOf traps, and safely handling null prototypes. Includes regression tests verifying Bun.inspect does not crash with proxy-based prototypes.

Changes

Proxy Exception Safety

Layer / File(s) Summary
Core Implementation
src/jsc/bindings/bindings.cpp
Prototype-chain iteration now stores getPropertySlot results before clearing proxy Get exceptions and captures getPrototype(globalObject) into a JSValue, clears proxy getPrototypeOf exceptions, and converts to an object only when the prototype value is non-empty.
Regression Tests
test/js/bun/util/inspect.test.js
New test suite ensures Bun.inspect does not throw when objects have prototypes involving Proxies, including ones with throwing getPrototypeOf traps and ones wrapping native prototypes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: preventing null dereference in forEachProperty when a Proxy's getPrototype method throws an exception.
Description check ✅ Passed The description comprehensively covers both required sections: it explains what the PR does (the null dereference bug and its fix) and how to verify it works (with reproduction code examples).
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.

@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 — mirrors the existing fast-path guard for the same crash, with regression tests.

Extended reasoning...

Overview

This PR fixes a null-pointer dereference in JSC__JSValue__forEachPropertyImpl (src/jsc/bindings/bindings.cpp), the property enumerator backing Bun.inspect / console.log. The slow-path prototype walk called .getObject() on the result of getPrototype() without checking for an empty JSValue, which occurs when a Proxy getPrototypeOf trap throws. The fix stores the result in a local, clears the pending exception, and null-checks before dereferencing — exactly the pattern already used by the fast path of the same function at lines 5327–5337. Two regression tests are added to test/js/bun/util/inspect.test.js.

Security risks

None introduced. This is a strict hardening: it turns a segfault into graceful loop termination. Exceptions are intentionally swallowed here (consistent with every other CLEAR_IF_EXCEPTION in this function, which is best-effort formatting code). No new attack surface, no behavior change beyond "don't crash".

Level of scrutiny

Low. The diff is 4 lines of C++ that copy an existing, adjacent idiom verbatim. The test additions are straightforward "does not crash" assertions. There are no design decisions, no API changes, and the function's contract (best-effort, swallow user-code exceptions during inspection) is preserved.

Other factors

  • The one inline comment from the bug-hunting system is explicitly pre-existing (same pattern in napi.cpp), not introduced or affected by this PR — it's a follow-up suggestion, not a blocker.
  • No CODEOWNERS for this path.
  • Found by Fuzzilli with a clear repro; the tests directly encode that repro.

Comment on lines +5447 to +5450
JSValue proto = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" trap.
CLEAR_IF_EXCEPTION(scope);
iterating = proto ? proto.getObject() : nullptr;

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: the same vulnerable pattern this PR fixes still exists at src/jsc/bindings/napi.cpp:1837 (current_object->getPrototype(globalObject).getObject()), reachable via napi_get_all_property_names with napi_key_include_prototypes when the chain contains a Proxy with a throwing getPrototypeOf or getOwnPropertyDescriptor trap. This is pre-existing and not touched by this PR — just flagging since it's the identical root cause and may be worth the same one-line guard as a follow-up.

Extended reasoning...

Summary

This PR correctly fixes a null deref in JSC__JSValue__forEachPropertyImpl where iterating->getPrototype(globalObject).getObject() is called on a potentially-empty JSValue. However, the identical pattern remains unfixed at src/jsc/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 or empty-JSValue guard between the getOwnPropertyDescriptor call and the .getObject() call on the next line.

How it manifests

As the PR description explains, when getPrototype() is called on a ProxyObject and the getPrototypeOf trap throws (or there is already a pending exception, causing ProxyObject::performGetPrototype to early-return {} via RETURN_IF_EXCEPTION), it returns an empty JSValue. An empty JSValue reports isCell() == true but asCell() == nullptr, so .getObject() calls JSCell::getObject() on nullptr and segfaults — exactly the crash this PR fixes in bindings.cpp.

Why the existing guard at line 1823 doesn't prevent it

allPropertyKeys() at line 1818 also walks the prototype chain, and NAPI_RETURN_IF_EXCEPTION(env) at line 1823 would catch an unconditionally-throwing getPrototypeOf trap on that first walk. However, the descriptor-filtering loop at lines 1833–1842 walks the chain again per key, and is reachable past line 1823 in at least two ways:

  1. Throwing getOwnPropertyDescriptor trap. Line 1836 calls getOwnPropertyDescriptor on the Proxy. If that trap throws, it returns false (loop body entered) and leaves a pending exception. Line 1837 then calls getPrototype() on the same Proxy with the exception pending → empty JSValue → null deref. The ownKeys/getPrototypeOf traps used by allPropertyKeys need not throw for this path.
  2. Stateful getPrototypeOf trap. A trap that succeeds during allPropertyKeys but throws on a subsequent invocation (e.g. throws on the Nth call) reaches line 1837 directly.

Step-by-step proof (path 1)

  1. Native addon calls napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_writable, ...) where obj's prototype is new Proxy(target, { getOwnPropertyDescriptor() { throw new Error('x') } }).
  2. Line 1818: allPropertyKeys walks the chain via getPrototype/ownKeys — no getOwnPropertyDescriptor trap invoked, no throw. Line 1823 passes.
  3. Line 1827: key_filter & filter_by_any_descriptor is truthy (napi_key_writable), so we enter the per-key filter loop.
  4. For some key, line 1836 calls getOwnPropertyDescriptor on obj → not own → loop iterates, current_object advances to the Proxy.
  5. Line 1836 (next iteration): getOwnPropertyDescriptor on the Proxy invokes the trap → throws → returns false, pending exception set.
  6. Line 1837: current_object->getPrototype(globalObject) on the Proxy with a pending exception returns JSValue{}.
  7. .getObject() on the empty value: isCell() → true, asCell()nullptr, nullptr->getObject()segfault.

Impact

A native addon calling napi_get_all_property_names with napi_key_include_prototypes and any of napi_key_enumerable | napi_key_writable | napi_key_configurable on user-supplied objects can be crashed by a hostile Proxy in the prototype chain. Same severity class as the bug this PR fixes (process crash from JS-observable input), just gated behind N-API rather than Bun.inspect.

Suggested fix

Apply the same guard the PR uses in bindings.cpp:

JSValue protoVal = current_object->getPrototype(globalObject);
NAPI_RETURN_IF_EXCEPTION(env); // or CLEAR_IF_EXCEPTION + break
JSObject* proto = protoVal ? protoVal.getObject() : nullptr;

(and ideally also check for an exception after getOwnPropertyDescriptor at line 1836).

Severity

Pre-existing. This PR does not touch napi.cpp, add callers to it, or change its behavior in any way. Flagging only because it is the identical root cause and the fix is the same one-liner — fine to defer to a follow-up.

@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 - Same fix: guards getPrototype() result and clears exceptions in forEachPropertyImpl to prevent crash when Proxy prototype throws
  2. inspect: handle throwing Proxy getPrototypeOf in forEachProperty #29814 - Same fix: clears exceptions and guards empty getPrototype() return in forEachPropertyImpl
  3. inspect: clear exceptions when walking Proxy prototype chain #29845 - Same fix: clears exception before continue and guards getPrototype() result in forEachPropertyImpl
  4. fix(inspect): handle Proxy trap exceptions when walking prototype chain #30099 - Same fix: handles Proxy trap exceptions and guards getPrototype() empty return in forEachPropertyImpl
  5. Fix null deref in Bun.inspect when Proxy prototype throws #30200 - Same fix: guards getPrototype() empty return and clears leaked exception in forEachPropertyImpl
  6. getPrototype exception checks #24985 - Adds exception handling around getPrototype calls in bindings.cpp, including in forEachPropertyImpl
  7. Fix null JSCell deref in Bun lazy property callbacks and forEachProperty #29671 - Includes the same forEachPropertyImpl fix plus additional lazy property callback null deref fixes
  8. Fix null JSCell derefs in Bun object lazy init and Bun.inspect with Proxy prototypes #30245 - Includes the same forEachPropertyImpl fix plus additional Bun object lazy init null deref fixes

🤖 Generated with Claude Code

When getPropertySlot returns false with a pending exception (Proxy
trap throws), the continue skipped CLEAR_IF_EXCEPTION, leaving the
exception pending into the next getPropertySlot call and tripping
JSC's exception-scope verification.

@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 — mirrors the existing fast-path guard in the same function, with regression tests.

Extended reasoning...

Overview

Two small edits to JSC__JSValue__forEachPropertyImpl in src/jsc/bindings/bindings.cpp plus two regression tests in test/js/bun/util/inspect.test.js. The first hunk reorders CLEAR_IF_EXCEPTION to run before continue so a throwing getPropertySlot doesn't leak a pending exception into the next iteration. The second hunk captures getPrototype() into a local JSValue, clears any Proxy-trap exception, and only calls .getObject() when the value is non-empty — preventing the null deref that Fuzzilli found.

Security risks

None introduced. This strictly hardens an existing code path against a JS-triggerable segfault (DoS-class). No new attack surface, no auth/crypto/permissions involvement.

Level of scrutiny

Low. The slow-path fix is a copy of the guard already present in the fast path of the same function (lines ~5327–5337: if (JSValue proto = ...) + CLEAR_IF_EXCEPTION). When proto is empty, iterating becomes nullptr and the while condition exits cleanly; when proto is a non-object (e.g. null), getObject() returns nullptr and the loop also exits — identical to prior semantics minus the crash. The getPropertySlot reorder is a pure defensive fix with no behavioral change in the non-exception case.

Other factors

Regression tests cover both repros from the PR description. The duplicate-PR bot lists 8 other PRs proposing the identical fix, which corroborates that this is the canonical patch for this fuzzer fingerprint. My earlier inline note about napi.cpp:1837 is pre-existing and explicitly out of scope for this PR. No CODEOWNERS apply to these paths.

@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 — mirrors the existing fast-path guard in the same function, with regression tests.

Extended reasoning...

Overview

Two small defensive changes in JSC__JSValue__forEachPropertyImpl (src/jsc/bindings/bindings.cpp) plus two regression tests in test/js/bun/util/inspect.test.js:

  1. Reorder CLEAR_IF_EXCEPTION(scope) to run before the continue when getPropertySlot returns false, so a throwing Proxy get trap doesn't leave a pending exception that poisons the subsequent getPrototype() call.
  2. Capture iterating->getPrototype(globalObject) into a JSValue, clear any getPrototypeOf-trap exception, and only call .getObject() when the value is non-empty — preventing the null deref on an empty JSValue.

Both changes exactly mirror the pre-existing fast-path handling at lines ~5327–5337 of the same function.

Security risks

None introduced. This is a robustness fix that turns a JS-triggerable segfault (DoS) into a graceful no-op during property enumeration for Bun.inspect/console.log. No new attack surface, no auth/crypto/permissions involved.

Level of scrutiny

Low-to-medium. The change is ~7 lines of native C++ in a hot inspection path, but it's purely defensive, follows the established pattern already used a few lines above in the same function, and is covered by two direct regression tests. The Fuzzilli-found crash and repro are well-characterized in the PR description.

Other factors

  • Bug-hunting system found no issues in the diff itself.
  • My earlier inline note about napi.cpp:1837 is a pre-existing sibling issue explicitly flagged as fine for a follow-up; it doesn't block this PR.
  • CI failures (musl build, bun-install-registry, s3-storage-class, http2/abort flakes) are unrelated to this change.
  • The duplicate-PR bot lists several prior attempts at the same fix, which is a repo workflow matter rather than a correctness concern here.

@robobun

robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures are pre-existing flakes unrelated to this change:

  • test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts — Windows timeout, also failing on builds #53149, #53145, #53143, #53142
  • test/js/web/fetch/fetch-http2-client.test.tsAtomStringImpl::remove assertion under ASAN, also failing on builds #53149, #53145, #53142
  • bun-install-registry.test.ts, jsc-stress.test.ts — auto-retried warnings

test/js/bun/util/inspect.test.js (the file this PR touches) passes on all platforms. This PR only modifies JSC__JSValue__forEachPropertyImpl in bindings.cpp — none of the failing tests exercise that path.

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