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
6 changes: 0 additions & 6 deletions src/jsc/bindings/BunObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,6 @@ static JSValue defaultBunSQLObject(VM& vm, JSObject* bunObject)
auto scope = DECLARE_THROW_SCOPE(vm);
auto* globalObject = defaultGlobalObject(bunObject->globalObject());
JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql);
#if BUN_DEBUG
if (scope.exception()) globalObject->reportUncaughtExceptionAtEventLoop(globalObject, scope.exception());
#endif
RETURN_IF_EXCEPTION(scope, {});
RELEASE_AND_RETURN(scope, sqlValue.getObject()->get(globalObject, vm.propertyNames->defaultKeyword));
}
Expand All @@ -332,9 +329,6 @@ static JSValue constructBunSQLObject(VM& vm, JSObject* bunObject)
auto scope = DECLARE_THROW_SCOPE(vm);
auto* globalObject = defaultGlobalObject(bunObject->globalObject());
JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql);
#if BUN_DEBUG
if (scope.exception()) globalObject->reportUncaughtExceptionAtEventLoop(globalObject, scope.exception());
#endif
RETURN_IF_EXCEPTION(scope, {});
auto clientData = WebCore::clientData(vm);
RELEASE_AND_RETURN(scope, sqlValue.getObject()->get(globalObject, clientData->builtinNames().SQLPublicName()));
Expand Down
14 changes: 10 additions & 4 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5605,10 +5605,11 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC:
}

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

if ((slot.attributes() & PropertyAttribute::DontEnum) != 0) {
if (property == propertyNames->underscoreProto
Expand Down Expand Up @@ -5680,7 +5681,12 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC:
break;
if (iterating == globalObject)
break;
iterating = iterating->getPrototype(globalObject).getObject();
JSValue prototype = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" traps.
CLEAR_IF_EXCEPTION(scope);
if (!prototype) [[unlikely]]
break;
iterating = prototype.getObject();
Comment on lines +5684 to +5689

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, not blocking: the same getPrototype(globalObject).getObject() null-deref this hunk fixes has one other site in the tree — src/jsc/bindings/napi.cpp:2080, in napi_get_all_property_names's descriptor-filter loop. A throwing Proxy getOwnPropertyDescriptor/getPrototypeOf trap there produces the same empty-JSValue → .getObject() segfault; worth the same split-and-guard treatment as a follow-up.

Extended reasoning...

What & where

REVIEW.md's "Fix the whole class in the same PR — grep for every sibling site sharing the pattern" rule prompts a grep for the exact chained call this PR's second bindings.cpp hunk fixes. getPrototype(globalObject).getObject() has exactly one other occurrence in src/: src/jsc/bindings/napi.cpp:2080, inside napi_get_all_property_names:

JSObject* owner = object;
if (key_mode == napi_key_include_prototypes) {
    // Climb up the prototype chain to find inherited properties
    while (!owner->getOwnPropertyDescriptor(globalObject, propKey, desc)) {
        JSObject* proto = owner->getPrototype(globalObject).getObject();
        if (!proto) {
            break;
        }
        owner = proto;
    }
}

There is no exception check between line 2079 (getOwnPropertyDescriptor) and line 2085, and no guard on getPrototype's return value before .getObject() is called on it.

Code path that triggers it

napi_get_all_property_names is called by native N-API modules. The loop at 2077–2085 is reached when:

  • key_mode == napi_key_include_prototypes, and
  • key_filter includes any of napi_key_enumerable | napi_key_writable | napi_key_configurable (the filter_by_any_descriptor gate at line 2069).

owner starts at the user-supplied object (from toJS(objectNapi)) and climbs its prototype chain, so a Proxy anywhere in that chain is reachable from JS.

Why the existing code doesn't prevent it

The if (!proto) break; check at line 2081 never runs when getPrototype returns an empty JSValue. As the PR description establishes for the bindings.cpp case: an empty JSValue encodes as 0, so isCell() returns true (0 & NotCellMask == 0), asCell() returns nullptr, and ->isObject() reads m_type at offset 5 from nullptr — segfault at address 0x5. The null check is dead code on the throwing path.

There is also no RETURN_IF_EXCEPTION / NAPI_RETURN_IF_EXCEPTION between getOwnPropertyDescriptor (which invokes the Proxy getOwnPropertyDescriptor trap) and getPrototype (which invokes the Proxy getPrototypeOf trap).

Step-by-step proof

Concrete example — a 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 is:

Object.create(new Proxy({ a: 1 }, {
  getOwnPropertyDescriptor() { throw new Error("trap"); }
}))
  1. collectInheritedPropertyKeys (line 2060) enumerates "a" from the proxy target via getPropertyNames.
  2. The filter loop enters with propKey = "a", owner = obj (the plain child).
  3. owner->getOwnPropertyDescriptor(...) on the child returns false (no own "a"), no exception yet.
  4. owner->getPrototype(globalObject) returns the Proxy; .getObject() succeeds; owner = proxy.
  5. Loop iterates: owner->getOwnPropertyDescriptor(...) now invokes the Proxy trap, which throws. ProxyObject::performGetOwnPropertyDescriptor returns false via RETURN_IF_EXCEPTION, leaving the exception pending.
  6. Loop body runs: owner->getPrototype(globalObject) on the Proxy enters ProxyObject::getPrototype, whose throw scope observes the pending exception and returns {} (empty JSValue).
  7. .getObject() on the empty JSValue: isCell() → true, asCell() → nullptr, ->isObject() reads offset 5 from nullptr → SIGSEGV at 0x5.

A second variant: a stateful getPrototypeOf trap that succeeds during collectInheritedPropertyKeys' getPropertyNames walk (line 2007) but throws on the per-key descriptor climb hits step 6 directly — getPrototype returns {} and .getObject() segfaults the same way.

Impact

Segfault (crash) of the Bun process when a native N-API module calls napi_get_all_property_names with napi_key_include_prototypes + a descriptor filter on an object whose prototype chain contains a Proxy with a throwing getOwnPropertyDescriptor or getPrototypeOf trap. The surface is much narrower than Bun.inspect (requires a native addon using this specific N-API call with these specific flags), which is why it's flagged as a follow-up rather than a blocker.

How to fix

Same treatment as this PR applies at bindings.cpp:5684–5689: split the chained call, check for an exception after getOwnPropertyDescriptor and after getPrototype, and bail (NAPI_RETURN_IF_EXCEPTION or break out of the filter loop) when either is set or when the returned JSValue is empty. The owner->getOwnPropertyDescriptor at line 2087 (the napi_key_own_only branch) also lacks an exception check and should get one in the same follow-up.

Why this is pre_existing

The PR does not touch napi.cpp, does not add callers to napi_get_all_property_names, and lives in a separate subsystem (N-API vs. Bun.inspect). This code has been shaped this way since before the PR. It is the exact bug class the PR fixes and the only other site with the pattern, so REVIEW.md's whole-class rule makes it worth mentioning — but it should not block merging this fix.

}
}

Expand Down
81 changes: 81 additions & 0 deletions test/js/bun/util/inspect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -928,3 +928,84 @@ describe.skipIf(!isASAN)("object mutated while being formatted", () => {
expect(exitCode).toBe(0);
});
});

// Run in a child: a regression here segfaults the process instead of throwing.
describe("property lookup throws while formatting an object", () => {
it.concurrent("Proxy traps in the prototype chain", async () => {
const fixture = `
{
// Only "a" throws, so the walk has to carry on past it without the
// exception still pending when "b" and "c" are looked up.
const proto = new Proxy(
{ a: 1, b: 2, c: 3 },
{
get(target, key, receiver) {
if (key === "a") throw new Error("get trap");
return Reflect.get(target, key, receiver);
},
},
);
console.log(Bun.inspect(Object.create(proto)));
}
{
const proto = new Proxy(
{ a: 1 },
{
getPrototypeOf() {
throw new Error("getPrototypeOf trap");
},
},
);
const obj = Object.create(proto);
obj.x = 1;
console.log(Bun.inspect(obj));
console.log(obj);
}
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toMatchInlineSnapshot(`
"{
b: 2,
c: 3,
}
{
x: 1,
a: 1,
}
{
x: 1,
a: 1,
}
"
`);
expect(exitCode).toBe(0);
});

it.concurrent("lazy property of the Bun object", async () => {
// Bun.redis builds the default client the first time it is read, and an
// invalid REDIS_URL makes that throw. Only that property may be left out;
// the properties visited after it must still be printed.
const fixture = `
const keys = Object.keys(Bun);
const out = Bun.inspect(Bun);
console.log(JSON.stringify(keys.filter(key => !out.includes("\\n " + key + ":"))));
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture],
env: { ...bunEnv, REDIS_URL: "http://not-a-redis-url" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe('["redis"]\n');
expect(exitCode).toBe(0);
});
});