Repository: nodejs/node
Pull request: crypto: fix cross-realm ArrayBuffer validation

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->

## Problem

The Web Crypto API in Node.js v22+ fails to properly validate ArrayBuffer instances
created in different JavaScript realms. When an ArrayBuffer from a different realm
is passed to SubtleCrypto.digest(), it fails with:


> TypeError: Failed to execute 'digest' on 'SubtleCrypto': 2nd argument is not instance of ArrayBuffer, Buffer, TypedArray, or DataView.

This is a common issue when:
1. Using TypedArray.buffer with ArrayBuffers created in worker threads
2. Using TypedArray.buffer with ArrayBuffers created in VM contexts
3. In some cases, when libraries load in different contexts in bundled applications

The problem is in `isNonSharedArrayBuffer()` which checks using prototype 
inheritance (`ObjectPrototypeIsPrototypeOf(ArrayBufferPrototype, V)`) which fails 
for cross-realm objects, even though structurally they are valid ArrayBuffers.

## Solution

This PR modifies `isNonSharedArrayBuffer()` to use `isArrayBuffer` from `internal/util/types` to properly check for cross-realm objects.

## Testing

Added a new test that verifies that ArrayBuffers created in different VM contexts
are correctly recognized as valid inputs to SubtleCrypto.digest().


## Workaround

Until this fix is merged, users can work around this issue by wrapping cross-realm ArrayBuffers in a TypedArray view before passing them to WebCrypto functions. Here's a simple example demonstrating the workaround:

```javascript
'use strict';

// Create a cross-realm ArrayBuffer
const vm = require('vm');
const context = vm.createContext({});
const crossRealmBuffer = vm.runInContext('new ArrayBuffer(16)', context);

// Fill it with some data
const view = new Uint8Array(crossRealmBuffer);
for (let i = 0; i < view.length; i++) {
  view[i] = i % 256;
}

// This will fail without the fix:
// crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, crossRealmBuffer);

// Workaround: Use the TypedArray view instead
const ciphertext = await crypto.subtle.encrypt(
  { name: 'AES-GCM', iv },
  key,
  view  // Use the view instead of the cross-realm buffer
);
```

Ref: https://github.com/storacha/w3up/issues/1591


Changed files:
filename: lib/internal/crypto/webidl.js
patch: @@ -12,7 +12,6 @@
 
 const {
   ArrayBufferIsView,
-  ArrayBufferPrototype,
   ArrayPrototypeIncludes,
   ArrayPrototypePush,
   ArrayPrototypeSort,
@@ -48,6 +47,7 @@ const {
   validateMaxBufferLength,
   kNamedCurveAliases,
 } = require('internal/crypto/util');
+const { isArrayBuffer } = require('internal/util/types');
 
 // https://tc39.es/ecma262/#sec-tonumber
 function toNumber(value, opts = kEmptyObject) {
@@ -193,9 +193,7 @@ converters.object = (V, opts) => {
   return V;
 };
 
-function isNonSharedArrayBuffer(V) {
-  return ObjectPrototypeIsPrototypeOf(ArrayBufferPrototype, V);
-}
+const isNonSharedArrayBuffer = isArrayBuffer;
 
 function isSharedArrayBuffer(V) {
   // SharedArrayBuffers can be disabled with --no-harmony-sharedarraybuffer.

filename: test/parallel/test-crypto-subtle-cross-realm.js
patch: @@ -0,0 +1,153 @@
+'use strict';
+// Flags: --expose-internals
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const { subtle } = globalThis.crypto;
+const vm = require('vm');
+const { isArrayBuffer } = require('internal/util/types');
+
+// Test with same-realm ArrayBuffer
+{
+  const samerealmData = new Uint8Array([1, 2, 3, 4]).buffer;
+
+  subtle.digest('SHA-256', samerealmData)
+    .then(common.mustCall((result) => {
+      assert(isArrayBuffer(result));
+      assert.strictEqual(result.byteLength, 32); // SHA-256 is 32 bytes
+    }));
+}
+
+// Test with cross-realm ArrayBuffer
+{
+  const context = vm.createContext({});
+  const crossrealmUint8Array = vm.runInContext('new Uint8Array([1, 2, 3, 4])', context);
+  const crossrealmBuffer = crossrealmUint8Array.buffer;
+
+  // Verify it's truly cross-realm
+  assert.notStrictEqual(
+    Object.getPrototypeOf(crossrealmBuffer),
+    ArrayBuffer.prototype
+  );
+
+  // This should still work, since we're checking structural type
+  subtle.digest('SHA-256', crossrealmBuffer)
+    .then(common.mustCall((result) => {
+      assert(isArrayBuffer(result));
+      assert.strictEqual(result.byteLength, 32); // SHA-256 is 32 bytes
+    }));
+}
+
+// Test with both TypedArray buffer methods
+{
+  const context = vm.createContext({});
+  const crossrealmUint8Array = vm.runInContext('new Uint8Array([1, 2, 3, 4])', context);
+
+  // Test the .buffer property
+  subtle.digest('SHA-256', crossrealmUint8Array.buffer)
+    .then(common.mustCall((result) => {
+      assert(isArrayBuffer(result));
+      assert.strictEqual(result.byteLength, 32);
+    }));
+
+  // Test passing the TypedArray directly (should work both before and after the fix)
+  subtle.digest('SHA-256', crossrealmUint8Array)
+    .then(common.mustCall((result) => {
+      assert(isArrayBuffer(result));
+      assert.strictEqual(result.byteLength, 32);
+    }));
+}
+
+// Test with AES-GCM encryption/decryption using cross-realm ArrayBuffer
+{
+  const context = vm.createContext({});
+  const crossRealmBuffer = vm.runInContext('new ArrayBuffer(16)', context);
+
+  // Fill the buffer with some data
+  const dataView = new Uint8Array(crossRealmBuffer);
+  for (let i = 0; i < dataView.length; i++) {
+    dataView[i] = i % 256;
+  }
+
+  // Generate a key
+  subtle.generateKey({
+    name: 'AES-GCM',
+    length: 256
+  }, true, ['encrypt', 'decrypt'])
+    .then(common.mustCall((key) => {
+      // Create an initialization vector
+      const iv = crypto.getRandomValues(new Uint8Array(12));
+
+      // Encrypt using the cross-realm ArrayBuffer
+      return subtle.encrypt(
+        { name: 'AES-GCM', iv },
+        key,
+        crossRealmBuffer
+      ).then((ciphertext) => {
+        // Decrypt
+        return subtle.decrypt(
+          { name: 'AES-GCM', iv },
+          key,
+          ciphertext
+        );
+      }).then(common.mustCall((plaintext) => {
+        // Verify the decrypted content matches original
+        const decryptedView = new Uint8Array(plaintext);
+        for (let i = 0; i < dataView.length; i++) {
+          assert.strictEqual(
+            decryptedView[i],
+            dataView[i],
+            `Byte at position ${i} doesn't match`
+          );
+        }
+      }));
+    }));
+}
+
+// Test with AES-GCM using TypedArray view of cross-realm ArrayBuffer
+{
+  const context = vm.createContext({});
+  const crossRealmBuffer = vm.runInContext('new ArrayBuffer(16)', context);
+
+  // Fill the buffer with some data
+  const dataView = new Uint8Array(crossRealmBuffer);
+  for (let i = 0; i < dataView.length; i++) {
+    dataView[i] = i % 256;
+  }
+
+  // Generate a key
+  subtle.generateKey({
+    name: 'AES-GCM',
+    length: 256
+  }, true, ['encrypt', 'decrypt'])
+    .then(common.mustCall((key) => {
+      // Create an initialization vector
+      const iv = crypto.getRandomValues(new Uint8Array(12));
+
+      // Encrypt using the TypedArray view of cross-realm ArrayBuffer
+      return subtle.encrypt(
+        { name: 'AES-GCM', iv },
+        key,
+        dataView
+      ).then((ciphertext) => {
+        // Decrypt
+        return subtle.decrypt(
+          { name: 'AES-GCM', iv },
+          key,
+          ciphertext
+        );
+      }).then(common.mustCall((plaintext) => {
+        // Verify the decrypted content matches original
+        const decryptedView = new Uint8Array(plaintext);
+        for (let i = 0; i < dataView.length; i++) {
+          assert.strictEqual(
+            decryptedView[i],
+            dataView[i],
+            `Byte at position ${i} doesn't match`
+          );
+        }
+      }));
+    }));
+}