Skip to content

inspect: handle throwing Proxy getPrototypeOf in forEachProperty - #29814

Closed
robobun wants to merge 3 commits into
mainfrom
farm/ab5195e2/inspect-proxy-getprototype-null-deref
Closed

inspect: handle throwing Proxy getPrototypeOf in forEachProperty#29814
robobun wants to merge 3 commits into
mainfrom
farm/ab5195e2/inspect-proxy-getprototype-null-deref

Conversation

@robobun

@robobun robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Found by the fuzzer. Fingerprint: f20677b52d4735c2

What

JSC__JSValue__forEachPropertyImpl (used by Bun.inspect/console.log to enumerate properties) walks the prototype chain with:

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

When iterating is a Proxy and either its getPrototypeOf trap throws, or a pending exception exists from the property loop above (a Proxy get trap can cause getPropertySlot to return false with a pending exception, which was only cleared on the true path), getPrototype() returns an empty JSValue. JSValue::getObject() on an empty value calls asCell()->getObject() where asCell() is nullptr, triggering a null-pointer member call.

Repro

const obj = { a: 1 };
const proto = new Proxy({ b: 2 }, {
  getPrototypeOf() { throw new Error("nope"); }
});
Object.setPrototypeOf(obj, proto);
Bun.inspect(obj);

Fix

  • Clear exceptions after getPropertySlot regardless of return value, so pending exceptions don't leak into later iterations.
  • After getPrototype, clear exceptions and break if the result is empty before calling getObject(). This matches how the fast path in the same function already handles getPrototype.

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 PM PT - May 4th, 2026

@robobun, your commit f994d1f has 2 failures in Build #51224 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29814

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

bun-29814 --bun

@coderabbitai

coderabbitai Bot commented Apr 28, 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: f8aa4b35-4db8-4c7c-a3fc-67a47c5663b0

📥 Commits

Reviewing files that changed from the base of the PR and between 17f626a and 7e297bf.

📒 Files selected for processing (1)
  • test/js/bun/util/inspect.test.js

Walkthrough

Adjusts property enumeration to preserve control flow around exception-prone proxy operations by explicitly capturing and clearing prototype and property-lookup results. Adds tests ensuring Bun.inspect does not throw when encountering proxy getPrototypeOf traps or throwing getters during enumeration.

Changes

Cohort / File(s) Summary
Property Enumeration Exception Handling
src/bun.js/bindings/bindings.cpp
Refactored JSC__JSValue__forEachPropertyImpl to record hasProperty, capture getPrototype into a JSValue, explicitly CLEAR_IF_EXCEPTION before using results, and set iterating from the prototype object to avoid proxy trap or leftover-exception disruptions.
Proxy Prototype Safety Tests
test/js/bun/util/inspect.test.js
Added two crash-safety tests: one where a prototype Proxy throws from getPrototypeOf, and one where a prototype Proxy's target has a throwing getter; both assert Bun.inspect completes and that the getter runs where applicable.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: handling a throwing Proxy getPrototypeOf in the forEachProperty implementation used by inspect.
Description check ✅ Passed The description thoroughly covers both required sections: what the PR does (null-dereference fix with detailed explanation) and how it was verified (fuzzer-found issue with concrete repro code 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.

@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 483-495: The test "inspecting object with Proxy prototype that
leaves a pending exception during enumeration doesn't crash" currently relies on
a throwing getter (target.second) which Bun.inspect is known not to call; change
it to use a Proxy trap that is actually consulted during property enumeration
(e.g., ownKeys or getOwnPropertyDescriptor) so the exception path is exercised.
Replace Object.setPrototypeOf(obj, new Proxy(target, {})) with a proxy whose
ownKeys or getOwnPropertyDescriptor throws, set a local flag or spy to verify
the trap was invoked, and assert that calling Bun.inspect(obj) does not throw
while also asserting the trap was hit. Ensure references to Bun.inspect, the
test name, and the Proxy trap (ownKeys/getOwnPropertyDescriptor) are updated
accordingly.
🪄 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: 177758a9-4120-42bb-b5ff-3d05a1932a79

📥 Commits

Reviewing files that changed from the base of the PR and between ea43009 and 17f626a.

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

Comment thread test/js/bun/util/inspect.test.js
@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 two bugs in forEachPropertyImpl: the CLEAR_IF_EXCEPTION/getPropertySlot reorder and the getPrototype() null guard, plus an additional fix in napi.cpp
  2. Fix null JSCell deref in Bun lazy property callbacks and forEachProperty #29671 - Partially overlaps: fixes the same getPrototype() null dereference in forEachPropertyImpl, but does not address the getPropertySlot exception-leak fix

🤖 Generated with Claude Code

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author

Related earlier attempts (both mine, both with stale/red CI):

This PR is the minimal, targeted version of the forEachPropertyImpl fix against current main. Happy to close whichever is redundant once one lands.

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures on build 48468 are all unrelated to this change — every failing test is in the experimental HTTP/2 fetch client that landed yesterday (#29766):

Shard Failing file Symptom
debian-13-x64-asan fetch-http2-client.test.ts all 20 tests timed out at 90s → job hit 45min wall clock (exit -1)
windows-2019-x64 fetch-http2-leak.test.ts, test-tonic.test.ts h2 watchdog "stuck at batch N/200"; gRPC timeout
windows-2019-x64-baseline fetch-http2-leak.test.ts h2 watchdog "stuck at batch N/200"
windows-11-aarch64 fetch-http2-leak.test.ts h2 watchdog "stuck at batch N/200"

Same ASAN shard is red on #29812, #29804, #29795 right now. #29809 and #29812 are hardening the h2 client.

All 12 other test-bun shards passed (darwin, debian, ubuntu, alpine — x64 and aarch64). This PR only touches JSC__JSValue__forEachPropertyImpl (property enumeration for Bun.inspect/console.log) and adds tests to inspect.test.js; no interaction with the HTTP/2 fetch path.

@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 — targeted null-deref fix with regression tests for both trigger paths.

Extended reasoning...

Overview

This PR fixes a fuzzer-found null-pointer dereference in JSC__JSValue__forEachPropertyImpl (src/bun.js/bindings/bindings.cpp), the property enumerator behind Bun.inspect/console.log. Two tightly-scoped changes:

  1. Reorder CLEAR_IF_EXCEPTION(scope) to run before the if (!hasProperty) continue; check, so a pending exception from a Proxy get trap (which can cause getPropertySlot to return false) is cleared rather than leaking into the next iteration.
  2. Capture iterating->getPrototype(globalObject) into a local, clear any exception (Proxy getPrototypeOf trap may throw), and break if the result is empty before calling .getObject() — avoiding asCell() on an empty JSValue.

Two regression tests are added to test/js/bun/util/inspect.test.js covering both trigger paths; the second test includes a getterCalled assertion (added in 7e297bf) so it self-verifies that the slow path is actually exercised.

Security risks

None. The change is purely defensive — adding an exception-clear and an empty-value guard. No new user-controlled data flows, no auth/crypto/permissions surface. The worst-case behavioral change is that Bun.inspect now stops walking the prototype chain (instead of crashing) when a hostile Proxy trap throws, which is the intended and pre-existing design of this function (it already swallows exceptions throughout).

Level of scrutiny

Low-to-moderate. This is ~10 lines of C++ in a non-hot-path slow branch of the inspector, strictly narrowing crash conditions. The no-exception path is byte-for-byte equivalent to the old code. The pattern matches how the fast path in the same function already handles getPrototype, and the surrounding code already uses CLEAR_IF_EXCEPTION/tryClearException liberally to keep Bun.inspect non-throwing.

Other factors

  • The one CodeRabbit concern (whether the second test exercises the intended path) was addressed and acknowledged as resolved.
  • CI failures on build #48468 are documented as unrelated HTTP/2 fetch-client flakes affecting multiple PRs on the same shards; 12/16 test-bun shards passed.
  • Related PRs #29642/#29671 overlap but this is the minimal, current-main version; the author has flagged willingness to close redundant ones.
  • No bugs were found by the bug-hunting system.

@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 — defensive null-check matching the existing fast-path pattern in the same function.

Extended reasoning...

Overview

This PR fixes a null-pointer dereference in JSC__JSValue__forEachPropertyImpl (src/bun.js/bindings/bindings.cpp), the property-enumeration helper backing Bun.inspect/console.log. Two small changes: (1) move CLEAR_IF_EXCEPTION to run regardless of getPropertySlot's return value so a pending exception from a Proxy get trap doesn't leak into the next iteration, and (2) capture getPrototype() into a local, clear any exception, and break if empty before calling .getObject(). Two regression tests are added to test/js/bun/util/inspect.test.js.

Security risks

None. The change is purely defensive — it adds an exception-clear and an empty-value guard. Bun.inspect is output formatting; the only effect is that adversarial Proxy traps in the prototype chain now terminate iteration cleanly instead of crashing. No new code paths, no user input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-moderate. The diff is ~10 lines of C++ that mirror the existing fast-path handling at lines 5243–5253 of the same function (if (JSValue proto = ...getPrototype(...)) + CLEAR_IF_EXCEPTION). The reorder of CLEAR_IF_EXCEPTION before continue is semantically obvious — it just ensures the clear happens on both branches. The if (!proto) break; guard is the standard JSC idiom for handling empty JSValue from a throwing internal method.

Other factors

  • Fuzzer-found with a concrete repro and fingerprint.
  • Two regression tests cover both the getPrototypeOf-throws case and the pending-exception-from-getter case; the second test was hardened with a getterCalled assertion after CodeRabbit feedback (now resolved).
  • Bug-hunting system found no issues.
  • CI failures on build 48468 are documented as unrelated HTTP/2 client flakes (fetch-http2-*.test.ts) also red on contemporaneous PRs; all 12 other shards passed.
  • The author noted overlap with #29642/#29671; this is the minimal targeted version against current main.

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

Build 49024 (after merging latest main): debian-13-x64-asan-test-bun — 18/20 parallel shards passed, 2 failed with pre-existing issues unrelated to this change:

Shard 019dd7c9-8f02: test/js/workerd/html-rewriter-leak.test.ts — "HTMLRewriter does not leak element/document handler allocations" fails 4x under ASAN. This test was just added to main in #29879.

Shard 019dd7c9-8f03: test-stream-readable-to-web.js and test-worker-nested-uncaught.js crash with:

panic: EventLoop.enqueueTaskConcurrent: VM has terminated

(src/bun.js/event_loop.zig:644) — pre-existing worker-teardown race.

Current main (8d2674a, a2ef6a8, 306b381) is also red. This PR only touches JSC__JSValue__forEachPropertyImpl property enumeration; no interaction with event loop, workers, streams, or HTMLRewriter.

@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 — targeted null-deref fix that mirrors the fast-path's existing getPrototype guard, with regression tests for both crash paths.

Extended reasoning...

Overview

This PR fixes a fuzzer-found null-pointer dereference in JSC__JSValue__forEachPropertyImpl (src/bun.js/bindings/bindings.cpp), the property enumerator used by Bun.inspect/console.log. Two changes: (1) reorder CLEAR_IF_EXCEPTION to run regardless of getPropertySlot's return value so a Proxy get trap exception doesn't leak into the next iteration, and (2) capture getPrototype() into a local, clear any exception, and break on empty before calling .getObject() — preventing the asCell()->getObject() null deref when a Proxy getPrototypeOf trap throws. Two regression tests are added to test/js/bun/util/inspect.test.js.

Security risks

None. This is a crash-hardening change in a read-only inspection path. It does not touch auth, crypto, permissions, or any trust boundary. If anything, it reduces a denial-of-service vector (adversarial Proxy crashing the process via console.log).

Level of scrutiny

Low-to-moderate. The diff is ~10 lines of mechanical change inside a single function. The new getPrototype guard exactly mirrors the pattern already used twice in the same function's fast path (lines 5166 and 5243: if (JSValue proto = ...->getPrototype(...))). The getPropertySlot reorder is a trivial control-flow fix — the CLEAR_IF_EXCEPTION was already there, just unreachable when hasProperty was false. There is no behavior change for non-throwing inputs.

Other factors

  • The CodeRabbit concern about the second test not exercising the getter path was addressed in 7e297bf with a getterCalled assertion that makes the test self-verifying; CodeRabbit retracted the comment.
  • CI failures on builds 48468 and 49024 are all in unrelated subsystems (HTTP/2 fetch client, html-rewriter-leak, worker-nested-uncaught) that are also red on current main per the author's analysis; none touch property enumeration or Bun.inspect.
  • The author noted overlap with #29642/#29671; this PR is the minimal targeted version of the forEachPropertyImpl portion.
  • No CODEOWNERS entry covers this file.

@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 — targeted null-deref fix that mirrors the existing fast-path getPrototype guard in the same function, with self-verifying regression tests.

Extended reasoning...

Overview

This PR fixes a fuzzer-found null-pointer dereference in JSC__JSValue__forEachPropertyImpl (src/bun.js/bindings/bindings.cpp), the property-enumeration routine behind Bun.inspect/console.log. Two changes: (1) clear exceptions after getPropertySlot regardless of return value (previously only cleared on the true path, so a Proxy get trap that throws and returns false would leak a pending exception into the next iteration), and (2) capture getPrototype() into a local, clear exceptions, and break on empty before calling .getObject() (previously iterating->getPrototype(...).getObject() would null-deref when the trap threw). Two regression tests are added to test/js/bun/util/inspect.test.js.

Security risks

None. This is a defensive guard that converts a crash into a graceful loop termination. No data exposure, auth, or permission surface is touched. The exceptions being swallowed are intentional — Bun.inspect is best-effort and already clears exceptions throughout this function.

Level of scrutiny

Low-to-moderate. While bindings.cpp is a core file, the change is ~10 lines confined to one function and is a near-verbatim copy of the pattern already used in the fast path of the same function (lines ~5243–5253: if (JSValue proto = ...) followed by CLEAR_IF_EXCEPTION(scope)). The getPropertySlot reorder is a pure statement reordering that preserves semantics on the success path and only adds exception clearing on the failure path. Non-exceptional behavior is unchanged.

Other factors

  • The bug hunting system found no issues.
  • The one CodeRabbit concern (whether the second test exercises the intended path) was addressed with a getterCalled assertion that makes the test self-verifying; CodeRabbit acknowledged and resolved the thread.
  • CI failures are documented in detail by the author as pre-existing flakes on main (HTTP/2 fetch client, HTMLRewriter leak, worker-teardown race) with no overlap with property enumeration.
  • The author noted overlapping PRs #29642/#29671 and clarified this is the minimal targeted version; that's a process question for whichever lands first, not a correctness concern with this diff.
  • No CODEOWNERS entry covers this path.

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

Build 49062 (fc170cdb) — all failures are unrelated infra/network flakes:

Shard Cause
windows-2019-x64 bun-create.test.ts — "GitHub returned 403" rate-limit
windows-11-aarch64 bun-create.test.ts — GitHub 403; dev-and-prod-12 HMR 15s timeout
darwin-14-x64 s3 - Storage class > should work with writer + options on big file
darwin-* (5 shards) "Expired" — jobs timed out in queue, never ran

All Linux shards (debian, ubuntu, alpine — x64/aarch64/baseline) and all 20 ASAN shards passed. inspect.test.js passes on every shard that ran it. This PR only touches JSC__JSValue__forEachPropertyImpl.

robobun added 2 commits May 4, 2026 10:29
When walking the prototype chain during property enumeration for
Bun.inspect/console.log, getPrototype() on a Proxy can return an empty
JSValue if the getPrototypeOf trap throws or if a pending exception
exists from a prior getPropertySlot call that returned false. Calling
.getObject() on an empty JSValue invokes a member function on a null
JSCell pointer.

Clear pending exceptions after getPropertySlot regardless of its return
value, and clear exceptions + check for an empty result after
getPrototype before dereferencing.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/ab5195e2/inspect-proxy-getprototype-null-deref branch from fc170cd to 5159905 Compare May 4, 2026 10:30

@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 — minimal defensive null-guard that mirrors the fast-path's existing getPrototype/CLEAR_IF_EXCEPTION pattern.

Extended reasoning...

Overview

This PR fixes a fuzzer-found null-deref in JSC__JSValue__forEachPropertyImpl (the property enumerator behind Bun.inspect/console.log). Two changes in src/jsc/bindings/bindings.cpp:

  1. Reorder CLEAR_IF_EXCEPTION(scope) to run before the if (!hasProperty) continue; so a Proxy get trap that throws and causes getPropertySlot to return false doesn't leak a pending exception into the next iteration.
  2. Capture iterating->getPrototype(globalObject) into a local, clear exceptions, and break on an empty result before calling .getObject() — avoiding the asCell() null deref when a Proxy getPrototypeOf trap throws.

Two crash-safety tests are added to test/js/bun/util/inspect.test.js, the second with a self-verifying getterCalled assertion so it fails loudly if the slow path ever stops exercising the getter.

Security risks

None. This is purely defensive exception/null handling inside the inspect formatter; no auth, crypto, permissions, or user-input parsing is involved.

Level of scrutiny

Low. The diff is ~10 lines of logic and is a direct mirror of the fast-path handling already present in the same function (lines ~5327–5337: if (JSValue proto = prototypeObject.getPrototype(...)) followed by CLEAR_IF_EXCEPTION). On the no-exception happy path the new code is semantically identical to the old code, so there is no behavior change for non-Proxy objects — only the crash on hostile Proxy traps is eliminated.

Other factors

  • Bug-hunting system found no issues.
  • CodeRabbit raised one concern (test not exercising the intended path) which was rebutted by the author and retracted; the thread is resolved and a getterCalled assertion was added in 7e297bf to make the test self-verifying.
  • No CODEOWNERS rules cover these files.
  • The author has documented that all CI failures across builds 48468/49024/49062 are pre-existing flakes (HTTP/2 client, HTMLRewriter leak, worker-teardown race, GitHub 403 rate limits) unrelated to property enumeration; inspect.test.js passes on every shard that ran it.
  • Overlapping PRs #29642/#29671 exist; the author has acknowledged this and positioned this PR as the minimal targeted version. Which one to land is a maintainer call, but it doesn't affect the correctness of this change.

@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 — small defensive null-guard that mirrors the existing fast-path getPrototype handling in the same function.

Extended reasoning...

Overview

This PR fixes a fuzzer-found null-pointer dereference in JSC__JSValue__forEachPropertyImpl (src/jsc/bindings/bindings.cpp), the property-enumeration routine behind Bun.inspect/console.log. Two changes: (1) move CLEAR_IF_EXCEPTION after getPropertySlot to fire regardless of return value so a throwing Proxy get trap doesn't leave a pending exception, and (2) capture iterating->getPrototype() into a local JSValue, clear exceptions, and break on empty before calling .getObject(). Two regression tests are added to test/js/bun/util/inspect.test.js covering both the throwing-getPrototypeOf-trap case and the pending-exception-during-enumeration case (the latter with a self-verifying getterCalled assertion added in response to CodeRabbit feedback, which is now resolved).

Security risks

None. The change is purely defensive — adding a null check and an exception-clear in a code path that previously dereferenced null. It does not introduce new data flows, parse untrusted input differently, or change any auth/permission logic. If anything it removes a DoS vector (user-controlled object passed to console.log could crash the process).

Level of scrutiny

Low-to-moderate. While bindings.cpp is core JSC glue, the diff is ~10 lines and is a direct copy of the pattern already used by the fast path in the same function (lines 5327–5337: if (JSValue proto = ...getPrototype(...)) { ... } CLEAR_IF_EXCEPTION(scope);). In the non-exception case behavior is identical to before (proto.getObject() returns the same pointer the old chained call would have); in the exception case the code now cleanly exits the prototype walk instead of invoking UB. There are no design decisions to weigh here.

Other factors

  • The single bug-hunter finding is explicitly tagged pre-existing / non-blocking — it points at the same pattern in napi.cpp, which this PR does not touch, and the author has already noted that sibling PR #29642 covers it.
  • CodeRabbit's one concern (test not exercising the intended path) was addressed in 7e297bf with a getterCalled assertion and the thread is resolved.
  • The author triaged four CI runs (48468, 49024, 49062, 51059); all failures are unrelated infra/flake issues (HTTP/2 client tests from #29766, HTMLRewriter leak test, worker-teardown race, GitHub 403s, queue-expired darwin jobs). All Linux and all 20 ASAN shards passed on build 49062, and inspect.test.js passes on every shard that ran it.
  • No CODEOWNERS apply to the touched files.

Comment on lines +5448 to +5453
JSValue proto = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" traps or leftover exceptions from the loop above.
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 / non-blocking: the identical unguarded getPrototype(globalObject).getObject() pattern this PR fixes also exists at src/jsc/bindings/napi.cpp:1837 inside napi_get_all_property_names — a Proxy with a throwing getPrototypeOf/getOwnPropertyDescriptor trap in the prototype chain triggers the same null-member-call there. Sibling PR #29642 bundled that napi.cpp fix; flagging so it isn't lost if #29642 is closed in favor of this minimal PR.

Extended reasoning...

What

This PR correctly hardens JSC__JSValue__forEachPropertyImpl by capturing the result of iterating->getPrototype(globalObject), clearing exceptions, and breaking on an empty JSValue before calling .getObject(). The exact same vulnerable pattern remains at src/jsc/bindings/napi.cpp:1837 in napi_get_all_property_names:

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

There is no CLEAR_IF_EXCEPTION / RETURN_IF_EXCEPTION between the getOwnPropertyDescriptor call (which can throw via a Proxy getOwnPropertyDescriptor trap) and the getPrototype() call (which can throw via a Proxy getPrototypeOf trap), and no empty-JSValue guard before .getObject(). The if (!proto) check on the next line is too late — the null member call has already happened inside .getObject().

Why existing code doesn't prevent it

The only exception check in this region is NAPI_RETURN_IF_EXCEPTION(env) at line 1823, which runs before the filtering loop. Inside the loop, when current_object is a Proxy:

  • getOwnPropertyDescriptor invokes the Proxy's getOwnPropertyDescriptor trap; if it throws, the call returns false with a pending exception, so the while body executes.
  • getPrototype then either short-circuits on the pending exception or invokes the Proxy's getPrototypeOf trap (which can also throw), and in either case returns an empty JSValue.
  • On JSVALUE64 an empty JSValue is encoded as 0, so isCell() is true (0 & NotCellMask == 0) and asCell() is nullptr. JSValue::getObject() therefore calls asCell()->getObject() on a null cell — the same UBSAN null-pointer-member-call this PR fixes in bindings.cpp.

Step-by-step proof

  1. Native addon calls napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_enumerable, napi_key_numbers_to_strings, &result) where obj's prototype chain contains new Proxy({}, { getPrototypeOf() { throw 0 } }).
  2. key_filter & filter_by_any_descriptor is non-zero (line 1827) → enters the per-key filtering loop.
  3. key_mode == napi_key_include_prototypes → enters the prototype-climb while at line 1836.
  4. The walk reaches the Proxy as current_object. getOwnPropertyDescriptor returns false (key not on the Proxy target) → loop body runs.
  5. current_object->getPrototype(globalObject) invokes the Proxy getPrototypeOf trap, which throws → returns empty JSValue.
  6. .getObject() on the empty value: isCell() → true, asCell()nullptr, nullptr->getObject() reads m_type → null-pointer member call (same fingerprint f20677b52d4735c2 crash class).

The same outcome occurs if the Proxy instead defines a throwing getOwnPropertyDescriptor trap: the trap throws, getOwnPropertyDescriptor returns false with a pending exception, and getPrototype() returns empty because of the pending exception.

Impact & fix

Impact is narrower than the Bun.inspect case (requires a native addon calling napi_get_all_property_names with napi_key_include_prototypes plus an enumerable/writable/configurable filter on user-controlled objects), but it is the same crash class. The fix is identical to what this PR applies in bindings.cpp: capture getPrototype() into a local JSValue, RETURN_/CLEAR_IF_EXCEPTION, and bail if it's empty before calling .getObject() (and ideally also check for an exception after getOwnPropertyDescriptor).

Scope

This is pre-existing — this PR does not touch napi.cpp, add callers to it, or change its trigger surface. The author explicitly noted in the timeline that #29642 bundled both the forEachPropertyImpl fix and the napi.cpp fix, and that this PR is the "minimal, targeted version" with "Happy to close whichever is redundant once one lands." Flagging only so the napi.cpp half isn't lost if #29642 is closed in favor of this PR. Should not block merge.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #30099, which fixes the same two problems in JSC__JSValue__forEachPropertyImpl:

  1. A Proxy get trap throwing inside getPropertySlot left the exception pending, because the continue preceded the CLEAR_IF_EXCEPTION.
  2. iterating->getPrototype(globalObject).getObject() dereferences null when a Proxy getPrototypeOf trap throws, since getPrototype returns an empty JSValue.

#29814, #29845, #30099, #30200, and #30457 all make the same two changes. #30099 additionally clears the exception in the fast-path prototype pre-scan, a third site the others miss, so it's the most complete of the set.

@robobun robobun closed this Jul 1, 2026
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