Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions benchmark/http/websocket-mask.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
90 changes: 90 additions & 0 deletions doc/api/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]])`

<!-- 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.
**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: <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);
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)`, 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
Expand Down Expand Up @@ -4845,6 +4933,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
Expand Down Expand Up @@ -4880,6 +4969,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
Expand Down
88 changes: 87 additions & 1 deletion lib/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,25 @@

const {
ObjectDefineProperty,
Uint8Array,
} = primordials;

const { validateInteger, validateObject } = require('internal/validators');
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,
Expand Down Expand Up @@ -183,6 +194,79 @@ 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;
if (offset === undefined) {
offset = 0;
} else {
validateInteger(offset, 'offset', 0, outputLength);
}
if (length === undefined) {
length = source.byteLength;
} else {
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(),
Expand All @@ -205,6 +289,8 @@ module.exports = {
parsers.max = max;
},
setGlobalProxyFromEnv,
websocketMask,
websocketUnmask,
};

ObjectDefineProperty(module.exports, 'maxHeaderSize', {
Expand Down
Loading
Loading