Skip to content

fix(runtime): make WebAssembly.Module instanceof recognize constructed modules - #7124

Open
jdalton wants to merge 1 commit into
PerryTS:mainfrom
jdalton:fix/wasm-module-instanceof
Open

fix(runtime): make WebAssembly.Module instanceof recognize constructed modules#7124
jdalton wants to merge 1 commit into
PerryTS:mainfrom
jdalton:fix/wasm-module-instanceof

Conversation

@jdalton

@jdalton jdalton commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

A WebAssembly module built by Perry does not recognize itself as a WebAssembly.Module. Any code that type-checks a value before using it, which is the ordinary way to write a guard or an overloaded function, takes the wrong branch:

const m = new WebAssembly.Module(bytes);
m instanceof WebAssembly.Module; // → false  (should be true)

WebAssembly.compile(bytes) produces the same wrapper object and had the same defect. This affects programs running under the wasm-host (wasmi) runtime, which is selected by --enable-wasm-runtime and linked automatically when a program uses anything under WebAssembly.*.

After this change a genuine module answers true, a WebAssembly.Memory instance still answers correctly for its own constructor, and an object that merely imitates a module answers false, matching Node in every case.

Why the check returns false

instanceof in JavaScript is defined by OrdinaryHasInstance, which walks the value's prototype chain looking for the constructor's .prototype object. If the walk reaches the end without finding it, the answer is false.

The module wrapper never joins that chain. crate::webassembly::make_module_object returns a plain heap object stamped with two properties, __wasmKind and __wasmModulePtr, but it never links the object's [[Prototype]] to WebAssembly.Module.prototype.

The right-hand side of the comparison, WebAssembly.Module, is a member expression rather than a plain identifier, so the comparison lowers to js_instanceof_dynamic, which ultimately falls back to that same OrdinaryHasInstance walk. The walk cannot reach the namespace constructor's .prototype, so it returns false.

This is the same shape problem the WebAssembly namespace's error constructors already have. Their ErrorHeader-backed instances also lack a prototype chain reaching their constructor's .prototype, and they work around it with webassembly_error_ctor_instanceof.

The fix, and why the obvious version of it is wrong

A sibling function, webassembly_value_ctor_instanceof, is added in global_this_webassembly.rs and consulted from js_instanceof_dynamic directly after the existing error-constructor hook.

It first has to identify what is on the right-hand side. It does that by the constructor thunk's func_ptr, which is stable across garbage-collector moves and cannot be forged by a user-defined function that merely shares the name Module.

It then has to decide whether the left-hand value is a genuine module, and this is where the obvious approach fails. Both __wasmKind and __wasmModulePtr are ordinary enumerable, user-writable properties, so checking the string tag alone is not a brand check at all. A plain object literal such as { __wasmKind: "module", __wasmModulePtr: 1 } would pass it, and Node answers false for that object.

The unforgeable signal is that __wasmModulePtr names a host allocation this runtime actually produced. Every module pointer is recorded in a side registry at construction time, inside make_module_object, and the brand check requires membership in that registry. A copied field carries a pointer the runtime never handed out, so it is rejected. This mirrors the is_registered_buffer, _map, and _set side registries the other builtin instanceof probes already rely on, and it fails closed: a null pointer or a poisoned lock yields false.

The hook short-circuits only on a positive match and returns None otherwise, so any non-matching value still falls through to the ordinary prototype walk. That keeps the change additive and non-regressive.

Which constructors this covers

Module is the one fixed here. It is the only value constructor in this runtime that produces real instances, so it is the only one that needs a brand.

Memory already resolves correctly through the ordinary prototype walk, because the dynamic construct path pre-links Memory.prototype onto the receiver. This is verified in the parity fixture: memory instanceof WebAssembly.Memory is true and module instanceof WebAssembly.Memory is false.

Instance, Table, and Global throw on construction in this baseline, so no instances of them exist to brand.

Test runs

A new Rust unit test, value_ctor_instanceof_brands_module_by_registered_ptr in global_this_webassembly.rs, covers a genuine module matching, a cross-brand miss returning None, a forged object carrying only the __wasmKind tag returning None, a forged object carrying both fields but an unregistered pointer returning None, a foreign object returning None, and a non-constructor right-hand side returning None.

instanceof assertions are added to the test-parity/node-suite/globals/webassembly-module-metadata.ts fixture, which exercises the wasm-host new WebAssembly.Module(...) path. Its output was verified byte-identical to Node's.

Before the change the fixture printed module instanceof Module: false. After it, module instanceof Module: true and compiled instanceof Module: true. All 8 global_this_webassembly unit tests pass, and the default no-engine graceful-failure path in tests/test_webassembly_graceful_fail.sh still passes.

@jdalton
jdalton force-pushed the fix/wasm-module-instanceof branch from f568bc4 to ee99532 Compare July 31, 2026 06:35
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

WebAssembly.Module instanceof checks now validate the __wasmKind brand and a runtime-registered host module pointer. Module creation registration, constructor wiring, prototype fallback, and runtime and parity tests were added.

Changes

WebAssembly Module instanceof

Layer / File(s) Summary
Register host module pointers
crates/perry-runtime/src/object/global_this_webassembly.rs, crates/perry-runtime/src/object/global_this.rs, crates/perry-runtime/src/webassembly.rs
The runtime stores host module pointers in a synchronized registry and registers each pointer when it creates a JavaScript module wrapper. The registration helper is re-exported for the wasm host.
Apply WebAssembly.Module brand checks
crates/perry-runtime/src/object/global_this_webassembly.rs, crates/perry-runtime/src/object/instanceof.rs
The runtime resolves the WebAssembly.Module constructor, checks __wasmKind, and verifies the registered pointer. Non-matching values continue through normal prototype processing.
Validate module identity cases
crates/perry-runtime/src/object/global_this_webassembly.rs, test-parity/node-suite/globals/webassembly-module-metadata.ts, changelog.d/7124-wasm-module-instanceof.md
Tests cover genuine modules, forged and foreign objects, cross-brand values, memory instances, and invalid right-hand values. The changelog records the fix.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested labels: bug

Sequence Diagram(s)

sequenceDiagram
  participant ModuleCreation
  participant WebAssemblyObject
  participant ModulePointerRegistry
  participant Instanceof
  ModuleCreation->>WebAssemblyObject: create WebAssembly module wrapper
  WebAssemblyObject->>ModulePointerRegistry: register host module pointer
  Instanceof->>WebAssemblyObject: check WebAssembly.Module instanceof
  WebAssemblyObject->>ModulePointerRegistry: verify module pointer
  ModulePointerRegistry-->>WebAssemblyObject: return registration result
  WebAssemblyObject-->>Instanceof: return true or use prototype processing
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the primary change: correcting constructed WebAssembly.Module instanceof behavior.
Description check ✅ Passed The description thoroughly explains the defect, implementation, security rationale, affected constructors, and test coverage, but omits the template headings and checklist.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/object/global_this_webassembly.rs`:
- Around line 254-258: Update value_wasm_kind_matches to root the NaN-boxed
value in a RuntimeHandleScope before calling named_key; allocate the key while
the value is rooted, then reload the ObjectHeader pointer from the rewritten
handle and use that pointer for js_object_get_field_by_name_f64. Preserve the
existing non-object early return and wasm-kind comparison behavior.
- Around line 251-267: Replace value_wasm_kind_matches with a non-forgeable
internal runtime-metadata check instead of reading the public __wasmKind
property, while preserving rejection of non-module objects. In the tests around
the WebAssembly brand checks, use an authentic wrapper from make_module_object
for the positive case and assert that an ordinary object manually stamped with
__wasmKind is rejected; update both affected sites in
crates/perry-runtime/src/object/global_this_webassembly.rs at lines 251-267 and
1297-1337.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 47b93074-7c4c-4559-9326-944a1a6b7b0f

📥 Commits

Reviewing files that changed from the base of the PR and between e7bc73b and ee99532.

📒 Files selected for processing (5)
  • changelog.d/7124-wasm-module-instanceof.md
  • crates/perry-runtime/src/object/global_this.rs
  • crates/perry-runtime/src/object/global_this_webassembly.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • test-parity/node-suite/globals/webassembly-module-metadata.ts

Comment thread crates/perry-runtime/src/object/global_this_webassembly.rs Outdated
Comment on lines +254 to +258
fn value_wasm_kind_matches(value: f64, expected: &[u8]) -> bool {
let Some(obj) = value_object_ptr(value) else {
return false;
};
let kind = js_object_get_field_by_name_f64(obj, named_key(b"__wasmKind"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Root value before named_key can allocate.

Line 255 converts value to a raw ObjectHeader pointer. Line 258 then calls named_key, which allocates a string before the runtime uses that pointer.

A moving GC can invalidate obj. Hold value in a RuntimeHandleScope, allocate the key, and reload the object pointer from the rewritten handle before the property lookup.

Based on learnings, raw Rust pointer locals are not GC roots, so NaN-boxed values must be rooted and reloaded across allocating operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/global_this_webassembly.rs` around lines 254
- 258, Update value_wasm_kind_matches to root the NaN-boxed value in a
RuntimeHandleScope before calling named_key; allocate the key while the value is
rooted, then reload the ObjectHeader pointer from the rewritten handle and use
that pointer for js_object_get_field_by_name_f64. Preserve the existing
non-object early return and wasm-kind comparison behavior.

Source: Learnings

@jdalton

jdalton commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

The brand check keyed only on the enumerable, user-writable __wasmKind string, so a plain object that copies it is a false positive:

({ __wasmKind: "module" }) instanceof WebAssembly.Module            // was true; Node: false
({ __wasmKind: "module", __wasmModulePtr: 1 }) instanceof WebAssembly.Module  // was true; Node: false

(The func_ptr RHS identity is genuinely unforgeable, but the instance brand was not — a string tag is not a brand. The original positive unit test even hand-built such a plain object and asserted Some(true), so it was asserting the forgery.)

Fix: record each real module's host pointer at construction and require __wasmModulePtr to name a registered live module (mirrors the is_registered_buffer/_map/_set side registries the other builtin probes use). A user object carries a pointer this runtime never handed out, so it is rejected and falls through to the ordinary prototype walk → false. The membership test compares by value and never dereferences the candidate pointer, so a forged number is safe. The registry lives in the always-compiled namespace module so the check builds with the engine off (no engine → nothing registered → never matches).

Rewrote the unit test to build a genuine (registered) wrapper and added forgery-rejection cases (tag-only, and both-fields-but-unregistered); added a forged not instanceof Module parity assertion. All 8 global_this_webassembly unit tests pass and node-suite/globals/webassembly-module-metadata parity is byte-identical to Node.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-runtime/src/object/global_this_webassembly.rs (1)

298-338: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

WebAssembly.Module brand is bound to a copyable field value, not to object identity. The check (value_wasm_kind_matches) and the creation site (make_module_object) share one root cause: the brand pointer is stored as a plain enumerable/writable property, so it can be read off a genuine module and copied into a forged object to pass instanceof.

  • crates/perry-runtime/src/object/global_this_webassembly.rs#L298-L338: change value_wasm_kind_matches to validate that obj itself (its ObjectHeader pointer) is a registered wrapper, not that its __wasmModulePtr field's numeric value happens to match a live pointer.
  • crates/perry-runtime/src/webassembly.rs#L302-L313: when registering in make_module_object, key the registry by the wrapper's own object pointer (or store the host pointer in a non-user-accessible side table keyed by the wrapper) instead of relying solely on the copyable __wasmModulePtr field value.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/global_this_webassembly.rs` around lines 298
- 338, The WebAssembly.Module brand currently trusts the copyable
__wasmModulePtr field instead of object identity. In
crates/perry-runtime/src/object/global_this_webassembly.rs:298-338, update
value_wasm_kind_matches to validate obj itself against the registered wrapper
registry; in crates/perry-runtime/src/webassembly.rs:302-313, register each
wrapper’s own object pointer (or use a side table keyed by it) during
make_module_object, while preserving the existing module-kind validation.
crates/perry-runtime/src/webassembly.rs (1)

316-331: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject unregistered __wasmModulePtr before calling the wasm host.

extract_module_handle passes the raw pointer into perry_wasm_host_module_* functions, which dereference it as a WasmModuleHandle in the wasm-host build. A forged object can set __wasmModulePtr to any number, so these static Module metadata methods can dereference an attacker-chosen address. Reject values returned from extract_module_handle that are not registered with is_registered_module_ptr(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/webassembly.rs` around lines 316 - 331, Update
extract_module_handle to validate the decoded __wasmModulePtr with
is_registered_module_ptr(...) before returning it. Return None for null,
non-finite, non-positive, or unregistered pointers, ensuring
perry_wasm_host_module_* callers only receive registered WasmModuleHandle
addresses.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-runtime/src/object/global_this_webassembly.rs`:
- Around line 298-338: The WebAssembly.Module brand currently trusts the
copyable __wasmModulePtr field instead of object identity. In
crates/perry-runtime/src/object/global_this_webassembly.rs:298-338, update
value_wasm_kind_matches to validate obj itself against the registered wrapper
registry; in crates/perry-runtime/src/webassembly.rs:302-313, register each
wrapper’s own object pointer (or use a side table keyed by it) during
make_module_object, while preserving the existing module-kind validation.

In `@crates/perry-runtime/src/webassembly.rs`:
- Around line 316-331: Update extract_module_handle to validate the decoded
__wasmModulePtr with is_registered_module_ptr(...) before returning it. Return
None for null, non-finite, non-positive, or unregistered pointers, ensuring
perry_wasm_host_module_* callers only receive registered WasmModuleHandle
addresses.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e068f724-dd20-46f6-8475-5cdff55d5333

📥 Commits

Reviewing files that changed from the base of the PR and between ee99532 and a701cea.

📒 Files selected for processing (5)
  • changelog.d/7124-wasm-module-instanceof.md
  • crates/perry-runtime/src/object/global_this.rs
  • crates/perry-runtime/src/object/global_this_webassembly.rs
  • crates/perry-runtime/src/webassembly.rs
  • test-parity/node-suite/globals/webassembly-module-metadata.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/object/global_this.rs

…d modules

Under the wasm-host (wasmi) runtime, `new WebAssembly.Module(bytes)` (and
`WebAssembly.compile(bytes)`) returned a plain heap object whose `[[Prototype]]`
does not reach `WebAssembly.Module.prototype`, so `mod instanceof
WebAssembly.Module` walked the prototype chain and answered `false`.

This is the same shape problem the namespace error constructors already work
around (`webassembly_error_ctor_instanceof`): their instances have no prototype
chain reaching the namespace ctor's `.prototype`. Add a sibling
`webassembly_value_ctor_instanceof`, consulted from `js_instanceof_dynamic`
right after the error-ctor hook. It identifies the RHS by its constructor thunk
`func_ptr` (GC-move-safe, not forgeable by a same-named user function) and
short-circuits only on a positive match, so non-matching values still fall
through to the ordinary prototype walk (how `WebAssembly.Memory` instances
resolve, and how a foreign object answers `false`).

The instance brand is an UNFORGEABLE live host-module pointer, not the
enumerable, user-writable `__wasmKind` string. A string tag alone is forgeable:
`{ __wasmKind: "module" }` (or `{ __wasmKind: "module", __wasmModulePtr: 1 }`)
would otherwise answer `true` while Node answers `false`. Each real module's
host pointer is recorded at construction (`make_module_object`) in a side
registry, and the brand check requires the candidate's `__wasmModulePtr` to
name a registered live module -- mirroring the `is_registered_buffer`/`_map`/
`_set` registries the other builtin `instanceof` probes use. A user object
carries a pointer this runtime never handed out, so it is rejected. The
membership test compares by value and never dereferences the candidate pointer,
so a forged number is safe; the registry lives in the always-compiled namespace
module so the check builds with the engine off (no engine -> nothing registered
-> never matches).

Covered constructors: Module (fixed via the registry brand). Memory already
resolves through the prototype walk; Instance/Table/Global throw on construction
in this baseline so no instances exist to brand.

Tests: `value_ctor_instanceof_brands_module_by_registered_ptr` unit test
(genuine registered wrapper positive, plus forgery-rejection cases: tag-only,
and both-fields-but-unregistered), and instanceof assertions in the
`webassembly-module-metadata` parity fixture including a forged-object
negative (verified byte-identical to Node).
@jdalton
jdalton force-pushed the fix/wasm-module-instanceof branch from a701cea to 4cd1848 Compare July 31, 2026 17:29
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.

1 participant