From ac760bc21eaf028575777eaa50142245ccaa7c41 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 27 Sep 2026 03:17:43 +0000 Subject: [PATCH] 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 Assisted-by: Opencode --- benchmark/http/websocket-mask.js | 56 +++++ doc/api/http.md | 90 ++++++++ lib/http.js | 88 +++++++- src/node_buffer.cc | 123 ++++++++++ .../parallel/test-http-websocket-mask-fast.js | 38 ++++ .../test-http-websocket-mask-immutable.js | 90 ++++++++ test/parallel/test-http-websocket-mask.js | 213 ++++++++++++++++++ typings/internalBinding/buffer.d.ts | 2 + 8 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 benchmark/http/websocket-mask.js create mode 100644 test/parallel/test-http-websocket-mask-fast.js create mode 100644 test/parallel/test-http-websocket-mask-immutable.js create mode 100644 test/parallel/test-http-websocket-mask.js 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..88f563a89b3 100644 --- a/doc/api/http.md +++ b/doc/api/http.md @@ -4637,6 +4637,94 @@ 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]])` + + + +* `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. + **Default:** `0`. +* `length` {integer} Number of bytes of `source` to mask. + **Default:** `source.byteLength`. + +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); +console.log(frame.subarray(6)); +// Prints: + +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); +console.log(frame.subarray(6)); +// Prints: + +const received = frame.subarray(6); +websocketUnmask(received, key); +console.log(received.toString()); +// Prints: Hello +``` + +## `http.websocketUnmask(buffer, mask)` + + + +* `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)`, 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`