Skip to content

Fix null deref in forEachProperty when Proxy prototype getter throws - #30541

Closed
robobun wants to merge 1 commit into
mainfrom
farm/a6d9a57a/fix-foreach-property-proxy-exception
Closed

Fix null deref in forEachProperty when Proxy prototype getter throws#30541
robobun wants to merge 1 commit into
mainfrom
farm/a6d9a57a/fix-foreach-property-proxy-exception

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Fuzzer fingerprint: b8b8543bb6fc12e8

What

Bun.inspect / console.log crashed with a null-pointer deref when formatting an object whose prototype chain contains a Proxy and a getter reached through that Proxy throws.

Minimal repro:

const { expect } = Bun.jest(import.meta.path);
const e = expect(1);
Object.setPrototypeOf(e, new Proxy(Object.getPrototypeOf(e), {}));
console.log(e); // null deref in JSValue::getObject()

Why

The slow path of JSC__JSValue__forEachPropertyImpl walks the prototype chain using PropertySlot::InternalMethodType::Get, which means going through a Proxy actually invokes getters. The Expect prototype has stateful getters (.rejects then .resolves) where the second one throws.

When getPropertySlot throws it returns false, but the existing code did:

if (!object->getPropertySlot(globalObject, property, slot))
    continue;
// Ignore exceptions from "Get" proxy traps.
CLEAR_IF_EXCEPTION(scope);

The continue runs before the exception is cleared, so it stays pending for the rest of the loop. At the end of the loop body we do iterating->getPrototype(globalObject).getObject(); for a Proxy, getPrototype sees the pending exception and returns an empty JSValue, and .getObject() on an empty value dereferences a null JSCell*.

Fix

  • Clear the exception from getPropertySlot before the continue.
  • Guard the result of getPrototype and clear any exception from a throwing getPrototypeOf trap (matching what the fast path already does).

Added regression tests covering both a throwing getter and a throwing getPrototypeOf trap.

When inspecting an object whose prototype chain contains a Proxy, the
slow path in forEachPropertyImpl uses InternalMethodType::Get which
invokes getters through the Proxy. If a getter throws, getPropertySlot
returns false but the CLEAR_IF_EXCEPTION was placed after the early
continue, so the exception stayed pending. On the next prototype hop,
ProxyObject::getPrototype would observe the pending exception and
return an empty JSValue, and .getObject() on an empty value
dereferenced a null cell.

Clear the exception before the continue, and also guard the
getPrototype result so a throwing getPrototypeOf trap cannot crash the
formatter either.
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:16 AM PT - May 12th, 2026

@robobun, your commit 089403a has 1 failures in Build #53631 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30541

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

bun-30541 --bun

@alii

alii commented May 12, 2026

Copy link
Copy Markdown
Member

I am sure we already fixed this? @robobun ?

@coderabbitai

coderabbitai Bot commented May 12, 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: e93c8cd4-5ee8-4755-a298-e0eafdc47310

📥 Commits

Reviewing files that changed from the base of the PR and between 314ffe3 and 089403a.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/util/inspect-proxy-prototype.test.ts

Walkthrough

Exception handling in JSC property enumeration is refactored to capture property-slot lookup and prototype-chain access results into temporary variables, clear exceptions immediately after each operation, and then decide iteration flow. A new test suite validates that Bun.inspect handles Proxy traps that throw without crashing.

Changes

Proxy exception handling in property enumeration

Layer / File(s) Summary
Exception handling in property enumeration
src/jsc/bindings/bindings.cpp
JSC__JSValue__forEachPropertyImpl refactored to store getPropertySlot result into a hasSlot boolean and immediately clear exceptions before checking the result; prototype-chain advancement now stores getPrototype result into a temporary JSValue, clears exceptions, and then updates iterating to proto.getObject().
Proxy prototype chain inspection tests
test/js/bun/util/inspect-proxy-prototype.test.ts
New test suite with three cases validating that Bun.inspect does not crash when Proxy getters, getPrototypeOf traps, or get traps throw during prototype-chain iteration.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: addressing a null dereference in forEachProperty when a Proxy prototype getter throws.
Description check ✅ Passed The description provides comprehensive context with a detailed explanation of the problem, root cause analysis, and the fix applied, exceeding the template requirements.
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.

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

You're right — this is a dupe. The bug is still present on main (verified it reproduces against the current base commit), but there are several open PRs with the identical fix already waiting: #30457, #29845, #30200, #30099, #29814, #29642. Closing this one in favor of those; once any of them lands the fuzzer should stop rediscovering fingerprint b8b8543bb6fc12e8.

@robobun robobun closed this May 12, 2026
@robobun
robobun deleted the farm/a6d9a57a/fix-foreach-property-proxy-exception branch May 12, 2026 07:15

@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 exception-handling reorder and null guard, matching the existing fast-path pattern.

Extended reasoning...

Overview

This PR fixes a null-pointer deref in JSC__JSValue__forEachPropertyImpl (bindings.cpp) when Bun.inspect / console.log formats an object with a Proxy in its prototype chain and a getter or getPrototypeOf trap throws. Two changes:

  1. Reorder CLEAR_IF_EXCEPTION(scope) to run before the continue after getPropertySlot, so a throwing Proxy get trap doesn't leave a pending exception that poisons the subsequent getPrototype call.
  2. Guard the result of iterating->getPrototype(globalObject) and clear any exception from a throwing getPrototypeOf trap before calling .getObject() on it.

Both changes mirror what the fast path in the same function already does at lines ~5336–5337, so this is bringing the slow path to parity rather than introducing new behavior.

Security risks

None. This is purely defensive — replacing a crash with graceful exception swallowing in a debug/inspection code path. No new attack surface, no auth/crypto/permissions involved.

Level of scrutiny

Low-to-moderate. While bindings.cpp is core JSC integration code, the diff is ~10 lines and mechanical: a statement reorder and a null check. The CLEAR_IF_EXCEPTION + proto ? proto.getObject() : nullptr pattern is idiomatic and already used elsewhere in this function. The loop correctly terminates when iterating becomes nullptr (the while condition checks it).

Other factors

  • Fuzzer-found crash with a clear repro and root-cause analysis in the PR description.
  • Three regression tests added; tests 1 and 2 directly exercise the two fixed code paths. The inline nit notes test 3's threshold is too high to actually trigger the throw, but that's supplementary coverage — the fix itself is well-tested by tests 1 and 2.
  • No CODEOWNERS for this path.
  • No outstanding reviewer comments.

Comment on lines +44 to +46
get(t, k, r) {
if (++count > 3) throw new Error("get fail");
return Reflect.get(t, k, r);

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.

🟡 This test never actually triggers the throw: the get trap is invoked exactly 3 times during Bun.inspect(obj) (once for Symbol(nodejs.util.inspect.custom), then once each for bar and baz), so ++count > 3 is never true and the test passes even without the bindings.cpp fix. Lower the threshold to e.g. > 1 so the trap actually throws inside getPropertySlot and exercises the CLEAR_IF_EXCEPTION-before-continue path. (Nit — tests 1 and 2 already cover the actual fixes, this one is just currently vacuous.)

Extended reasoning...

What the test claims vs. what it does

The third test is titled "does not crash when a Proxy get trap throws during iteration" and asserts expect(() => Bun.inspect(obj)).not.toThrow(). For that to be a meaningful regression test, the get trap must actually throw at some point during forEachPropertyImpl's slow-path iteration so that the new CLEAR_IF_EXCEPTION(scope) before continue (bindings.cpp:5372–5376) is exercised. As written, the trap never throws.

Step-by-step trace of the get-trap invocations

Running this scenario on bun and counting trap invocations gives exactly 3:

  1. Symbol(nodejs.util.inspect.custom) lookup — Before forEachProperty runs, Tag.getAdvanced (ConsoleObject.zig:1246) calls fastGet(.inspectCustom), which walks obj → proxy and invokes ProxyObject::getOwnPropertySlot with InternalMethodType::Get, calling the trap once. count = 1, 1 > 3 is false.
  2. bar — In the slow path, getOwnPropertyNames on the proxy (no ownKeys trap) returns [constructor, bar, baz]; constructor is filtered at bindings.cpp:5362. For bar, object->getPropertySlot(globalObject, property, slot) walks obj → proxy and invokes performGet, calling the trap. count = 2, 2 > 3 is false. The slot is a plain value slot, so slot.getValue() later returns the cached value with no second trap call.
  3. baz — Same as above. count = 3, 3 > 3 is false.
  4. proxy->getPrototype() (no getPrototypeOf trap) returns Object.prototype, the loop terminates.

The condition if (++count > 3) throw first fires on the 4th invocation, which never happens. No exception is ever raised inside forEachPropertyImpl, so neither the new CLEAR_IF_EXCEPTION at 5374 nor the getPrototype guard at 5448–5451 is reached via this test.

Why this matters

This was verified empirically on a bun build without this PR's fix: the test body runs to completion with count == 3, no throw, no crash — so the test would pass even on the broken code it's meant to guard against. It currently provides no regression coverage for the throwing-get-trap path described in its title.

Why this is only a nit

The first test (the expect(1) + Proxy-prototype repro from the fuzzer) does exercise the throwing-getter path that crashed pre-fix and covers bindings.cpp:5372–5376, and the second test covers the getPrototype guard at 5448–5451. So overall regression coverage for the fix exists; this third test is supplementary and just ineffective as written.

Fix

Lower the threshold so the trap throws during the getPropertySlot loop:

get(t, k, r) {
  if (++count > 1) throw new Error("get fail");
  return Reflect.get(t, k, r);
},

With > 1, the inspectCustom lookup succeeds (count=1), then bar throws inside getPropertySlot (count=2) — exercising exactly the code path this PR fixed. > 2 would also work (throws on baz).

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.

2 participants