Commit 3a4454151a5 for nodejs

commit 3a4454151a5e551d2f4fb2f7518343f6e645a419
Author: James M Snell <jasnell@gmail.com>
Date:   Sun Sep 27 03:17:43 2026 +0000

    http: add websocketMask() and websocketUnmask()

    Add websocketMask(source, mask, output[, offset[, length]]) and
    websocketUnmask(buffer, mask), which XOR data with a repeating 4-byte
    key. This is the masking that WebSocket clients apply to every frame
    they send (RFC 6455, Section 5.3), and that servers undo on every frame
    they receive.

    Userland WebSocket implementations do this either with a byte-by-byte
    JS loop (undici, and ws without optional dependencies) or with the
    bufferutil native addon. bufferutil is installed for only about 3% of
    ws downloads and has no linux-arm64 prebuild, so almost all users run
    the JS loop. The signature matches bufferutil, so existing users can
    switch with a feature check, as ws did for buffer.isUtf8().

    The implementation uses a V8 fast API call and processes 8-byte words,
    which the compiler vectorizes. It handles overlapping source and output
    views, and treats every view type as raw bytes. It is about 20x faster
    than the JS loop at 1 KiB and 40x faster at 64 KiB, and faster than
    bufferutil at every size from 32 bytes up.

    Signed-off-by: James M Snell <jasnell@gmail.com>
    Assisted-by: Opencode
    PR-URL: https://github.com/nodejs/node/pull/66335
    Reviewed-By: Filip Skokan <panva.ip@gmail.com>
    Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
    Reviewed-By: Matthew Aitken <maitken033380023@gmail.com>
    Reviewed-By: Robert Nagy <ronagy@icloud.com>

diff --git a/benchmark/http/websocket-mask.js b/benchmark/http/websocket-mask.js
new file mode 100644
index 00000000000..68af6541bbd
--- /dev/null
+++ b/benchmark/http/websocket-mask.js
@@ -0,0 +1,56 @@
+'use strict';
+
+const common = require('../common.js');
+const { websocketMask, websocketUnmask } = require('node:http');
+
+const bench = common.createBenchmark(main, {
+  type: ['mask', 'unmask'],
+  // 'http' uses http.websocketMask() / http.websocketUnmask(). 'js' is the
+  // byte-by-byte loop that userland WebSocket implementations fall back to
+  // without a native addon, for comparison.
+  impl: ['http', 'js'],
+  len: [4, 16, 125, 1024, 16384, 65536],
+  n: [1e6],
+});
+
+function jsMask(source, key, output, offset, length) {
+  for (let i = 0; i < length; i++) {
+    output[offset + i] = source[i] ^ key[i & 3];
+  }
+}
+
+function jsUnmask(buffer, key) {
+  for (let i = 0; i < buffer.length; i++) {
+    buffer[i] ^= key[i & 3];
+  }
+}
+
+function main({ n, type, impl, len }) {
+  const key = Buffer.from([0x12, 0x34, 0x56, 0x78]);
+  const source = Buffer.alloc(len, 'abcdefg');
+  // Leave room for a WebSocket frame header before the payload.
+  const output = Buffer.alloc(len + 14);
+
+  switch (type) {
+    case 'mask': {
+      const fn = impl === 'http' ? websocketMask : jsMask;
+      bench.start();
+      for (let i = 0; i < n; i++) {
+        fn(source, key, output, 14, len);
+      }
+      bench.end(n);
+      break;
+    }
+    case 'unmask': {
+      const fn = impl === 'http' ? websocketUnmask : jsUnmask;
+      bench.start();
+      for (let i = 0; i < n; i++) {
+        fn(source, key);
+      }
+      bench.end(n);
+      break;
+    }
+    default:
+      throw new Error(`Unexpected type: ${type}`);
+  }
+}
diff --git a/doc/api/http.md b/doc/api/http.md
index 5351b05eeaa..e3abe0b1a43 100644
--- a/doc/api/http.md
+++ b/doc/api/http.md
@@ -4637,6 +4637,92 @@ requests are made and avoid invoking it in the middle of any requests.
 See [Built-in Proxy Support][] for details on proxy URL formats and `NO_PROXY`
 syntax.

+## `http.websocketMask(source, mask, output, offset, length)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `source` {Buffer|TypedArray|DataView} The data to mask.
+* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.
+* `output` {Buffer|TypedArray|DataView} Where to write the masked data.
+* `offset` {integer} Byte offset in `output` at which to start writing.
+* `length` {integer} Number of bytes of `source` to mask.
+
+XORs the first `length` bytes of `source` with `mask`, repeated, and writes the
+result to `output` starting at `offset`: byte `i` of `source` is XORed with
+byte `i % 4` of `mask`. This is the masking operation that WebSocket clients
+apply to every frame payload they send ([RFC 6455, Section 5.3][]), and that
+servers undo on every frame they receive. Applying it twice with the same
+`mask` restores the original data. `source` is not modified, unless it shares
+memory with `output`.
+
+All arguments are treated as raw bytes, whatever the view type. `source` and
+`output` may be the same view or overlapping views over the same memory; the
+result is the same as if `source` had been copied first.
+
+An error is thrown if `mask` is not exactly 4 bytes long, if `length` is
+greater than `source.byteLength`, if `offset + length` is greater than
+`output.byteLength`, or if `output` is backed by an immutable `ArrayBuffer`.
+Nothing is written in that case. `source` and `mask` may be backed by an
+immutable `ArrayBuffer`.
+
+```mjs
+import { Buffer } from 'node:buffer';
+import { websocketMask, websocketUnmask } from 'node:http';
+
+const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
+const payload = Buffer.from('Hello');
+
+// Write a masked copy of the payload after a 6-byte frame header.
+const frame = Buffer.alloc(6 + payload.length);
+websocketMask(payload, key, frame, 6, payload.length);
+console.log(frame.subarray(6));
+// Prints: <Buffer 7f 9f 4d 51 58>
+
+const received = frame.subarray(6);
+websocketUnmask(received, key);
+console.log(received.toString());
+// Prints: Hello
+```
+
+```cjs
+const { Buffer } = require('node:buffer');
+const { websocketMask, websocketUnmask } = require('node:http');
+
+const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
+const payload = Buffer.from('Hello');
+
+// Write a masked copy of the payload after a 6-byte frame header.
+const frame = Buffer.alloc(6 + payload.length);
+websocketMask(payload, key, frame, 6, payload.length);
+console.log(frame.subarray(6));
+// Prints: <Buffer 7f 9f 4d 51 58>
+
+const received = frame.subarray(6);
+websocketUnmask(received, key);
+console.log(received.toString());
+// Prints: Hello
+```
+
+## `http.websocketUnmask(buffer, mask)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `buffer` {Buffer|TypedArray|DataView} The data to unmask, in place.
+* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.
+
+XORs every byte of `buffer` with `mask`, repeated, in place: byte `i` is XORed
+with byte `i % 4` of `mask`. This is equivalent to
+`http.websocketMask(buffer, mask, buffer, 0, buffer.byteLength)`, and is
+typically used to unmask a received WebSocket frame payload
+([RFC 6455, Section 5.3][]). See [`http.websocketMask()`][] for an example.
+
+An error is thrown, and nothing is written, if `mask` is not exactly 4 bytes
+long or if `buffer` is backed by an immutable `ArrayBuffer`.
+
 ## Class: `WebSocket`

 <!-- YAML
@@ -4845,6 +4931,7 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
 ```

 [Built-in Proxy Support]: #built-in-proxy-support
+[RFC 6455, Section 5.3]: https://datatracker.ietf.org/doc/html/rfc6455#section-5.3
 [RFC 8187]: https://www.rfc-editor.org/rfc/rfc8187.txt
 [RFC 9110 Section 6.6.1]: https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1
 [`'ERR_HTTP_CONTENT_LENGTH_MISMATCH'`]: errors.md#err_http_content_length_mismatch
@@ -4880,6 +4967,7 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
 [`http.setGlobalProxyFromEnv()`]: #httpsetglobalproxyfromenvproxyenv
 [`http.validateHeaderName()`]: #httpvalidateheadernamename-label
 [`http.validateHeaderValue()`]: #httpvalidateheadervaluename-value
+[`http.websocketMask()`]: #httpwebsocketmasksource-mask-output-offset-length
 [`message.headers`]: #messageheaders
 [`message.rawHeaders`]: #messagerawheaders
 [`message.socket`]: #messagesocket
diff --git a/lib/http.js b/lib/http.js
index 783366e9fac..726e3e30933 100644
--- a/lib/http.js
+++ b/lib/http.js
@@ -23,6 +23,7 @@

 const {
   ObjectDefineProperty,
+  Uint8Array,
 } = primordials;

 const { validateInteger, validateObject } = require('internal/validators');
@@ -30,7 +31,17 @@ const httpAgent = require('_http_agent');
 const { ClientRequest } = require('_http_client');
 const { methods, parsers } = require('_http_common');
 const { IncomingMessage } = require('_http_incoming');
-const { ERR_PROXY_INVALID_CONFIG } = require('internal/errors').codes;
+const {
+  ERR_INVALID_ARG_TYPE,
+  ERR_INVALID_ARG_VALUE,
+  ERR_OUT_OF_RANGE,
+  ERR_PROXY_INVALID_CONFIG,
+} = require('internal/errors').codes;
+const {
+  isArrayBufferView,
+  isUint8Array,
+} = require('internal/util/types');
+const { mask: bindingMask } = internalBinding('buffer');
 const {
   isValidHeaderName,
   isValidHeaderValue,
@@ -183,6 +194,71 @@ function setGlobalProxyFromEnv(env = process.env) {
   };
 }

+/**
+ * Packs a 4-byte mask into a uint32, with byte k in bits 8k..8k+7.
+ * @param {ArrayBufferView} key
+ * @returns {number}
+ */
+function maskKeyToUint32(key) {
+  if (!isArrayBufferView(key)) {
+    throw new ERR_INVALID_ARG_TYPE('mask', ['Buffer', 'TypedArray', 'DataView'], key);
+  }
+  if (key.byteLength !== 4) {
+    throw new ERR_INVALID_ARG_VALUE('mask', key, 'must be 4 bytes long');
+  }
+  const bytes = isUint8Array(key) ?
+    /** @type {Uint8Array} */ (key) :
+    new Uint8Array(key.buffer, key.byteOffset, 4);
+  return (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >>> 0;
+}
+
+/**
+ * XORs `length` bytes of `source` with the repeating 4-byte `key` and writes
+ * the result to `output` at `offset` (RFC 6455, Section 5.3).
+ * @param {ArrayBufferView} source
+ * @param {ArrayBufferView} key
+ * @param {ArrayBufferView} output
+ * @param {number} offset
+ * @param {number} length
+ * @returns {void}
+ */
+function websocketMask(source, key, output, offset, length) {
+  if (!isArrayBufferView(source)) {
+    throw new ERR_INVALID_ARG_TYPE('source', ['Buffer', 'TypedArray', 'DataView'], source);
+  }
+  const maskValue = maskKeyToUint32(key);
+  if (!isArrayBufferView(output)) {
+    throw new ERR_INVALID_ARG_TYPE('output', ['Buffer', 'TypedArray', 'DataView'], output);
+  }
+  const outputLength = output.byteLength;
+  validateInteger(offset, 'offset', 0, outputLength);
+  validateInteger(length, 'length', 0, source.byteLength);
+  if (length > outputLength - offset) {
+    throw new ERR_OUT_OF_RANGE('length', `<= ${outputLength - offset}`, length);
+  }
+  if (!bindingMask(source, output, offset, length, maskValue)) {
+    throw new ERR_INVALID_ARG_VALUE(
+      'output', output, 'must not be backed by an immutable ArrayBuffer');
+  }
+}
+
+/**
+ * XORs every byte of `buffer` with the repeating 4-byte `key`, in place.
+ * @param {ArrayBufferView} buffer
+ * @param {ArrayBufferView} key
+ * @returns {void}
+ */
+function websocketUnmask(buffer, key) {
+  if (!isArrayBufferView(buffer)) {
+    throw new ERR_INVALID_ARG_TYPE('buffer', ['Buffer', 'TypedArray', 'DataView'], buffer);
+  }
+  const maskValue = maskKeyToUint32(key);
+  if (!bindingMask(buffer, buffer, 0, buffer.byteLength, maskValue)) {
+    throw new ERR_INVALID_ARG_VALUE(
+      'buffer', buffer, 'must not be backed by an immutable ArrayBuffer');
+  }
+}
+
 module.exports = {
   _connectionListener,
   METHODS: methods.toSorted(),
@@ -205,6 +281,8 @@ module.exports = {
     parsers.max = max;
   },
   setGlobalProxyFromEnv,
+  websocketMask,
+  websocketUnmask,
 };

 ObjectDefineProperty(module.exports, 'maxHeaderSize', {
diff --git a/src/node_buffer.cc b/src/node_buffer.cc
index 5aee8dced2f..69b4c0ac525 100644
--- a/src/node_buffer.cc
+++ b/src/node_buffer.cc
@@ -1436,6 +1436,126 @@ static bool FastIsLatin1(Local<Value> receiver, Local<Value> value) {

 static CFunction fast_is_latin1(CFunction::Make(FastIsLatin1));

+// Copies `length` bytes from `source` to `destination`, XORing byte i with
+// byte (i % 4) of `mask`, as done for WebSocket frame payloads (RFC 6455,
+// Section 5.3). The mask bytes are packed little-endian into a uint32, so mask
+// byte k is (mask >> (8 * k)) & 0xff regardless of platform endianness.
+// `source` and `destination` may be the same memory or otherwise overlap.
+static void MaskImpl(const uint8_t* source,
+                     uint8_t* destination,
+                     size_t length,
+                     uint32_t mask) {
+  uint8_t pattern[8];
+  for (size_t i = 0; i < 8; i++) {
+    pattern[i] = static_cast<uint8_t>(mask >> (8 * (i & 3)));
+  }
+  uint64_t pattern64;
+  memcpy(&pattern64, pattern, sizeof(pattern64));
+
+  // A forward pass is safe when the destination starts at or before the
+  // source: every source chunk is read before any overlapping byte is
+  // written. Otherwise, take a copy of the source first.
+  std::unique_ptr<uint8_t[]> copy;
+  if (destination > source && destination < source + length) [[unlikely]] {
+    copy.reset(new uint8_t[length]);
+    memcpy(copy.get(), source, length);
+    source = copy.get();
+  }
+
+  // Chunks start at multiples of 8, so the mask phase of every chunk is 0.
+  // memcpy() is used for unaligned loads and stores, and lets the compiler
+  // vectorize the loop.
+  size_t i = 0;
+  for (; i + 32 <= length; i += 32) {
+    uint64_t a, b, c, d;
+    memcpy(&a, source + i, 8);
+    memcpy(&b, source + i + 8, 8);
+    memcpy(&c, source + i + 16, 8);
+    memcpy(&d, source + i + 24, 8);
+    a ^= pattern64;
+    b ^= pattern64;
+    c ^= pattern64;
+    d ^= pattern64;
+    memcpy(destination + i, &a, 8);
+    memcpy(destination + i + 8, &b, 8);
+    memcpy(destination + i + 16, &c, 8);
+    memcpy(destination + i + 24, &d, 8);
+  }
+  for (; i + 8 <= length; i += 8) {
+    uint64_t v;
+    memcpy(&v, source + i, sizeof(v));
+    v ^= pattern64;
+    memcpy(destination + i, &v, sizeof(v));
+  }
+  for (; i < length; i++) destination[i] = source[i] ^ pattern[i & 3];
+}
+
+// Arguments are validated in JS: source and destination are ArrayBufferViews,
+// offset + length <= destination.byteLength and length <= source.byteLength.
+// Returns false, without writing anything, if the destination is backed by an
+// immutable ArrayBuffer.
+static bool MaskArgs(Local<Value> source_obj,
+                     Local<Value> destination_obj,
+                     size_t offset,
+                     size_t length,
+                     uint32_t mask) {
+  CHECK(destination_obj->IsArrayBufferView());
+  Local<ArrayBufferView> destination = destination_obj.As<ArrayBufferView>();
+  Local<ArrayBuffer> destination_ab = destination->Buffer();
+  if (destination_ab->IsImmutable()) return false;
+  if (length == 0) return true;
+  const size_t destination_length = destination->ByteLength();
+  CHECK_LE(offset, destination_length);
+  CHECK_LE(length, destination_length - offset);
+  uint8_t* destination_data =
+      static_cast<uint8_t*>(destination_ab->Data()) + destination->ByteOffset();
+  CHECK_NOT_NULL(destination_data);
+  uint8_t* dest = destination_data + offset;
+  if (source_obj == destination_obj) {
+    MaskImpl(destination_data, dest, length, mask);
+    return true;
+  }
+  SPREAD_BUFFER_ARG(source_obj, source);
+  CHECK_LE(length, source_length);
+  MaskImpl(reinterpret_cast<const uint8_t*>(source_data), dest, length, mask);
+  return true;
+}
+
+// mask(source, destination, offset, length, mask)
+static void Mask(const FunctionCallbackInfo<Value>& args) {
+  CHECK_EQ(args.Length(), 5);
+  CHECK(args[2]->IsNumber());
+  CHECK(args[3]->IsNumber());
+  CHECK(args[4]->IsUint32());
+  // Offsets and lengths can exceed uint32 for buffers larger than 4 GiB, so
+  // they are passed as doubles (exact for integers < 2^53).
+  args.GetReturnValue().Set(
+      MaskArgs(args[0],
+               args[1],
+               static_cast<size_t>(args[2].As<Number>()->Value()),
+               static_cast<size_t>(args[3].As<Number>()->Value()),
+               args[4].As<Uint32>()->Value()));
+}
+
+static bool FastMask(Local<Value> receiver,
+                     Local<Value> source_obj,
+                     Local<Value> destination_obj,
+                     double offset,
+                     double length,
+                     uint32_t mask,
+                     // NOLINTNEXTLINE(runtime/references)
+                     FastApiCallbackOptions& options) {
+  TRACK_V8_FAST_API_CALL("buffer.mask");
+  HandleScope scope(options.isolate);
+  return MaskArgs(source_obj,
+                  destination_obj,
+                  static_cast<size_t>(offset),
+                  static_cast<size_t>(length),
+                  mask);
+}
+
+static CFunction fast_mask(CFunction::Make(FastMask));
+
 // Number of UTF-16 code units produced by decoding [p, end) as UTF-8 with
 // WHATWG "maximal subpart" U+FFFD replacement, matching the fallback that
 // StringBytes::Encode takes for invalid input (v8::String::NewFromUtf8).
@@ -1952,6 +2072,7 @@ void Initialize(Local<Object> target,
       context, target, "isAscii", IsAscii, &fast_is_ascii);
   SetFastMethodNoSideEffect(
       context, target, "isLatin1", IsLatin1, &fast_is_latin1);
+  SetFastMethod(context, target, "mask", Mask, &fast_mask);
   SetFastMethodNoSideEffect(context,
                             target,
                             "stringLengthUtf8",
@@ -2034,6 +2155,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
   registry->Register(fast_is_ascii);
   registry->Register(IsLatin1);
   registry->Register(fast_is_latin1);
+  registry->Register(Mask);
+  registry->Register(fast_mask);
   registry->Register(StringLengthUtf8);
   registry->Register(fast_string_length_utf8);

diff --git a/test/parallel/test-http-websocket-mask-fast.js b/test/parallel/test-http-websocket-mask-fast.js
new file mode 100644
index 00000000000..0d2f97c321a
--- /dev/null
+++ b/test/parallel/test-http-websocket-mask-fast.js
@@ -0,0 +1,38 @@
+// Flags: --expose-internals --no-warnings --allow-natives-syntax
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { websocketMask, websocketUnmask } = require('http');
+
+const key = Buffer.from([1, 2, 3, 4]);
+const source = Buffer.from('hello world, this is a masking test');
+const expected = Buffer.from(source.map((b, i) => b ^ key[i & 3]));
+
+function testFastMask() {
+  const output = Buffer.alloc(source.length);
+  websocketMask(source, key, output, 0, source.length);
+  assert.deepStrictEqual(output, expected);
+}
+
+function testFastUnmask() {
+  const buf = Buffer.from(expected);
+  websocketUnmask(buf, key);
+  assert.deepStrictEqual(buf, source);
+}
+
+eval('%PrepareFunctionForOptimization(websocketMask)');
+testFastMask();
+eval('%OptimizeFunctionOnNextCall(websocketMask)');
+testFastMask();
+
+eval('%PrepareFunctionForOptimization(websocketUnmask)');
+testFastUnmask();
+eval('%OptimizeFunctionOnNextCall(websocketUnmask)');
+testFastUnmask();
+
+if (common.isDebug) {
+  const { internalBinding } = require('internal/test/binding');
+  const { getV8FastApiCallCount } = internalBinding('debug');
+  assert.strictEqual(getV8FastApiCallCount('buffer.mask'), 2);
+}
diff --git a/test/parallel/test-http-websocket-mask-immutable.js b/test/parallel/test-http-websocket-mask-immutable.js
new file mode 100644
index 00000000000..2966afb934c
--- /dev/null
+++ b/test/parallel/test-http-websocket-mask-immutable.js
@@ -0,0 +1,91 @@
+// Flags: --js-immutable-arraybuffer --allow-natives-syntax
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const { websocketMask, websocketUnmask } = require('http');
+
+// transferToImmutable is gated behind --js-immutable-arraybuffer (set above).
+// Skip if this V8 build does not expose the API even with the flag set.
+if (typeof ArrayBuffer.prototype.transferToImmutable !== 'function')
+  common.skip('ArrayBuffer.prototype.transferToImmutable is not available');
+
+const key = Buffer.from([1, 2, 3, 4]);
+
+function immutable(bytes) {
+  const ab = new ArrayBuffer(bytes.length);
+  new Uint8Array(ab).set(bytes);
+  return ab.transferToImmutable();
+}
+
+// Masking *into* a view of an immutable ArrayBuffer throws and does not write
+// to the read-only backing store, whatever the view type, offset or length.
+{
+  const bytes = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
+  const ab = immutable(bytes);
+  const targets = [
+    Buffer.from(ab),
+    new Uint8Array(ab),
+    new Uint32Array(ab),
+    new DataView(ab),
+    Buffer.from(ab, 4, 8),
+  ];
+  for (const target of targets) {
+    const expectedError = {
+      name: 'TypeError',
+      code: 'ERR_INVALID_ARG_VALUE',
+    };
+    const length = target.byteLength;
+    assert.throws(() => websocketMask(Buffer.alloc(length, 9), key, target, 0, length),
+                  { ...expectedError, message: /'output' must not be backed by an immutable ArrayBuffer/ });
+    assert.throws(() => websocketMask(Buffer.alloc(4, 9), key, target, 2, 2),
+                  expectedError);
+    // Zero-length writes are rejected too, as with TypedArray.prototype.fill().
+    assert.throws(() => websocketMask(Buffer.alloc(0), key, target, 0, 0), expectedError);
+    assert.throws(() => websocketUnmask(target, key),
+                  { ...expectedError, message: /'buffer' must not be backed by an immutable ArrayBuffer/ });
+    assert.deepStrictEqual([...new Uint8Array(ab)], bytes);
+  }
+
+  // Masking a view in place onto itself is a write, so it throws too.
+  const view = Buffer.from(ab);
+  assert.throws(() => websocketMask(view, key, view, 0, view.length), { code: 'ERR_INVALID_ARG_VALUE' });
+  assert.deepStrictEqual([...new Uint8Array(ab)], bytes);
+}
+
+// Masking *from* a view of an immutable ArrayBuffer is allowed, since reads
+// do not require a writable backing store.
+{
+  const source = Buffer.from(immutable([10, 20, 30, 40, 50, 60, 70, 80]));
+  const output = Buffer.alloc(8);
+  websocketMask(source, key, output, 0, 8);
+  assert.deepStrictEqual([...output], [11, 22, 29, 44, 51, 62, 69, 84]);
+  assert.deepStrictEqual([...source], [10, 20, 30, 40, 50, 60, 70, 80]);
+}
+
+// The mask itself may be backed by an immutable ArrayBuffer.
+{
+  const immutableKey = new Uint8Array(immutable([1, 2, 3, 4]));
+  const buf = Buffer.from([10, 20, 30, 40]);
+  websocketUnmask(buf, immutableKey);
+  assert.deepStrictEqual([...buf], [11, 22, 29, 44]);
+}
+
+// The optimized (fast API) path also rejects immutable targets.
+{
+  const target = Buffer.from(immutable([1, 2, 3, 4, 5, 6, 7, 8]));
+  const source = Buffer.alloc(8, 9);
+  function tryMask(output) {
+    try {
+      websocketMask(source, key, output, 0, 8);
+      return true;
+    } catch (err) {
+      assert.strictEqual(err.code, 'ERR_INVALID_ARG_VALUE');
+      return false;
+    }
+  }
+  eval('%PrepareFunctionForOptimization(websocketMask)');
+  assert.strictEqual(tryMask(Buffer.alloc(8)), true);
+  eval('%OptimizeFunctionOnNextCall(websocketMask)');
+  assert.strictEqual(tryMask(target), false);
+  assert.deepStrictEqual([...target], [1, 2, 3, 4, 5, 6, 7, 8]);
+}
diff --git a/test/parallel/test-http-websocket-mask.js b/test/parallel/test-http-websocket-mask.js
new file mode 100644
index 00000000000..962ef17d573
--- /dev/null
+++ b/test/parallel/test-http-websocket-mask.js
@@ -0,0 +1,205 @@
+'use strict';
+
+require('../common');
+const assert = require('assert');
+const { websocketMask, websocketUnmask } = require('http');
+
+// Reference implementation (RFC 6455, Section 5.3).
+function referenceMask(source, key, length = source.length) {
+  const out = Buffer.alloc(length);
+  for (let i = 0; i < length; i++) out[i] = source[i] ^ key[i & 3];
+  return out;
+}
+
+function randomBytes(length) {
+  const buf = Buffer.alloc(length);
+  for (let i = 0; i < length; i++) buf[i] = (Math.random() * 256) | 0;
+  return buf;
+}
+
+const key = Buffer.from([0x12, 0x34, 0x56, 0x78]);
+
+// Sizes around the 8- and 32-byte chunk boundaries, and a large buffer.
+const sizes = [];
+for (let i = 0; i <= 80; i++) sizes.push(i);
+sizes.push(125, 126, 127, 1023, 1024, 1025, 65535, 65536, 65537, 1024 * 1024 + 3);
+
+// websocketMask() and websocketUnmask() match the reference, for source and
+// output views at every alignment.
+for (const size of sizes) {
+  const data = randomBytes(size);
+  const expected = referenceMask(data, key);
+  for (const align of size > 4096 ? [0, 3] : [0, 1, 2, 3, 4, 5, 6, 7]) {
+    const source = Buffer.alloc(size + align).subarray(align);
+    data.copy(source);
+    const output = Buffer.alloc(size + 8).subarray(7 - align, 7 - align + size);
+    websocketMask(source, key, output, 0, size);
+    assert.deepStrictEqual(output, expected, `mask size=${size} align=${align}`);
+    // The source must not be modified.
+    assert.deepStrictEqual(source, data);
+
+    websocketUnmask(output, key);
+    assert.deepStrictEqual(output, data, `unmask size=${size} align=${align}`);
+  }
+}
+
+// Masking twice with the same key is the identity; websocketMask() in place works.
+{
+  const data = randomBytes(1000);
+  const buf = Buffer.from(data);
+  websocketMask(buf, key, buf, 0, buf.length);
+  assert.deepStrictEqual(buf, referenceMask(data, key));
+  websocketMask(buf, key, buf, 0, buf.length);
+  assert.deepStrictEqual(buf, data);
+}
+
+// The offset and length arguments.
+{
+  const data = randomBytes(100);
+  const output = Buffer.alloc(110, 0xaa);
+  websocketMask(data, key, output, 6, 90);
+  assert.deepStrictEqual(output.subarray(0, 6), Buffer.alloc(6, 0xaa));
+  assert.deepStrictEqual(output.subarray(6, 96), referenceMask(data, key, 90));
+  assert.deepStrictEqual(output.subarray(96), Buffer.alloc(14, 0xaa));
+}
+
+// Overlapping source and output: the result is as if the source had been
+// copied before masking.
+for (const size of [1, 7, 8, 31, 32, 33, 100, 5000]) {
+  for (const shift of [-33, -9, -8, -1, 1, 3, 8, 9, 33]) {
+    const base = size + 40;
+    const backing = randomBytes(base * 2);
+    const srcStart = 40;
+    const dstStart = srcStart + shift;
+    const expected = referenceMask(backing.subarray(srcStart, srcStart + size), key);
+
+    // Views over the same memory.
+    const ab = Buffer.from(backing);
+    const source = ab.subarray(srcStart, srcStart + size);
+    const output = ab.subarray(dstStart, dstStart + size);
+    websocketMask(source, key, output, 0, size);
+    assert.deepStrictEqual(output, expected, `views size=${size} shift=${shift}`);
+
+    // Same object, using offset to shift the output.
+    if (shift > 0) {
+      const buf = Buffer.from(backing.subarray(srcStart, srcStart + size + shift));
+      websocketMask(buf, key, buf, shift, size);
+      assert.deepStrictEqual(buf.subarray(shift), expected,
+                             `same object size=${size} shift=${shift}`);
+    }
+  }
+}
+
+// The mask may alias the output.
+{
+  const buf = Buffer.from([1, 2, 3, 4, 10, 20, 30, 40, 50, 60]);
+  const aliasKey = buf.subarray(0, 4);
+  const expected = referenceMask(buf, Buffer.from([1, 2, 3, 4]));
+  websocketMask(buf, aliasKey, buf, 0, buf.length);
+  assert.deepStrictEqual(buf, expected);
+}
+
+// Any ArrayBufferView works, and is treated as bytes.
+{
+  const data = randomBytes(64);
+  const expected = referenceMask(data, key);
+  const u16 = new Uint16Array(data.buffer.slice(data.byteOffset, data.byteOffset + 64));
+  const dv = new DataView(new ArrayBuffer(64));
+  websocketMask(u16, key, dv, 0, 64);
+  assert.deepStrictEqual(Buffer.from(dv.buffer), expected);
+
+  const keyViews = [
+    new Uint8Array([0x12, 0x34, 0x56, 0x78]),
+    new Uint32Array(new Uint8Array([0x12, 0x34, 0x56, 0x78]).buffer),
+    new DataView(new Uint8Array([0, 0x12, 0x34, 0x56, 0x78]).buffer, 1, 4),
+    Buffer.from([0, 0, 0x12, 0x34, 0x56, 0x78, 0]).subarray(2, 6),
+    new Float32Array(new Uint8Array([0x12, 0x34, 0x56, 0x78]).buffer),
+  ];
+  for (const k of keyViews) {
+    const out = Buffer.alloc(64);
+    websocketMask(data, k, out, 0, 64);
+    assert.deepStrictEqual(out, expected, k.constructor.name);
+
+    const copy = new Uint8Array(data);
+    websocketUnmask(copy, k);
+    assert.deepStrictEqual(Buffer.from(copy), expected, k.constructor.name);
+  }
+}
+
+// Views backed by a SharedArrayBuffer.
+{
+  const data = randomBytes(100);
+  const shared = new Uint8Array(new SharedArrayBuffer(100));
+  shared.set(data);
+  websocketUnmask(shared, key);
+  assert.deepStrictEqual(Buffer.from(shared), referenceMask(data, key));
+}
+
+// Empty and detached inputs.
+{
+  websocketMask(Buffer.alloc(0), key, Buffer.alloc(0), 0, 0);
+  websocketUnmask(Buffer.alloc(0), key);
+
+  const ab = new ArrayBuffer(16);
+  const detached = new Uint8Array(ab);
+  structuredClone(ab, { transfer: [ab] });
+  assert.strictEqual(detached.byteLength, 0);
+  websocketUnmask(detached, key);
+  websocketMask(detached, key, Buffer.alloc(4), 0, 0);
+  assert.throws(() => websocketMask(detached, key, Buffer.alloc(4), 0, 4), {
+    code: 'ERR_OUT_OF_RANGE',
+  });
+}
+
+// Argument validation.
+{
+  const buf = Buffer.alloc(8);
+  const invalidViews = [undefined, null, 1, 'abcd', [1, 2, 3, 4], {}, new ArrayBuffer(8)];
+
+  for (const value of invalidViews) {
+    assert.throws(() => websocketMask(value, key, buf, 0, 8), { code: 'ERR_INVALID_ARG_TYPE' });
+    assert.throws(() => websocketMask(buf, key, value, 0, 8), { code: 'ERR_INVALID_ARG_TYPE' });
+    assert.throws(() => websocketMask(buf, value, buf, 0, 8), { code: 'ERR_INVALID_ARG_TYPE' });
+    assert.throws(() => websocketUnmask(value, key), { code: 'ERR_INVALID_ARG_TYPE' });
+    assert.throws(() => websocketUnmask(buf, value), { code: 'ERR_INVALID_ARG_TYPE' });
+  }
+
+  for (const badKey of [Buffer.alloc(0), Buffer.alloc(3), Buffer.alloc(5), new Uint16Array(4)]) {
+    assert.throws(() => websocketMask(buf, badKey, buf, 0, 8), { code: 'ERR_INVALID_ARG_VALUE' });
+    assert.throws(() => websocketUnmask(buf, badKey), { code: 'ERR_INVALID_ARG_VALUE' });
+  }
+
+  for (const offset of [-1, 9, 1.5, NaN, Infinity]) {
+    assert.throws(() => websocketMask(buf, key, buf, offset, 0), { code: 'ERR_OUT_OF_RANGE' });
+  }
+  // The offset and length arguments are required.
+  assert.throws(() => websocketMask(buf, key, buf), { code: 'ERR_INVALID_ARG_TYPE' });
+  assert.throws(() => websocketMask(buf, key, buf, 0), { code: 'ERR_INVALID_ARG_TYPE' });
+  assert.throws(() => websocketMask(buf, key, buf, undefined, 8),
+                { code: 'ERR_INVALID_ARG_TYPE' });
+
+  for (const offset of ['1', null, 1n]) {
+    assert.throws(() => websocketMask(buf, key, buf, offset, 0), { code: 'ERR_INVALID_ARG_TYPE' });
+  }
+  for (const length of [-1, 9, 1.5, NaN, Infinity]) {
+    assert.throws(() => websocketMask(buf, key, buf, 0, length), { code: 'ERR_OUT_OF_RANGE' });
+  }
+  for (const length of ['1', null, 1n]) {
+    assert.throws(() => websocketMask(buf, key, buf, 0, length), { code: 'ERR_INVALID_ARG_TYPE' });
+  }
+
+  // The sum of offset and length must fit in the output.
+  assert.throws(() => websocketMask(buf, key, Buffer.alloc(8), 1, 8), { code: 'ERR_OUT_OF_RANGE' });
+  assert.throws(() => websocketMask(buf, key, Buffer.alloc(4), 0, 8), { code: 'ERR_OUT_OF_RANGE' });
+  // The length must fit in the source.
+  assert.throws(() => websocketMask(Buffer.alloc(4), key, buf, 0, 5), { code: 'ERR_OUT_OF_RANGE' });
+
+  // Nothing is written when validation fails.
+  const out = Buffer.alloc(8, 0xaa);
+  assert.throws(() => websocketMask(buf, key, out, 1, 8), { code: 'ERR_OUT_OF_RANGE' });
+  assert.deepStrictEqual(out, Buffer.alloc(8, 0xaa));
+}
+
+// Return values.
+assert.strictEqual(websocketMask(Buffer.alloc(4), key, Buffer.alloc(4), 0, 4), undefined);
+assert.strictEqual(websocketUnmask(Buffer.alloc(4), key), undefined);
diff --git a/typings/internalBinding/buffer.d.ts b/typings/internalBinding/buffer.d.ts
index 063b1ddd57a..4d5e1f5cde8 100644
--- a/typings/internalBinding/buffer.d.ts
+++ b/typings/internalBinding/buffer.d.ts
@@ -23,6 +23,8 @@ export interface BufferBinding {
   isAscii(input: ArrayBufferView | ArrayBuffer | SharedArrayBuffer): boolean;
   isLatin1(input: string): boolean;

+  mask(source: ArrayBufferView, destination: ArrayBufferView, offset: number, length: number, mask: number): boolean;
+
   kMaxLength: number;
   kStringMaxLength: number;