Skip to content

fix: harden N-API boundary against JS-triggerable memory bugs#60

Open
nazarhussain wants to merge 8 commits into
mainfrom
nh/code-analysis
Open

fix: harden N-API boundary against JS-triggerable memory bugs#60
nazarhussain wants to merge 8 commits into
mainfrom
nh/code-analysis

Conversation

@nazarhussain

@nazarhussain nazarhussain commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Fixes JS-triggerable memory-safety bugs found in a security audit of src/js and the N-API layer.

Fixes

  • BigInt word OOB read (getValueBigintWords): napi reports the required word count, which can exceed the caller's buffer; slicing by it read out of bounds. Now returns error.Overflow.
  • BigInt.toI128 overflow: out-of-range magnitudes and the valid -2^127 both tripped @intCast. Now range-checked.
  • Unknown napi enum values (Status, ValueType, TypedarrayType): exhaustive enums from napi out-params were invalid-enum UB (e.g. Float16Array, newer statuses). Made non-exhaustive; unknown values map to errors.
  • Constructor without new: wrapped native state onto globalThis. Now throws TypeError.
  • Error-path memory bugs (not reachable from the test harness): double-free in materializeClassInstance, use-after-free in registerClass list linking, catch unreachable in module registration.
  • Bonus: implemented expectType/expectTypedArrayOfType, which every js.Value.as*() method called but were never defined (compile error on use, hidden by lazy analysis).

Impact depends on the consumer's optimize mode — zapi ships as source, so the build mode is chosen downstream. lodestar-z builds ReleaseSafe, where these surface as safety-check panics → process abort (DoS). They would be UB only under ReleaseFast/ReleaseSmall (safety checks elided), which lodestar-z doesn't use.

Tests

  • zig build test:napi / test:zapi, pnpm test:js all green
  • New regression tests: i128 boundaries, out-of-range BigInts, no-new calls, value narrowing
  • examples/targets has one pre-existing, unrelated failure (musl detection on macOS)

🤖 Generated with Claude Code

nazarhussain and others added 6 commits July 22, 2026 10:19
napi sets the out word_count to the required count, which can exceed
the caller's buffer; slicing by it read out of bounds for BigInts
wider than the buffer (panic in safe builds, UB in ReleaseFast).
getValueBigintWords returns error.Overflow instead. toI128 now
range-checks the magnitude and handles -2^127, which previously
tripped @intcast before negation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Status, ValueType, and TypedarrayType were exhaustive enums populated
straight from napi out-params; a status or array type this binding
doesn't know (e.g. Float16Array, statuses added in newer Node) was
invalid-enum UB in ReleaseFast and a panic in safe builds. Make them
non-exhaustive and map unknown values to errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without new, V8 hands the global proxy as the receiver, so the
generated constructor wrapped native state and a finalizer onto
globalThis and type-tagged it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- wrapTaggedObject destroyed the native object on tagging failure
  while materializeClassInstance's errdefer destroyed it again
  (double free); ownership now stays with the caller on error
- registerClass linked the entry before addEnvCleanupHook, so a hook
  failure freed the list head while still reachable (use-after-free)
  and leaked the ctor ref
- module registration aborted via catch unreachable when throwError
  failed with an exception already pending

None of these paths are reachable from the JS test harness (they need
napi calls failing mid-sequence), hence no regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
expectType and expectTypedArrayOfType were called by every as*()
narrowing method but never defined; Zig's lazy analysis hid it until
a consumer instantiated one, which then failed to compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nazarhussain
nazarhussain marked this pull request as ready for review July 23, 2026 07:09

@spiral-ladder spiral-ladder left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

safe builds panic (process abort / DoS); ReleaseFast — the usual production build — gets UB.

This is not true, we're using ReleaseSafe for zapi. I don't think we should use ReleaseSafe anywhere accept for bls (the rust bindings is compiled with -O3 which is == ReleaseFast)

Comment thread examples/js_dsl/mod.zig
/// Read a BigInt's first u64 word via `getValueBigintWords` passing `null` for `sign_bit`.
///
/// Throws if word_count > 1.
/// Throws `Overflow` if the BigInt needs more than one word.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If this is the intended behaviour, maybe rename to bigIntSingleWord? bigIntFirstWord seems to imply there's more than one word and we're taking only the first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call — renamed to bigIntSingleWord (6a6231f). It reads a single-word BigInt and throws otherwise, so "first" was misleading.

Comment thread examples/js_dsl/mod.zig
}

/// Narrow an untyped value to a Uint8Array and return its length.
pub fn narrowToUint8ArrayLen(v: Value) !Number {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we need the Len suffix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept it — the fn returns the length (a Number), not the array, so the suffix names what comes back. Happy to drop it if you'd rather; it's just example code.

Comment thread src/js/class_runtime.zig Outdated
}

/// On error the caller keeps ownership of `native_object`: the wrap is
/// detached so the finalizer cannot fire, but the object is not destroyed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think maybe it's worth documenting why we don't want the finalizer to fire for now (because consumer cleans it up), this comment in itself isn't helpful w/o context imo

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Expanded it (4245470): wrap() installs a finalizer that frees the object on GC; on error the caller owns the free instead, so we detach the finalizer first — otherwise both free it.

Comment thread src/value_types.zig
bigint64 = c.napi_bigint64_array,
biguint64 = c.napi_biguint64_array,
// Non-exhaustive: engines already ship array types napi doesn't name yet
// (e.g. Float16Array); unknown values must be representable, not UB.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

question: i know the _ is to account for future upgrades adding new types, but do we necessarily want to silently accept and not UB?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's not silent — _ just makes an unknown value representable (reading an unknown into an exhaustive enum is the UB). We then reject it: elementSize() → null → error.GenericFailureTypeMismatch thrown to JS. An exhaustive enum would instead be UB/panic at @enumFromInt, before we ever get to reject. So this is what lets us turn an unknown type into a clean error instead of UB.

@spiral-ladder

Copy link
Copy Markdown
Member

also, it appears some of BLS code use these codepaths, we could perhaps have regression tests aimed at what they used?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nazarhussain

Copy link
Copy Markdown
Contributor Author

Looked at blst.zig — it exercises typed-array narrowing (isTypedarray/getTypedarrayInfo, Uint8Array.toSlice/fromExternal) and class construction/unwrap, but no BigInt and no no-new calls. Both of the paths it hits are already covered by the regression tests here: Uint8Array narrowing by exact subtype, and class construction/cross-class binding. The BigInt fixes aren't on BLS's path. Happy to add a test mirroring a specific BLS pattern if there's one you want pinned down.

The helper reads a single-word BigInt and errors otherwise; "first"
wrongly implied there are more words to follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nazarhussain

Copy link
Copy Markdown
Contributor Author

safe builds panic (process abort / DoS); ReleaseFast — the usual production build — gets UB.

This is not true, we're using ReleaseSafe for zapi. I don't think we should use ReleaseSafe anywhere accept for bls (the rust bindings is compiled with -O3 which is == ReleaseFast)

You're right — and since zapi ships as source, the build mode is the consumer's call, not zapi's. lodestar-z builds ReleaseSafe, so these surface as safety-check panics → process abort (DoS), not UB; UB would only apply to a ReleaseFast/ReleaseSmall consumer, which we don't use. Corrected the description to frame it that way.

@spiral-ladder spiral-ladder left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

code changes lgtm but i personally prefer not having an ai reply to me

@nazarhussain

nazarhussain commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

code changes lgtm but i personally prefer not having an ai reply to me

It was the first attempt for that experiment, but none of those replies were automated.

Comment thread src/js/class_runtime.zig
} else |_| {};
// Assumed infallible right after a successful wrap; if removeWrap ever
// failed the finalizer would stay live and both it and the caller would free.
errdefer _ = env.removeWrap(T, object) catch {};

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.

What is the behavior when catch {} triggered, is it possible double free or memory leak? Is it possible testing this case?

This comment also confused me it should infallible after a successful wrap, but actually below statements still can throw error, why not make the env.wrap after these checks?

Comment thread src/js/wrap_class.zig

const init_result = callInit(init_fn, args) orelse return null;

const obj_ptr = std.heap.c_allocator.create(T) catch {

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 fail seems probably leak the init_result, reorder obj_ptr and init_result may more simple fix

Comment thread src/status.zig
no_external_buffers_allowed = c.napi_no_external_buffers_allowed,
cannot_run_js = c.napi_cannot_run_js,
// Non-exhaustive: future Node versions may add statuses.
_,

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 still panic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants