Skip to content

Commit 7f1937d

Browse files
jasnellrichardlau
authored andcommitted
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 <[email protected]> Assisted-by: Opencode PR-URL: #66335 Reviewed-By: Filip Skokan <[email protected]> Reviewed-By: Luigi Pinca <[email protected]> Reviewed-By: Matthew Aitken <[email protected]> Reviewed-By: Robert Nagy <[email protected]>
1 parent b18e121 commit 7f1937d

8 files changed

Lines changed: 682 additions & 1 deletion

File tree

‎benchmark/http/websocket-mask.js‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict';
2+
3+
const common = require('../common.js');
4+
const { websocketMask, websocketUnmask } = require('node:http');
5+
6+
const bench = common.createBenchmark(main, {
7+
type: ['mask', 'unmask'],
8+
// 'http' uses http.websocketMask() / http.websocketUnmask(). 'js' is the
9+
// byte-by-byte loop that userland WebSocket implementations fall back to
10+
// without a native addon, for comparison.
11+
impl: ['http', 'js'],
12+
len: [4, 16, 125, 1024, 16384, 65536],
13+
n: [1e6],
14+
});
15+
16+
function jsMask(source, key, output, offset, length) {
17+
for (let i = 0; i < length; i++) {
18+
output[offset + i] = source[i] ^ key[i & 3];
19+
}
20+
}
21+
22+
function jsUnmask(buffer, key) {
23+
for (let i = 0; i < buffer.length; i++) {
24+
buffer[i] ^= key[i & 3];
25+
}
26+
}
27+
28+
function main({ n, type, impl, len }) {
29+
const key = Buffer.from([0x12, 0x34, 0x56, 0x78]);
30+
const source = Buffer.alloc(len, 'abcdefg');
31+
// Leave room for a WebSocket frame header before the payload.
32+
const output = Buffer.alloc(len + 14);
33+
34+
switch (type) {
35+
case 'mask': {
36+
const fn = impl === 'http' ? websocketMask : jsMask;
37+
bench.start();
38+
for (let i = 0; i < n; i++) {
39+
fn(source, key, output, 14, len);
40+
}
41+
bench.end(n);
42+
break;
43+
}
44+
case 'unmask': {
45+
const fn = impl === 'http' ? websocketUnmask : jsUnmask;
46+
bench.start();
47+
for (let i = 0; i < n; i++) {
48+
fn(source, key);
49+
}
50+
bench.end(n);
51+
break;
52+
}
53+
default:
54+
throw new Error(`Unexpected type: ${type}`);
55+
}
56+
}

‎doc/api/http.md‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4609,6 +4609,92 @@ requests are made and avoid invoking it in the middle of any requests.
46094609
See [Built-in Proxy Support][] for details on proxy URL formats and `NO_PROXY`
46104610
syntax.
46114611
4612+
## `http.websocketMask(source, mask, output, offset, length)`
4613+
4614+
<!-- YAML
4615+
added: REPLACEME
4616+
-->
4617+
4618+
* `source` {Buffer|TypedArray|DataView} The data to mask.
4619+
* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.
4620+
* `output` {Buffer|TypedArray|DataView} Where to write the masked data.
4621+
* `offset` {integer} Byte offset in `output` at which to start writing.
4622+
* `length` {integer} Number of bytes of `source` to mask.
4623+
4624+
XORs the first `length` bytes of `source` with `mask`, repeated, and writes the
4625+
result to `output` starting at `offset`: byte `i` of `source` is XORed with
4626+
byte `i % 4` of `mask`. This is the masking operation that WebSocket clients
4627+
apply to every frame payload they send ([RFC 6455, Section 5.3][]), and that
4628+
servers undo on every frame they receive. Applying it twice with the same
4629+
`mask` restores the original data. `source` is not modified, unless it shares
4630+
memory with `output`.
4631+
4632+
All arguments are treated as raw bytes, whatever the view type. `source` and
4633+
`output` may be the same view or overlapping views over the same memory; the
4634+
result is the same as if `source` had been copied first.
4635+
4636+
An error is thrown if `mask` is not exactly 4 bytes long, if `length` is
4637+
greater than `source.byteLength`, if `offset + length` is greater than
4638+
`output.byteLength`, or if `output` is backed by an immutable `ArrayBuffer`.
4639+
Nothing is written in that case. `source` and `mask` may be backed by an
4640+
immutable `ArrayBuffer`.
4641+
4642+
```mjs
4643+
import { Buffer } from 'node:buffer';
4644+
import { websocketMask, websocketUnmask } from 'node:http';
4645+
4646+
const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
4647+
const payload = Buffer.from('Hello');
4648+
4649+
// Write a masked copy of the payload after a 6-byte frame header.
4650+
const frame = Buffer.alloc(6 + payload.length);
4651+
websocketMask(payload, key, frame, 6, payload.length);
4652+
console.log(frame.subarray(6));
4653+
// Prints: <Buffer 7f 9f 4d 51 58>
4654+
4655+
const received = frame.subarray(6);
4656+
websocketUnmask(received, key);
4657+
console.log(received.toString());
4658+
// Prints: Hello
4659+
```
4660+
4661+
```cjs
4662+
const { Buffer } = require('node:buffer');
4663+
const { websocketMask, websocketUnmask } = require('node:http');
4664+
4665+
const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
4666+
const payload = Buffer.from('Hello');
4667+
4668+
// Write a masked copy of the payload after a 6-byte frame header.
4669+
const frame = Buffer.alloc(6 + payload.length);
4670+
websocketMask(payload, key, frame, 6, payload.length);
4671+
console.log(frame.subarray(6));
4672+
// Prints: <Buffer 7f 9f 4d 51 58>
4673+
4674+
const received = frame.subarray(6);
4675+
websocketUnmask(received, key);
4676+
console.log(received.toString());
4677+
// Prints: Hello
4678+
```
4679+
4680+
## `http.websocketUnmask(buffer, mask)`
4681+
4682+
<!-- YAML
4683+
added: REPLACEME
4684+
-->
4685+
4686+
* `buffer` {Buffer|TypedArray|DataView} The data to unmask, in place.
4687+
* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.
4688+
4689+
XORs every byte of `buffer` with `mask`, repeated, in place: byte `i` is XORed
4690+
with byte `i % 4` of `mask`. This is equivalent to
4691+
`http.websocketMask(buffer, mask, buffer, 0, buffer.byteLength)`, and is
4692+
typically used to unmask a received WebSocket frame payload
4693+
([RFC 6455, Section 5.3][]). See [`http.websocketMask()`][] for an example.
4694+
4695+
An error is thrown, and nothing is written, if `mask` is not exactly 4 bytes
4696+
long or if `buffer` is backed by an immutable `ArrayBuffer`.
4697+
46124698
## Class: `WebSocket`
46134699
46144700
<!-- YAML
@@ -4817,6 +4903,7 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
48174903
```
48184904
48194905
[Built-in Proxy Support]: #built-in-proxy-support
4906+
[RFC 6455, Section 5.3]: https://datatracker.ietf.org/doc/html/rfc6455#section-5.3
48204907
[RFC 8187]: https://www.rfc-editor.org/rfc/rfc8187.txt
48214908
[RFC 9110 Section 6.6.1]: https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1
48224909
[`'ERR_HTTP_CONTENT_LENGTH_MISMATCH'`]: errors.md#err_http_content_length_mismatch
@@ -4852,6 +4939,7 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
48524939
[`http.setGlobalProxyFromEnv()`]: #httpsetglobalproxyfromenvproxyenv
48534940
[`http.validateHeaderName()`]: #httpvalidateheadernamename-label
48544941
[`http.validateHeaderValue()`]: #httpvalidateheadervaluename-value
4942+
[`http.websocketMask()`]: #httpwebsocketmasksource-mask-output-offset-length
48554943
[`message.headers`]: #messageheaders
48564944
[`message.rawHeaders`]: #messagerawheaders
48574945
[`message.socket`]: #messagesocket

‎lib/http.js‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,25 @@
2323

2424
const {
2525
ObjectDefineProperty,
26+
Uint8Array,
2627
} = primordials;
2728

2829
const { validateInteger, validateObject } = require('internal/validators');
2930
const httpAgent = require('_http_agent');
3031
const { ClientRequest } = require('_http_client');
3132
const { methods, parsers } = require('_http_common');
3233
const { IncomingMessage } = require('_http_incoming');
33-
const { ERR_PROXY_INVALID_CONFIG } = require('internal/errors').codes;
34+
const {
35+
ERR_INVALID_ARG_TYPE,
36+
ERR_INVALID_ARG_VALUE,
37+
ERR_OUT_OF_RANGE,
38+
ERR_PROXY_INVALID_CONFIG,
39+
} = require('internal/errors').codes;
40+
const {
41+
isArrayBufferView,
42+
isUint8Array,
43+
} = require('internal/util/types');
44+
const { mask: bindingMask } = internalBinding('buffer');
3445
const {
3546
isValidHeaderName,
3647
isValidHeaderValue,
@@ -183,6 +194,71 @@ function setGlobalProxyFromEnv(env = process.env) {
183194
};
184195
}
185196

197+
/**
198+
* Packs a 4-byte mask into a uint32, with byte k in bits 8k..8k+7.
199+
* @param {ArrayBufferView} key
200+
* @returns {number}
201+
*/
202+
function maskKeyToUint32(key) {
203+
if (!isArrayBufferView(key)) {
204+
throw new ERR_INVALID_ARG_TYPE('mask', ['Buffer', 'TypedArray', 'DataView'], key);
205+
}
206+
if (key.byteLength !== 4) {
207+
throw new ERR_INVALID_ARG_VALUE('mask', key, 'must be 4 bytes long');
208+
}
209+
const bytes = isUint8Array(key) ?
210+
/** @type {Uint8Array} */ (key) :
211+
new Uint8Array(key.buffer, key.byteOffset, 4);
212+
return (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >>> 0;
213+
}
214+
215+
/**
216+
* XORs `length` bytes of `source` with the repeating 4-byte `key` and writes
217+
* the result to `output` at `offset` (RFC 6455, Section 5.3).
218+
* @param {ArrayBufferView} source
219+
* @param {ArrayBufferView} key
220+
* @param {ArrayBufferView} output
221+
* @param {number} offset
222+
* @param {number} length
223+
* @returns {void}
224+
*/
225+
function websocketMask(source, key, output, offset, length) {
226+
if (!isArrayBufferView(source)) {
227+
throw new ERR_INVALID_ARG_TYPE('source', ['Buffer', 'TypedArray', 'DataView'], source);
228+
}
229+
const maskValue = maskKeyToUint32(key);
230+
if (!isArrayBufferView(output)) {
231+
throw new ERR_INVALID_ARG_TYPE('output', ['Buffer', 'TypedArray', 'DataView'], output);
232+
}
233+
const outputLength = output.byteLength;
234+
validateInteger(offset, 'offset', 0, outputLength);
235+
validateInteger(length, 'length', 0, source.byteLength);
236+
if (length > outputLength - offset) {
237+
throw new ERR_OUT_OF_RANGE('length', `<= ${outputLength - offset}`, length);
238+
}
239+
if (!bindingMask(source, output, offset, length, maskValue)) {
240+
throw new ERR_INVALID_ARG_VALUE(
241+
'output', output, 'must not be backed by an immutable ArrayBuffer');
242+
}
243+
}
244+
245+
/**
246+
* XORs every byte of `buffer` with the repeating 4-byte `key`, in place.
247+
* @param {ArrayBufferView} buffer
248+
* @param {ArrayBufferView} key
249+
* @returns {void}
250+
*/
251+
function websocketUnmask(buffer, key) {
252+
if (!isArrayBufferView(buffer)) {
253+
throw new ERR_INVALID_ARG_TYPE('buffer', ['Buffer', 'TypedArray', 'DataView'], buffer);
254+
}
255+
const maskValue = maskKeyToUint32(key);
256+
if (!bindingMask(buffer, buffer, 0, buffer.byteLength, maskValue)) {
257+
throw new ERR_INVALID_ARG_VALUE(
258+
'buffer', buffer, 'must not be backed by an immutable ArrayBuffer');
259+
}
260+
}
261+
186262
module.exports = {
187263
_connectionListener,
188264
METHODS: methods.toSorted(),
@@ -205,6 +281,8 @@ module.exports = {
205281
parsers.max = max;
206282
},
207283
setGlobalProxyFromEnv,
284+
websocketMask,
285+
websocketUnmask,
208286
};
209287

210288
ObjectDefineProperty(module.exports, 'maxHeaderSize', {

0 commit comments

Comments
 (0)