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

Bug

Under the wasm-host (wasmi) runtime (--enable-wasm-runtime / auto-linked when a program uses WebAssembly.*):

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

WebAssembly.compile(bytes) produces the same wrapper and had the same defect.

Root cause

crate::webassembly::make_module_object returns a plain heap object stamped with __wasmKind/__wasmModulePtr but never links its [[Prototype]] to WebAssembly.Module.prototype. The RHS WebAssembly.Module is a member expression, so x instanceof WebAssembly.Module lowers to js_instanceof_dynamic, which ultimately falls back to the ECMAScript OrdinaryHasInstance prototype-chain walk. Because the wrapper's chain never reaches the namespace constructor's .prototype, the walk returned false. This is the exact shape problem the WebAssembly namespace error constructors already work around via webassembly_error_ctor_instanceof (their ErrorHeader-backed instances also have no prototype chain reaching the ctor's .prototype).

Fix

Add a sibling webassembly_value_ctor_instanceof in global_this_webassembly.rs and consult it 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 brand-checks the module wrapper by its __wasmKind == "module" tag. It short-circuits only on a positive match and otherwise returns None, so non-matching values still fall through to the prototype walk. This keeps the behavior additive and non-regressive.

Constructors covered

  • Module — fixed via the __wasmKind brand tag (the only value constructor that produces real instances in this runtime).
  • Memory — already resolves through the prototype walk: the dynamic construct path pre-links Memory.prototype onto the receiver. Verified in the parity fixture (memory instanceof WebAssembly.Memorytrue, module instanceof WebAssembly.Memoryfalse).
  • Instance / Table / Global — throw on construction in this baseline, so no instances exist to brand.

Tests

  • New Rust unit test value_ctor_instanceof_brands_module_by_kind in global_this_webassembly.rs (positive match, cross-brand miss → None, foreign object → None, non-ctor RHS → None).
  • instanceof assertions added to the test-parity/node-suite/globals/webassembly-module-metadata.ts fixture (the wasm-host new WebAssembly.Module(...) path). Output verified byte-identical to Node.

Verification run

Before: module instanceof Module: false. After: module instanceof Module: true / compiled instanceof Module: true. All 8 global_this_webassembly unit tests pass; the default no-engine graceful-fail path (tests/test_webassembly_graceful_fail.sh) still passes.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed instanceof checks for WebAssembly.Module under the Wasmi runtime.
    • Improved recognition of genuine WebAssembly modules while rejecting forged or unrelated objects.
    • Preserved standard prototype-chain behavior for other values and constructors.
    • Added coverage for constructed, asynchronously compiled, cross-type, foreign-object, and WebAssembly.Memory checks.
    • Improved reliability of WebAssembly type checks across supported compilation and construction paths.
  • Documentation

    • Added a changelog entry describing the WebAssembly instanceof fix.

@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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4286cd78-0941-4aa8-b7a7-59aa8e7ab9f5

📥 Commits

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

📒 Files selected for processing (6)
  • 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
  • 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 (6)
  • crates/perry-runtime/src/webassembly.rs
  • test-parity/node-suite/globals/webassembly-module-metadata.ts
  • changelog.d/7124-wasm-module-instanceof.md
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/global_this.rs
  • crates/perry-runtime/src/object/global_this_webassembly.rs

📝 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
Title check ✅ Passed The title clearly and concisely identifies the primary fix for WebAssembly.Module instanceof behavior.
Description check ✅ Passed The description clearly explains the bug, root cause, fix, affected constructors, tests, and verification results, despite not using all template headings.
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.
✨ 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