Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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 @@
}

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 @@
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();

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

View check run for this annotation

Claude / Claude Code Review

Same getPrototype().getObject() null-deref remains at napi.cpp:2080

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.
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
79 changes: 79 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,82 @@
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, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

Check warning on line 971 in test/js/bun/util/inspect.test.js

View check run for this annotation

Claude / Claude Code Review

Subprocess tests pipe stderr but never drain it

Both new subprocess tests set `stderr: "pipe"` but never drain it — `Promise.all` only awaits `proc.stdout.text()` and `proc.exited`. REVIEW.md's "Subprocess tests: drain pipes concurrently" rule requires reading every piped stream, and the neighboring subprocess tests in this file all do so. Add `proc.stderr.text()` to the `Promise.all` (here and at line 1005), or drop `stderr: "pipe"` if stderr is intentionally ignored.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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.

🟡 Both new subprocess tests set stderr: "pipe" but never drain it — Promise.all only awaits proc.stdout.text() and proc.exited. REVIEW.md's "Subprocess tests: drain pipes concurrently" rule requires reading every piped stream, and the neighboring subprocess tests in this file all do so. Add proc.stderr.text() to the Promise.all (here and at line 1005), or drop stderr: "pipe" if stderr is intentionally ignored.

Extended reasoning...

What the issue is

Both tests added in the new describe("property lookup throws while formatting an object") block spawn a child with stderr: "pipe", but the await line is:

const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

proc.stderr is piped but never read. This appears at both inspect.test.js:971 (the Proxy-traps test) and inspect.test.js:1005 (the Bun.inspect(Bun) test).

Why this violates the repo's review rules

REVIEW.md states this explicitly under Tests reviewers reject:

Subprocess tests: drain pipes concurrently. Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) — an unread pipe fills the ~64KB OS buffer and deadlocks the child. assert a combined { stdout, stderr, exitCode } object.

It is also inconsistent with the file's own conventions: the two neighboring subprocess tests in this file — the huge-sparse-array test (~line 446) and the ASAN mutated-object test (~line 900) — both do Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) and assert on stderr.

Step-by-step: how the pattern can bite

  1. The second test spawns a child that runs Bun.inspect(Bun) with REDIS_URL set to an invalid URL.
  2. Bun.inspect(Bun) reifies every lazy property builder on the Bun object ($, sql, postgres, SQL, s3, redis, stdin/stdout/stderr, …). The redis builder is deliberately made to throw.
  3. The child's stderr fd is a pipe backed by an OS buffer of roughly 64 KB.
  4. If any of these builders — or a future one — writes enough to stderr (debug logging, error dumps, warnings), the child's next write(2) on stderr blocks because nothing in the parent is reading the pipe.
  5. The child never exits, proc.exited never resolves, and the it.concurrent test hangs until the runner times it out.

In practice, an actual deadlock is unlikely today: bunEnv sets BUN_DEBUG_QUIET_LOGS=1, the redis URL error is short, and the fix in this PR clears exceptions rather than printing them. So this is filed as a nit rather than a blocker. But piping-without-reading is precisely the pattern the repo rule forbids, and the second test's fixture (Bun.inspect(Bun)) is exactly the kind of thing whose stderr volume changes as new lazy builders are added to Bun.

Secondary cost: lost diagnostics

Beyond the deadlock hazard, not draining stderr means that when this test does fail (e.g. the child crashes on a regression), the failure output shows only an empty stdout and a nonzero exit code — the child's stderr, which would contain the actual crash report, is discarded. Asserting a combined { stdout, stderr, exitCode } object (as REVIEW.md recommends and as the neighboring tests do) surfaces that information automatically.

Fix

Change both call sites to:

const [stdout, stderr, exitCode] = await Promise.all([
  proc.stdout.text(),
  proc.stderr.text(),
  proc.exited,
]);

and either assert on stderr or include it in the asserted object. Alternatively, if stderr content is intentionally irrelevant, drop stderr: "pipe" from the spawn options entirely so it inherits and is never buffered.

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, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toBe('["redis"]\n');
expect(exitCode).toBe(0);
});
});