Skip to content

napi: stop napi_get_property_names poisoning JSC's per-Structure own-keys cache - #34130

Merged
Jarred-Sumner merged 1 commit into
mainfrom
claude/d0abb21c/napi-property-names-cache-poisoning
Jul 14, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
claude/d0abb21c/napi-property-names-cache-poisoning

Conversation

@robobun

@robobun robobun commented Jul 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

napi_get_all_property_names(include_prototypes) and napi_get_property_names permanently corrupt plain-JS Reflect.ownKeys / Object.keys process-wide by poisoning JSC's per-Structure own-keys cache.

Repro

// addon exports apn(obj, mode, filter, conv) -> napi_get_all_property_names(...)
const mk = () => ({ a: 1, b: 2 });
for (let i = 0; i < 20; i++) addon.apn(mk(), 0, 0, 0); // include_prototypes
console.log(Reflect.ownKeys(mk()).join(","));
// node: a,b
// bun:  a,b,toString,toLocaleString,valueOf,hasOwnProperty,propertyIsEnumerable,
//       isPrototypeOf,__defineGetter__,__defineSetter__,__lookupGetter__,
//       __lookupSetter__,__proto__,constructor   <- 14 "own" keys

Any native module that enumerates properties via napi_get_property_names (node-addon-api's Object::GetPropertyNames() maps directly to this) silently rewrites core JS semantics for unrelated pure-JS code: dedupe, serialization, spread and shape checks on same-shaped objects start seeing prototype methods as own keys.

Cause

Both call sites use JSC::allPropertyKeys(), which routes through getPropertyKeys<Inherit=true> in ObjectConstructor.cpp. That template collects names from the full prototype chain via getPropertyNames, then on the second call on a cacheable Structure stores that chain-walked list via structure->setCachedPropertyNames(vm, kind, ...) into the same per-Structure cache slot that Reflect.ownKeys (StringsAndSymbols) and Object.keys (EnumerableStrings) serve from. Bun's napi bridge is the only caller of allPropertyKeys() in the codebase.

Fix

Replace both allPropertyKeys() call sites with a local collectInheritedPropertyKeys() helper that walks the chain via JSObject::getPropertyNames into a PropertyNameArrayBuilder and builds the result JSArray directly, bypassing the Structure cache. The napi_key_own_only path keeps using ownPropertyKeys() since that cache write is correct (own keys into an own-keys cache).

Verification

$ bun bd test test/napi/napi.test.ts -t "does not poison"
(pass) napi > napi_get_property_names / napi_get_all_property_names > does not poison JSC's per-Structure own-keys cache

The test compares Bun's output against Node's for:

  • Reflect.ownKeys after 20x napi_get_all_property_names(include_prototypes, all, keep)
  • Object.keys after 20x napi_get_property_names on an object with a custom prototype
  • Object.getOwnPropertyNames after 20x chain walk with napi_key_skip_symbols
  • own-only mode still clean
  • napi results themselves still include inherited keys

Node's upstream test_object napi suite passes unchanged.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/napi.test.ts

…keys cache

napi_get_all_property_names(include_prototypes) and napi_get_property_names
used JSC::allPropertyKeys(), which walks the prototype chain and then
writes the chain-walked list into the Structure's own-keys cache via
setCachedPropertyNames(). After enough calls on same-shaped objects,
Reflect.ownKeys({a,b}) would permanently return 14 keys (all of
Object.prototype leaked in) and Object.keys would return inherited
enumerable keys, for every object sharing that Structure, process-wide.

Replace both call sites with a local helper that walks the chain via
JSObject::getPropertyNames into a PropertyNameArrayBuilder and builds
the result JSArray directly, bypassing the cache entirely. The own-only
path keeps using ownPropertyKeys() since that cache write is correct.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c38db65b-8c8b-4b60-99c2-3d0c213be435

📥 Commits

Reviewing files that changed from the base of the PR and between 16c5576 and a108fd5.

📒 Files selected for processing (4)
  • src/jsc/bindings/napi.cpp
  • test/napi/napi-app/js_test_helpers.cpp
  • test/napi/napi-app/module.js
  • test/napi/napi.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 AM PT - Jul 14th, 2026

@robobun, your commit a108fd5 is building: #72761

@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.

No issues found, but this touches JSC-facing C++ (GC-visible array construction, identifier lifetime via jsOwnedString, Structure-cache bypass), so it's worth a human look.

Checked: collectInheritedPropertyKeys mirrors the PropertyNameArrayBuilder + getPropertyNames pattern already used in bindings.cpp and JSPropertyIterator.cpp; the Symbol::create/jsOwnedString + putDirectIndex loop matches JSC's own ownPropertyKeys shape and identifiers are ref-counted AtomStringImpls so the borrowed-string path is safe. RETURN_IF_EXCEPTION is present after both throwing calls. Confirmed allPropertyKeys had no other callers in the tree, and the napi_key_own_only branch is untouched. Test uses checkSameOutput against Node and covers both entry points, skip_symbols, own-only, and that napi results still include inherited keys.

Extended reasoning...

Overview

This PR replaces two JSC::allPropertyKeys() calls in src/jsc/bindings/napi.cpp (napi_get_all_property_names with include_prototypes, and napi_get_property_names) with a new local helper collectInheritedPropertyKeys() that walks the prototype chain via JSObject::getPropertyNames into a PropertyNameArrayBuilder and materializes a JSArray directly. The rest of the diff is test scaffolding: two thin native wrappers in js_test_helpers.cpp, a JS driver in module.js, and a checkSameOutput-based test in napi.test.ts.

Security risks

None identified. This is a read-only property-name enumeration path; no user-controlled sizes drive allocation, no auth/crypto/permission logic is involved, and the change narrows behavior (stops writing to a shared cache) rather than widening it.

Level of scrutiny

High. napi.cpp is a production-critical C++ binding on a GC-sensitive path. The new helper allocates a JSArray, iterates identifiers, and calls putDirectIndex (which can allocate and thus GC) inside a loop — exactly the class of code CLAUDE.md flags for careful review. It looks correct: keys is stack-rooted, identifiers are ref-counted AtomStringImpls so jsOwnedString is the right constructor, exception scopes are declared and checked after both fallible calls, and the pattern is byte-for-byte the same shape JSC and bindings.cpp already use for property-name-array → JSArray conversion. But a maintainer with JSC context should confirm the choice to bypass the Structure cache here (vs. fixing allPropertyKeys upstream in the WebKit fork) is the intended layering.

Other factors

  • Verified allPropertyKeys has no other call sites in the repo, matching the PR's claim.
  • The napi_key_own_only branch still uses ownPropertyKeys(), which is correct (own keys → own-keys cache).
  • Test compares against Node via checkSameOutput and asserts specific expected lines, so it can't vacuously pass; it exercises Reflect.ownKeys, Object.keys, Object.getOwnPropertyNames, own-only mode, and that the napi results themselves still include inherited keys.
  • The PR description notes CI (not local) verification since the napi test is platform-gated.

@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the layering question (fix in napi.cpp vs the WebKit fork): allPropertyKeys has zero callers inside JSC itself; Bun's napi bridge is the only consumer in the tree. The cache-write in getPropertyKeys<Inherit=true> is arguably a latent upstream bug, but since nothing in WebKit exercises it on cacheable structures, patching the fork would carry maintenance cost across WebKit bumps for a path only Bun uses. Keeping the fix in napi.cpp makes it self-contained and leaves ownPropertyKeys (the own-only path) benefiting from the cache as intended. Happy to move the guard into ObjectConstructor.cpp instead if that's preferred.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the bug that teh caching-related flags don't match the flags used by napi? This sounds like a worthwhile optimization to keep as long as we call it the right way.

@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Good question. I looked at this more carefully and there's no flag combination that makes allPropertyKeys safe:

  • CachedPropertyNamesKind only has {EnumerableStrings, Strings, Symbols, StringsAndSymbols} (StructureRareData.h:44-49). There is no inherit dimension in the cache key.
  • The write is gated by structure->canCacheOwnPropertyNames() and lands in the slot that Reflect.ownKeys/Object.keys serve from. The read at the top of getPropertyKeys would also return stale own-only keys for an inherit=true call if the own-keys cache is already warm, so both directions are wrong.
  • A per-Structure cache can't validly hold inherited keys anyway: the result depends on the whole prototype chain, and a proto can gain or lose properties without any transition on the leaf Structure. JSC's for-in enumerator cache handles this separately via StructureChain validation, which this path doesn't do.

allPropertyKeys itself is a Bun fork addition (oven-sh/WebKit@aef8313033, "Add method to get own property names plus inherited property names"), not upstream WebKit. It wrapped upstream's ownPropertyKeys with an inherit parameter but didn't gate the cache on it.

Two ways to go:

  1. Keep this PR as-is (local non-caching helper in napi.cpp), and separately remove allPropertyKeys from the fork since its only caller is gone. Shrinks the fork diff.
  2. Move the fix into the fork: make getPropertyKeys<Inherit> skip both the cache read and write when Inherit is true, and keep napi.cpp calling allPropertyKeys. Same runtime behavior, but the fork keeps carrying a Bun-only helper that nothing in JSC exercises.

Either way there's no caching win available for the inherit path without building prototype-chain invalidation, which is a bigger project. Happy to do whichever you prefer; (1) seems cleaner to me.

@Jarred-Sumner
Jarred-Sumner merged commit c8e32b6 into main Jul 14, 2026
75 of 76 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/d0abb21c/napi-property-names-cache-poisoning branch July 14, 2026 06:44
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

This collided with #34126: both added a get_all_property_names helper to js_test_helpers.cpp, so napi-app stopped compiling on main. Deduped in #34141.

robobun added a commit that referenced this pull request Jul 14, 2026
#34126 and #34130 each added a get_all_property_names helper to
js_test_helpers.cpp with different return shapes; the merged result has a
C++ redefinition error that breaks the addon build. Keep the {status, keys}
shape and update test_property_names_cache_poisoning to read .keys.
robobun added a commit that referenced this pull request Jul 14, 2026
PRs #34126 and #34130 each added a get_all_property_names helper to
js_test_helpers.cpp, leaving main with a redefinition error. Keep the
{status, keys} variant and update test_property_names_cache_poisoning
to destructure .keys.
robobun added a commit that referenced this pull request Jul 14, 2026
napi_get_version() returned 9 while the full v10 symbol set
(node_api_create_external_string_*, node_api_create_property_key_*)
is exported and functional, and process.versions.napi already reads
"10". Addons that feature-detect via napi_get_version() >= 10 took
the fallback path even though the v10 APIs work.

Also fixes the two v10 gaps that actually existed:
node_api_create_external_string_latin1/utf16 rejected length==0 with
napi_invalid_arg because WTF::ExternalStringImpl does not allow empty
strings. Now return jsEmptyString, set *copied=false, and invoke the
finalizer immediately, matching Node.js/V8. The utf16 variant was also
never writing *copied on success; it now does.

Unblocks the upstream test_string empty-string cases that were guarded
for Bun, and bumps the vendored test_general expected version.

Also deduplicates get_all_property_names in the test addon (merged
twice via #34126 and #34130), which was preventing the napi test
addon from compiling on main.
robobun added a commit that referenced this pull request Jul 14, 2026
…rray

napi_is_typedarray already reported true for a Float16Array, but both
napi_get_typedarray_info and napi_create_typedarray(napi_float16_array)
returned napi_invalid_arg because the napi_typedarray_type <-> JSC type
tables stopped at biguint64.

Add napi_float16_array (= 11, matching Node's js_native_api_types.h) to
the vendored header, to the Rust napi_typedarray_type enum and its
from_js_type mapping, and to the three switches in napi.cpp that drive
napi_create_typedarray.

Also dedup a get_all_property_names helper in the napi test addon that
was left doubly defined after #34126 and #34130 both added one; the
addon did not compile on main.
Jarred-Sumner pushed a commit that referenced this pull request Jul 14, 2026
## What

`napi_detach_arraybuffer` on a `SharedArrayBuffer` returned `napi_ok`
without detaching anything. Node returns `napi_arraybuffer_expected`
(19).

## Repro

```c
// sab = new SharedArrayBuffer(8); new Uint8Array(sab)[0] = 7;
napi_status sd = napi_detach_arraybuffer(env, sab);
// node: sd=19 (napi_arraybuffer_expected)
// bun : sd=0  (napi_ok)          <- claims success, nothing detached
// aftermath in BOTH engines: sab.byteLength == 8, view[0] == 7
```

## Cause

JSC backs `SharedArrayBuffer` with the same `JSC::JSArrayBuffer` cell
type as a plain `ArrayBuffer`, so `dynamicDowncast<JSArrayBuffer>`
succeeds. The `isDetachable()` check (which is false for shared buffers)
then silently skipped the detach and fell through to
`NAPI_RETURN_SUCCESS`. V8 treats `SharedArrayBuffer` as a distinct type,
so Node's `value->IsArrayBuffer()` check rejects it up front.

The `napi_ok` here is a memory-lifetime lie: an addon that trusts it may
free or recycle a backing store that JS (and other threads holding the
same SAB) still read and write.

## Fix

In `napi_detach_arraybuffer`: reject shared buffers with
`napi_arraybuffer_expected` (matching Node's `IsArrayBuffer()` gate),
then require `isDetachable()` with
`napi_detachable_arraybuffer_expected` instead of succeeding as a no-op.
Detaching an already-detached buffer remains `napi_ok`.

Also aligns `napi_is_detached_arraybuffer` with Node: it now returns
`napi_ok` with `result=false` for any non-ArrayBuffer value (typed
arrays, SAB, anything else) instead of `napi_arraybuffer_expected`.

## Verification

New `test_detach_arraybuffer` in the napi-app addon prints the status
for `[SharedArrayBuffer, ArrayBuffer, <same ArrayBuffer again>,
Uint8Array]` and `checkSameOutput` asserts Bun matches Node
byte-for-byte.

Fails on system Bun (SAB row: `napi_detach_arraybuffer=0` vs Node's
`19`; Uint8Array row: `napi_is_detached_arraybuffer=19` vs Node's `0`),
passes with this change.

## Also in this PR

Deduplicates `get_all_property_names` in
`test/napi/napi-app/js_test_helpers.cpp`. #34126 and #34130 each landed
a definition with a different return shape and the addon no longer
compiled on main; kept the `{status, keys}` form and updated the one
caller that expected a bare array.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/napi/napi.test.ts

<!-- robobun:evidence:end -->
Jarred-Sumner pushed a commit that referenced this pull request Jul 14, 2026
## Problem

`napi_adjust_external_memory` silently drops negative `change_in_bytes`,
so the external-memory counter Bun reports can only grow. Every
well-behaved addon that decrements in its finalizers (the documented
`Napi::MemoryManagement::AdjustExternalMemory` pattern) leaks phantom GC
pressure for the life of the process, and the documented return value
("the adjusted value") diverges from Node on the first decrease.

### Repro

```c
int64_t a, b, c;
napi_adjust_external_memory(env,  8192, &a);   // allocate: report +8192
napi_adjust_external_memory(env, -8192, &b);   // finalizer: report the free
napi_adjust_external_memory(env,     0, &c);   // read back
// node: a-b=8192 b-c=0   (decrease applied; counter returns to baseline)
// bun : a-b=0    b-c=0   (decrease dropped; counter stuck +8192 forever)
```

## Cause

`src/jsc/bindings/napi.cpp`:

```cpp
if (change_in_bytes > 0) {
    heap.deprecatedReportExtraMemory(change_in_bytes);
}
*adjusted_value = heap.extraMemorySize();
```

JSC's `deprecatedReportExtraMemory` has no decrement path (V8's
`AdjustAmountOfExternalAllocatedMemory` is signed), so negatives are
guarded out, and `heap.extraMemorySize()` is the VM-wide extra-memory
total rather than the napi-reported running total.

## Fix

Keep a signed `int64_t m_externalMemory` accumulator on `NapiEnv`. Apply
both directions to the accumulator, forward only positive growth to the
JSC heap (there is still no decrement API), and return the accumulator
as the adjusted value so the trajectory matches Node.

## Verification

```
$ bun bd test test/napi/napi.test.ts -t "napi_adjust_external_memory"
(pass) napi > napi_adjust_external_memory > applies negative deltas and reports the running total
```

The test compares Bun's output against Node's (`checkSameOutput`) for
`+8192 / -8192 / 0` and asserts the exact deltas (`+8192, -8192, 0, 0`).

---

Also deduplicates `get_all_property_names` in
`test/napi/napi-app/js_test_helpers.cpp`: #34126 and #34130 each added a
static function with that name, so the test addon failed to compile on
main. The `{status, keys}` variant is kept and the one call site that
expected a raw array now reads `.keys`.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/napi/napi.test.ts

<!-- robobun:evidence:end -->
robobun added a commit that referenced this pull request Jul 15, 2026
… in Node v26

Implements and exports node_api_set_prototype,
node_api_create_object_with_properties, node_api_create_sharedarraybuffer,
node_api_create_external_sharedarraybuffer, and node_api_is_sharedarraybuffer.
These are the only five node_api_*/napi_* symbols from Node v26 that Bun
does not already export. Because addon binding is lazy, an addon that
references any of them loads cleanly on Bun and then dies with an
uncatchable ld.so "symbol lookup error" (exit 127) the first time the
code path is hit, with no JS exception to catch.

The new tests in standalone_tests.cpp take link-time references to all
five symbols and compare their behaviour against the ABI-matching Node
(currently v26.3.0) via checkSameOutput.

Also de-duplicates get_all_property_names in js_test_helpers.cpp, where
two recent PRs (#34126, #34130) each added a helper with that name and
the addon stopped building.
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