Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5369,10 +5369,11 @@
}

JSC::PropertySlot slot(object, PropertySlot::InternalMethodType::Get);
if (!object->getPropertySlot(globalObject, property, slot))
continue;
bool hasProperty = object->getPropertySlot(globalObject, property, slot);
// Ignore exceptions from "Get" proxy traps.
CLEAR_IF_EXCEPTION(scope);
if (!hasProperty)
continue;

if ((slot.attributes() & PropertyAttribute::DontEnum) != 0) {
if (property == propertyNames->underscoreProto
Expand Down Expand Up @@ -5444,7 +5445,12 @@
break;
if (iterating == globalObject)
break;
iterating = iterating->getPrototype(globalObject).getObject();
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();

Check notice on line 5453 in src/jsc/bindings/bindings.cpp

View check run for this annotation

Claude / Claude Code Review

Same getPrototype().getObject() null-deref remains in napi.cpp

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.
Comment on lines +5448 to +5453

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.

}
}

Expand Down
33 changes: 33 additions & 0 deletions test/js/bun/util/inspect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,39 @@ describe("crash testing", () => {
}
});
}

it("inspecting object with Proxy prototype whose getPrototypeOf trap throws doesn't crash", () => {
const obj = { a: 1 };
const proto = new Proxy(
{ b: 2 },
{
getPrototypeOf() {
throw new Error("nope");
},
},
);
Object.setPrototypeOf(obj, proto);
expect(() => Bun.inspect(obj)).not.toThrow();
});

it("inspecting object with Proxy prototype that leaves a pending exception during enumeration doesn't crash", () => {
// When a Proxy sits in the prototype chain, getPropertySlot uses InternalMethodType::Get
// which invokes [[Get]] on the Proxy and therefore runs getters on the target.
let getterCalled = false;
const target = {
get first() {
return 1;
},
get second() {
getterCalled = true;
throw new Error("getter threw");
},
};
const obj = { a: 1 };
Object.setPrototypeOf(obj, new Proxy(target, {}));
expect(() => Bun.inspect(obj)).not.toThrow();
expect(getterCalled).toBeTrue();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("possibly formatted emojis log", () => {
Expand Down
Loading