From c1c9c0bf6a0b66e2afeac4327fa2ea1afb85697f Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 2 Apr 2026 05:40:18 +0000 Subject: [PATCH 1/5] feat: add configurable maxDecompressedMessageSize for WebSocket - Add webSocketOptions.maxDecompressedMessageSize to DispatcherBase - Propagate through Agent, Client, Pool - Increase default from 4 MB to 64 MB - Add estimated expansion check (10x ratio) - Fix actual size tracking to use configured limit - Add tests --- docs/docs/api/Client.md | 2 + lib/dispatcher/agent.js | 2 +- lib/dispatcher/client.js | 5 +- lib/dispatcher/dispatcher-base.js | 18 +++ lib/dispatcher/pool.js | 2 +- lib/web/websocket/permessage-deflate.js | 40 +++++- lib/web/websocket/receiver.js | 50 ++++---- lib/web/websocket/websocket.js | 7 +- test/websocket/permessage-deflate-config.js | 117 +++++++++++++++++ test/websocket/permessage-deflate-limit.js | 133 +++++++++++++++++++- types/client.d.ts | 11 ++ 11 files changed, 354 insertions(+), 33 deletions(-) create mode 100644 test/websocket/permessage-deflate-config.js diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index 680375d1479..1855c99ff4f 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -24,6 +24,8 @@ Returns: `Client` * **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds. * **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. +* **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options. + * **maxDecompressedMessageSize** `number` (optional) - Default: `67108864` (64 MB) - Maximum allowed size in bytes for decompressed WebSocket messages when using the permessage-deflate extension. Protects against decompression bomb attacks where a small compressed payload expands to an extremely large size. The check uses a conservative 10x expansion ratio estimate for early rejection. * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source. diff --git a/lib/dispatcher/agent.js b/lib/dispatcher/agent.js index 939b0ad55d3..09fdd4be3d5 100644 --- a/lib/dispatcher/agent.js +++ b/lib/dispatcher/agent.js @@ -35,7 +35,7 @@ class Agent extends DispatcherBase { throw new InvalidArgumentError('maxOrigins must be a number greater than 0') } - super() + super(options) if (connect && typeof connect !== 'function') { connect = { ...connect } diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index f4feff1bbe0..e80241951da 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -114,7 +114,8 @@ class Client extends DispatcherBase { useH2c, initialWindowSize, connectionWindowSize, - pingInterval + pingInterval, + webSocket } = {}) { if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -222,7 +223,7 @@ class Client extends DispatcherBase { throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') } - super() + super({ webSocket }) if (typeof connect !== 'function') { connect = buildConnector({ diff --git a/lib/dispatcher/dispatcher-base.js b/lib/dispatcher/dispatcher-base.js index a6f47100257..4f220c021f9 100644 --- a/lib/dispatcher/dispatcher-base.js +++ b/lib/dispatcher/dispatcher-base.js @@ -11,6 +11,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy const kOnDestroyed = Symbol('onDestroyed') const kOnClosed = Symbol('onClosed') +const kWebSocketOptions = Symbol('webSocketOptions') class DispatcherBase extends Dispatcher { /** @type {boolean} */ @@ -25,6 +26,23 @@ class DispatcherBase extends Dispatcher { /** @type {Array|null} */ [kOnClosed] = null + /** + * @param {import('../../types/dispatcher').DispatcherOptions} [opts] + */ + constructor (opts) { + super() + this[kWebSocketOptions] = opts?.webSocket ?? {} + } + + /** + * @returns {import('../../types/dispatcher').WebSocketOptions} + */ + get webSocketOptions () { + return { + maxDecompressedMessageSize: this[kWebSocketOptions].maxDecompressedMessageSize ?? 64 * 1024 * 1024 // 64 MB default + } + } + /** @returns {boolean} */ get destroyed () { return this[kDestroyed] diff --git a/lib/dispatcher/pool.js b/lib/dispatcher/pool.js index 8419ac611e7..0c5dbe44da7 100644 --- a/lib/dispatcher/pool.js +++ b/lib/dispatcher/pool.js @@ -63,7 +63,7 @@ class Pool extends PoolBase { }) } - super() + super(options) this[kConnections] = connections || null this[kUrl] = util.parseOrigin(origin) diff --git a/lib/web/websocket/permessage-deflate.js b/lib/web/websocket/permessage-deflate.js index 1f1a13038af..f0e937d033a 100644 --- a/lib/web/websocket/permessage-deflate.js +++ b/lib/web/websocket/permessage-deflate.js @@ -8,8 +8,10 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff]) const kBuffer = Symbol('kBuffer') const kLength = Symbol('kLength') -// Default maximum decompressed message size: 4 MB -const kDefaultMaxDecompressedSize = 4 * 1024 * 1024 +// Default maximum decompressed message size: 64 MB +const kDefaultMaxDecompressedSize = 64 * 1024 * 1024 +// Maximum expansion ratio for estimated size check (conservative DEFLATE upper bound) +const kMaxExpansionRatio = 10 class PerMessageDeflate { /** @type {import('node:zlib').InflateRaw} */ @@ -17,6 +19,9 @@ class PerMessageDeflate { #options = {} + /** @type {number} */ + #maxDecompressedSize + /** @type {boolean} */ #aborted = false @@ -25,18 +30,43 @@ class PerMessageDeflate { /** * @param {Map} extensions + * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor (extensions) { + constructor (extensions, options = {}) { this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') + this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize + } + + /** + * Check if compressed payload could exceed the decompressed size limit. + * Uses a conservative expansion ratio estimate for early rejection. + * @param {number} compressedLength + * @returns {boolean} true if the message should be rejected + */ + #exceedsEstimatedLimit (compressedLength) { + return compressedLength * kMaxExpansionRatio > this.#maxDecompressedSize } - decompress (chunk, fin, callback) { + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + * @param {number} [compressedLength] Compressed payload length for estimated size check + */ + decompress (chunk, fin, callback, compressedLength) { // An endpoint uses the following algorithm to decompress a message. // 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the // payload of the message. // 2. Decompress the resulting data using DEFLATE. + // Early rejection based on estimated expansion + if (compressedLength != null && this.#exceedsEstimatedLimit(compressedLength)) { + callback(new MessageSizeExceededError()) + return + } + if (this.#aborted) { callback(new MessageSizeExceededError()) return @@ -70,7 +100,7 @@ class PerMessageDeflate { this.#inflate[kLength] += data.length - if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) { + if (this.#inflate[kLength] > this.#maxDecompressedSize) { this.#aborted = true this.#inflate.removeAllListeners() this.#inflate.destroy() diff --git a/lib/web/websocket/receiver.js b/lib/web/websocket/receiver.js index 384808d1b7e..8422bde6a45 100644 --- a/lib/web/websocket/receiver.js +++ b/lib/web/websocket/receiver.js @@ -42,15 +42,16 @@ class ByteParser extends Writable { /** * @param {import('./websocket').Handler} handler * @param {Map|null} extensions + * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor (handler, extensions) { + constructor (handler, extensions, options = {}) { super() this.#handler = handler this.#extensions = extensions == null ? new Map() : extensions if (this.#extensions.has('permessage-deflate')) { - this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions)) + this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options)) } } @@ -224,29 +225,34 @@ class ByteParser extends Writable { this.#state = parserStates.INFO } else { - this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => { - if (error) { - // Use 1009 (Message Too Big) for decompression size limit errors - const code = error instanceof MessageSizeExceededError ? 1009 : 1007 - failWebsocketConnection(this.#handler, code, error.message) - return - } + this.#extensions.get('permessage-deflate').decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + // Use 1009 (Message Too Big) for decompression size limit errors + const code = error instanceof MessageSizeExceededError ? 1009 : 1007 + failWebsocketConnection(this.#handler, code, error.message) + return + } + + this.writeFragments(data) + + if (!this.#info.fin) { + this.#state = parserStates.INFO + this.#loop = true + this.run(callback) + return + } + + websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments()) - this.writeFragments(data) - - if (!this.#info.fin) { - this.#state = parserStates.INFO this.#loop = true + this.#state = parserStates.INFO this.run(callback) - return - } - - websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments()) - - this.#loop = true - this.#state = parserStates.INFO - this.run(callback) - }) + }, + this.#info.payloadLength + ) this.#loop = false break diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js index da94ab5b352..a1e2db7b4bb 100644 --- a/lib/web/websocket/websocket.js +++ b/lib/web/websocket/websocket.js @@ -468,7 +468,12 @@ class WebSocket extends EventTarget { // once this happens, the connection is open this.#handler.socket = response.socket - const parser = new ByteParser(this.#handler, parsedExtensions) + // Get maxDecompressedMessageSize from dispatcher options + const maxDecompressedMessageSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxDecompressedMessageSize + + const parser = new ByteParser(this.#handler, parsedExtensions, { + maxDecompressedMessageSize + }) parser.on('drain', () => this.#handler.onParserDrain()) parser.on('error', (err) => this.#handler.onParserError(err)) diff --git a/test/websocket/permessage-deflate-config.js b/test/websocket/permessage-deflate-config.js new file mode 100644 index 00000000000..e06580a66a1 --- /dev/null +++ b/test/websocket/permessage-deflate-config.js @@ -0,0 +1,117 @@ +'use strict' + +const { test } = require('node:test') +const { once } = require('node:events') +const { WebSocketServer } = require('ws') +const { WebSocket, Agent, Client, Pool } = require('../..') + +test('Agent webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { + const customLimit = 128 * 1024 * 1024 // 128 MB + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: customLimit + } + }) + + t.after(() => agent.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, customLimit) +}) + +test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { + const agent = new Agent() + + t.after(() => agent.close()) + + // Default should be 64 MB + t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, 64 * 1024 * 1024) +}) + +test('Custom maxDecompressedMessageSize allows messages under limit', async (t) => { + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + const dataSize = 512 * 1024 // 512 KB + + server.on('connection', (ws) => { + ws.send(Buffer.alloc(dataSize, 0x41), { binary: true }) + }) + + // Set custom limit of 1 MB via Agent + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: 1 * 1024 * 1024 + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + const [event] = await once(client, 'message') + t.assert.strictEqual(event.data.size, dataSize, 'Message under limit should be received') + client.close() +}) + +test('Messages at exactly the limit succeed', async (t) => { + const limit = 1 * 1024 * 1024 // 1 MB + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + server.on('connection', (ws) => { + ws.send(Buffer.alloc(limit, 0x41), { binary: true }) + }) + + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: limit + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + const [event] = await once(client, 'message') + t.assert.strictEqual(event.data.size, limit, 'Message at exactly the limit should succeed') + client.close() +}) + +test('Client webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { + const customLimit = 32 * 1024 * 1024 // 32 MB + const client = new Client('http://localhost', { + webSocket: { + maxDecompressedMessageSize: customLimit + } + }) + + t.after(() => client.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(client.webSocketOptions.maxDecompressedMessageSize, customLimit) +}) + +test('Pool webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { + const customLimit = 16 * 1024 * 1024 // 16 MB + const pool = new Pool('http://localhost', { + webSocket: { + maxDecompressedMessageSize: customLimit + } + }) + + t.after(() => pool.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(pool.webSocketOptions.maxDecompressedMessageSize, customLimit) +}) diff --git a/test/websocket/permessage-deflate-limit.js b/test/websocket/permessage-deflate-limit.js index 8764ab9eaf6..6ca977d4ea4 100644 --- a/test/websocket/permessage-deflate-limit.js +++ b/test/websocket/permessage-deflate-limit.js @@ -3,7 +3,7 @@ const { test } = require('node:test') const { once } = require('node:events') const { WebSocketServer } = require('ws') -const { WebSocket } = require('../..') +const { WebSocket, Agent } = require('../..') test('Compressed message under limit decompresses successfully', async (t) => { const server = new WebSocketServer({ @@ -26,3 +26,134 @@ test('Compressed message under limit decompresses successfully', async (t) => { t.assert.strictEqual(event.data.size, 1024) client.close() }) + +test('Agent webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { + const customLimit = 128 * 1024 * 1024 // 128 MB + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: customLimit + } + }) + + t.after(() => agent.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, customLimit) +}) + +test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { + const agent = new Agent() + + t.after(() => agent.close()) + + // Default should be 64 MB + t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, 64 * 1024 * 1024) +}) + +test('Custom maxDecompressedMessageSize allows messages under limit', async (t) => { + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + const dataSize = 512 * 1024 // 512 KB + + server.on('connection', (ws) => { + ws.send(Buffer.alloc(dataSize, 0x41), { binary: true }) + }) + + // Set custom limit of 1 MB via Agent + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: 1 * 1024 * 1024 + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + const [event] = await once(client, 'message') + t.assert.strictEqual(event.data.size, dataSize, 'Message under limit should be received') + client.close() +}) + +test('Messages at exactly the limit succeed', async (t) => { + const limit = 1 * 1024 * 1024 // 1 MB + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + server.on('connection', (ws) => { + ws.send(Buffer.alloc(limit, 0x41), { binary: true }) + }) + + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: limit + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + const [event] = await once(client, 'message') + t.assert.strictEqual(event.data.size, limit, 'Message at exactly the limit should succeed') + client.close() +}) + +test('Messages over the limit are rejected', async (t) => { + const limit = 1 * 1024 * 1024 // 1 MB + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + let messageReceived = false + let closeEvent = null + + server.on('connection', (ws) => { + // Send 2 MB of data, which exceeds the 1 MB limit + ws.send(Buffer.alloc(2 * 1024 * 1024, 0x41), { binary: true }) + }) + + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: limit + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + client.addEventListener('message', () => { + messageReceived = true + }) + + client.addEventListener('close', (event) => { + closeEvent = event + }) + + // Wait for connection to close (should happen when limit is exceeded) + // Use Promise.race with a timeout to avoid hanging forever + const closePromise = once(client, 'close') + const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5000)) + + await Promise.race([closePromise, timeoutPromise]) + + t.assert.strictEqual(messageReceived, false, 'Message over limit should be rejected') + t.assert.ok(closeEvent !== null, 'Close event should have been emitted') + t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') +}) diff --git a/types/client.d.ts b/types/client.d.ts index a6e20221f68..7b2add9f1cb 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -78,6 +78,8 @@ export declare namespace Client { localAddress?: string; /** Max response body size in bytes, -1 is disabled */ maxResponseSize?: number; + /** WebSocket-specific options */ + webSocket?: Client.WebSocketOptions; /** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */ autoSelectFamily?: boolean; /** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */ @@ -118,6 +120,15 @@ export declare namespace Client { bytesWritten?: number bytesRead?: number } + export interface WebSocketOptions { + /** + * Maximum allowed size in bytes for decompressed WebSocket messages when using the permessage-deflate extension. + * Prevents decompression bomb attacks where a small compressed payload expands to an extremely large size. + * The check uses a conservative 10x expansion ratio estimate for early rejection. + * @default 67108864 (64 MB) + */ + maxDecompressedMessageSize?: number; + } } export default Client From aba708d368ec9a9ad27b501717f3ef8c6ad0506e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 2 Apr 2026 22:39:21 +0000 Subject: [PATCH 2/5] feat: allow disabling maxDecompressedMessageSize limit with 0 - Set maxDecompressedMessageSize to 0 to disable the limit - Default remains 64 MB for decompression bomb protection - Add test for disabled limit --- docs/docs/api/Client.md | 2 +- lib/web/websocket/permessage-deflate.js | 5 ++- test/websocket/permessage-deflate-limit.js | 43 +++++++++++++++++++++- types/client.d.ts | 1 + 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index 1855c99ff4f..65f9bea6c1d 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -25,7 +25,7 @@ Returns: `Client` * **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options. - * **maxDecompressedMessageSize** `number` (optional) - Default: `67108864` (64 MB) - Maximum allowed size in bytes for decompressed WebSocket messages when using the permessage-deflate extension. Protects against decompression bomb attacks where a small compressed payload expands to an extremely large size. The check uses a conservative 10x expansion ratio estimate for early rejection. + * **maxDecompressedMessageSize** `number` (optional) - Default: `67108864` (64 MB) - Maximum allowed size in bytes for decompressed WebSocket messages when using the permessage-deflate extension. Protects against decompression bomb attacks where a small compressed payload expands to an extremely large size. The check uses a conservative 10x expansion ratio estimate for early rejection. Set to 0 to disable the limit. * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source. diff --git a/lib/web/websocket/permessage-deflate.js b/lib/web/websocket/permessage-deflate.js index f0e937d033a..3379d709c70 100644 --- a/lib/web/websocket/permessage-deflate.js +++ b/lib/web/websocket/permessage-deflate.js @@ -45,6 +45,8 @@ class PerMessageDeflate { * @returns {boolean} true if the message should be rejected */ #exceedsEstimatedLimit (compressedLength) { + // 0 disables the limit + if (this.#maxDecompressedSize <= 0) return false return compressedLength * kMaxExpansionRatio > this.#maxDecompressedSize } @@ -100,7 +102,8 @@ class PerMessageDeflate { this.#inflate[kLength] += data.length - if (this.#inflate[kLength] > this.#maxDecompressedSize) { + // 0 disables the limit + if (this.#maxDecompressedSize > 0 && this.#inflate[kLength] > this.#maxDecompressedSize) { this.#aborted = true this.#inflate.removeAllListeners() this.#inflate.destroy() diff --git a/test/websocket/permessage-deflate-limit.js b/test/websocket/permessage-deflate-limit.js index 6ca977d4ea4..57b81c992fb 100644 --- a/test/websocket/permessage-deflate-limit.js +++ b/test/websocket/permessage-deflate-limit.js @@ -2,6 +2,7 @@ const { test } = require('node:test') const { once } = require('node:events') +const { setTimeout: sleep } = require('node:timers/promises') const { WebSocketServer } = require('ws') const { WebSocket, Agent } = require('../..') @@ -149,7 +150,7 @@ test('Messages over the limit are rejected', async (t) => { // Wait for connection to close (should happen when limit is exceeded) // Use Promise.race with a timeout to avoid hanging forever const closePromise = once(client, 'close') - const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5000)) + const timeoutPromise = sleep(5000) await Promise.race([closePromise, timeoutPromise]) @@ -157,3 +158,43 @@ test('Messages over the limit are rejected', async (t) => { t.assert.ok(closeEvent !== null, 'Close event should have been emitted') t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') }) + +test('Limit can be disabled by setting maxDecompressedMessageSize to 0', async (t) => { + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + const dataSize = 100 * 1024 * 1024 // 100 MB + + server.on('connection', (ws) => { + ws.send(Buffer.alloc(dataSize, 0x41), { binary: true }) + }) + + // Set limit to 0 (disabled) + const agent = new Agent({ + webSocket: { + maxDecompressedMessageSize: 0 + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + // Use Promise.race with timeout since large message takes time + const messagePromise = once(client, 'message') + const timeoutPromise = sleep(10000) + + const result = await Promise.race([messagePromise, timeoutPromise]) + + if (result) { + t.assert.strictEqual(result[0].data.size, dataSize, 'Large message should be received when limit is disabled') + client.close() + } else { + t.fail('Test timed out waiting for large message') + } +}) diff --git a/types/client.d.ts b/types/client.d.ts index 7b2add9f1cb..68f11418c01 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -125,6 +125,7 @@ export declare namespace Client { * Maximum allowed size in bytes for decompressed WebSocket messages when using the permessage-deflate extension. * Prevents decompression bomb attacks where a small compressed payload expands to an extremely large size. * The check uses a conservative 10x expansion ratio estimate for early rejection. + * Set to 0 to disable the limit. * @default 67108864 (64 MB) */ maxDecompressedMessageSize?: number; From b8b6b2c558302948ac1205e292a2c6e48f8db7cb Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 3 Apr 2026 21:03:18 +0000 Subject: [PATCH 3/5] refactor: rename maxDecompressedMessageSize to maxPayloadSize - Apply limit to both compressed and uncompressed payloads - Add raw payload size check before accepting uncompressed data - Update types and docs to reflect new option name - Add test for raw uncompressed payload limit enforcement --- docs/docs/api/Client.md | 2 +- lib/dispatcher/dispatcher-base.js | 2 +- lib/web/websocket/permessage-deflate.js | 5 +- lib/web/websocket/receiver.js | 12 ++++- lib/web/websocket/websocket.js | 6 +-- test/websocket/permessage-deflate-config.js | 26 ++++----- test/websocket/permessage-deflate-limit.js | 60 +++++++++++++++++---- types/client.d.ts | 8 +-- 8 files changed, 86 insertions(+), 35 deletions(-) diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index 65f9bea6c1d..8a2da0a7401 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -25,7 +25,7 @@ Returns: `Client` * **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options. - * **maxDecompressedMessageSize** `number` (optional) - Default: `67108864` (64 MB) - Maximum allowed size in bytes for decompressed WebSocket messages when using the permessage-deflate extension. Protects against decompression bomb attacks where a small compressed payload expands to an extremely large size. The check uses a conservative 10x expansion ratio estimate for early rejection. Set to 0 to disable the limit. + * **maxPayloadSize** `number` (optional) - Default: `67108864` (64 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to both uncompressed and decompressed (permessage-deflate) messages. For decompressed messages, uses a conservative 10x expansion ratio estimate for early rejection. Set to 0 to disable the limit. * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source. diff --git a/lib/dispatcher/dispatcher-base.js b/lib/dispatcher/dispatcher-base.js index 4f220c021f9..74add6a7ac4 100644 --- a/lib/dispatcher/dispatcher-base.js +++ b/lib/dispatcher/dispatcher-base.js @@ -39,7 +39,7 @@ class DispatcherBase extends Dispatcher { */ get webSocketOptions () { return { - maxDecompressedMessageSize: this[kWebSocketOptions].maxDecompressedMessageSize ?? 64 * 1024 * 1024 // 64 MB default + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 64 * 1024 * 1024 // 64 MB default } } diff --git a/lib/web/websocket/permessage-deflate.js b/lib/web/websocket/permessage-deflate.js index 3379d709c70..7e789572c54 100644 --- a/lib/web/websocket/permessage-deflate.js +++ b/lib/web/websocket/permessage-deflate.js @@ -30,12 +30,13 @@ class PerMessageDeflate { /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxPayloadSize?: number }} [options] */ constructor (extensions, options = {}) { this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize + // 0 disables the limit + this.#maxDecompressedSize = options.maxPayloadSize ?? kDefaultMaxDecompressedSize } /** diff --git a/lib/web/websocket/receiver.js b/lib/web/websocket/receiver.js index 8422bde6a45..500a605223b 100644 --- a/lib/web/websocket/receiver.js +++ b/lib/web/websocket/receiver.js @@ -39,16 +39,20 @@ class ByteParser extends Writable { /** @type {import('./websocket').Handler} */ #handler + /** @type {number} */ + #maxPayloadSize + /** * @param {import('./websocket').Handler} handler * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxPayloadSize?: number }} [options] */ constructor (handler, extensions, options = {}) { super() this.#handler = handler this.#extensions = extensions == null ? new Map() : extensions + this.#maxPayloadSize = options.maxPayloadSize ?? 0 if (this.#extensions.has('permessage-deflate')) { this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options)) @@ -213,6 +217,12 @@ class ByteParser extends Writable { this.#state = parserStates.INFO } else { if (!this.#info.compressed) { + // Check raw payload size before accepting uncompressed data + if (this.#maxPayloadSize > 0 && this.#info.payloadLength > this.#maxPayloadSize) { + failWebsocketConnection(this.#handler, 1009, 'Payload size exceeds maximum allowed size') + return + } + this.writeFragments(body) // If the frame is not fragmented, a message has been received. diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js index a1e2db7b4bb..a2abd9c9ab6 100644 --- a/lib/web/websocket/websocket.js +++ b/lib/web/websocket/websocket.js @@ -468,11 +468,11 @@ class WebSocket extends EventTarget { // once this happens, the connection is open this.#handler.socket = response.socket - // Get maxDecompressedMessageSize from dispatcher options - const maxDecompressedMessageSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxDecompressedMessageSize + // Get maxPayloadSize from dispatcher options + const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize const parser = new ByteParser(this.#handler, parsedExtensions, { - maxDecompressedMessageSize + maxPayloadSize }) parser.on('drain', () => this.#handler.onParserDrain()) parser.on('error', (err) => this.#handler.onParserError(err)) diff --git a/test/websocket/permessage-deflate-config.js b/test/websocket/permessage-deflate-config.js index e06580a66a1..30812a9bb4c 100644 --- a/test/websocket/permessage-deflate-config.js +++ b/test/websocket/permessage-deflate-config.js @@ -5,18 +5,18 @@ const { once } = require('node:events') const { WebSocketServer } = require('ws') const { WebSocket, Agent, Client, Pool } = require('../..') -test('Agent webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { +test('Agent webSocketOptions.maxPayloadSize is read correctly', async (t) => { const customLimit = 128 * 1024 * 1024 // 128 MB const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: customLimit + maxPayloadSize: customLimit } }) t.after(() => agent.close()) // Verify the option is stored and retrievable - t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, customLimit) + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, customLimit) }) test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { @@ -25,10 +25,10 @@ test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { t.after(() => agent.close()) // Default should be 64 MB - t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, 64 * 1024 * 1024) + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 64 * 1024 * 1024) }) -test('Custom maxDecompressedMessageSize allows messages under limit', async (t) => { +test('Custom maxPayloadSize allows messages under limit', async (t) => { const server = new WebSocketServer({ port: 0, perMessageDeflate: true @@ -46,7 +46,7 @@ test('Custom maxDecompressedMessageSize allows messages under limit', async (t) // Set custom limit of 1 MB via Agent const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: 1 * 1024 * 1024 + maxPayloadSize: 1 * 1024 * 1024 } }) @@ -75,7 +75,7 @@ test('Messages at exactly the limit succeed', async (t) => { const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: limit + maxPayloadSize: limit } }) @@ -88,30 +88,30 @@ test('Messages at exactly the limit succeed', async (t) => { client.close() }) -test('Client webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { +test('Client webSocketOptions.maxPayloadSize is read correctly', async (t) => { const customLimit = 32 * 1024 * 1024 // 32 MB const client = new Client('http://localhost', { webSocket: { - maxDecompressedMessageSize: customLimit + maxPayloadSize: customLimit } }) t.after(() => client.close()) // Verify the option is stored and retrievable - t.assert.strictEqual(client.webSocketOptions.maxDecompressedMessageSize, customLimit) + t.assert.strictEqual(client.webSocketOptions.maxPayloadSize, customLimit) }) -test('Pool webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { +test('Pool webSocketOptions.maxPayloadSize is read correctly', async (t) => { const customLimit = 16 * 1024 * 1024 // 16 MB const pool = new Pool('http://localhost', { webSocket: { - maxDecompressedMessageSize: customLimit + maxPayloadSize: customLimit } }) t.after(() => pool.close()) // Verify the option is stored and retrievable - t.assert.strictEqual(pool.webSocketOptions.maxDecompressedMessageSize, customLimit) + t.assert.strictEqual(pool.webSocketOptions.maxPayloadSize, customLimit) }) diff --git a/test/websocket/permessage-deflate-limit.js b/test/websocket/permessage-deflate-limit.js index 57b81c992fb..5bd46f0b3ca 100644 --- a/test/websocket/permessage-deflate-limit.js +++ b/test/websocket/permessage-deflate-limit.js @@ -28,18 +28,18 @@ test('Compressed message under limit decompresses successfully', async (t) => { client.close() }) -test('Agent webSocketOptions.maxDecompressedMessageSize is read correctly', async (t) => { +test('Agent webSocketOptions.maxPayloadSize is read correctly', async (t) => { const customLimit = 128 * 1024 * 1024 // 128 MB const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: customLimit + maxPayloadSize: customLimit } }) t.after(() => agent.close()) // Verify the option is stored and retrievable - t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, customLimit) + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, customLimit) }) test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { @@ -48,10 +48,10 @@ test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { t.after(() => agent.close()) // Default should be 64 MB - t.assert.strictEqual(agent.webSocketOptions.maxDecompressedMessageSize, 64 * 1024 * 1024) + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 64 * 1024 * 1024) }) -test('Custom maxDecompressedMessageSize allows messages under limit', async (t) => { +test('Custom maxPayloadSize allows messages under limit', async (t) => { const server = new WebSocketServer({ port: 0, perMessageDeflate: true @@ -69,7 +69,7 @@ test('Custom maxDecompressedMessageSize allows messages under limit', async (t) // Set custom limit of 1 MB via Agent const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: 1 * 1024 * 1024 + maxPayloadSize: 1 * 1024 * 1024 } }) @@ -98,7 +98,7 @@ test('Messages at exactly the limit succeed', async (t) => { const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: limit + maxPayloadSize: limit } }) @@ -131,7 +131,7 @@ test('Messages over the limit are rejected', async (t) => { const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: limit + maxPayloadSize: limit } }) @@ -159,7 +159,7 @@ test('Messages over the limit are rejected', async (t) => { t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') }) -test('Limit can be disabled by setting maxDecompressedMessageSize to 0', async (t) => { +test('Limit can be disabled by setting maxPayloadSize to 0', async (t) => { const server = new WebSocketServer({ port: 0, perMessageDeflate: true @@ -177,7 +177,7 @@ test('Limit can be disabled by setting maxDecompressedMessageSize to 0', async ( // Set limit to 0 (disabled) const agent = new Agent({ webSocket: { - maxDecompressedMessageSize: 0 + maxPayloadSize: 0 } }) @@ -198,3 +198,43 @@ test('Limit can be disabled by setting maxDecompressedMessageSize to 0', async ( t.fail('Test timed out waiting for large message') } }) + +test('Raw uncompressed payload over limit is rejected', async (t) => { + const limit = 1 * 1024 * 1024 // 1 MB + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: false // Disable compression + }) + + t.after(() => server.close()) + await once(server, 'listening') + + let messageReceived = false + + server.on('connection', (ws) => { + // Send 2 MB uncompressed + ws.send(Buffer.alloc(2 * 1024 * 1024, 0x41), { binary: true }) + }) + + const agent = new Agent({ + webSocket: { + maxPayloadSize: limit + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + client.addEventListener('message', () => { + messageReceived = true + }) + + const closePromise = once(client, 'close') + const timeoutPromise = sleep(5000) + + await Promise.race([closePromise, timeoutPromise]) + + t.assert.strictEqual(messageReceived, false, 'Raw uncompressed message over limit should be rejected') + t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') +}) diff --git a/types/client.d.ts b/types/client.d.ts index 68f11418c01..c5dfb69ab03 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -122,13 +122,13 @@ export declare namespace Client { } export interface WebSocketOptions { /** - * Maximum allowed size in bytes for decompressed WebSocket messages when using the permessage-deflate extension. - * Prevents decompression bomb attacks where a small compressed payload expands to an extremely large size. - * The check uses a conservative 10x expansion ratio estimate for early rejection. + * Maximum allowed payload size in bytes for WebSocket messages. + * Applied to both uncompressed and decompressed (permessage-deflate) messages. + * For decompressed messages, uses a conservative 10x expansion ratio estimate for early rejection. * Set to 0 to disable the limit. * @default 67108864 (64 MB) */ - maxDecompressedMessageSize?: number; + maxPayloadSize?: number; } } From 89b2df0a17a737379ea08c553b0031fce5f5c7fd Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 9 Apr 2026 08:03:02 +0000 Subject: [PATCH 4/5] fix(websocket): raise maxPayloadSize default and validate early --- docs/docs/api/Client.md | 2 +- lib/dispatcher/dispatcher-base.js | 2 +- lib/web/websocket/permessage-deflate.js | 4 +- lib/web/websocket/receiver.js | 32 ++++++-- test/websocket/permessage-deflate-config.js | 6 +- test/websocket/permessage-deflate-limit.js | 90 +++++++++++++++++++-- types/client.d.ts | 2 +- 7 files changed, 119 insertions(+), 19 deletions(-) diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index 8a2da0a7401..cb9a47fb5dd 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -25,7 +25,7 @@ Returns: `Client` * **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options. - * **maxPayloadSize** `number` (optional) - Default: `67108864` (64 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to both uncompressed and decompressed (permessage-deflate) messages. For decompressed messages, uses a conservative 10x expansion ratio estimate for early rejection. Set to 0 to disable the limit. + * **maxPayloadSize** `number` (optional) - Default: `134217728` (128 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to both uncompressed and decompressed (permessage-deflate) messages. For decompressed messages, uses a conservative 10x expansion ratio estimate for early rejection. Set to 0 to disable the limit. * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source. diff --git a/lib/dispatcher/dispatcher-base.js b/lib/dispatcher/dispatcher-base.js index 74add6a7ac4..4554f5c999f 100644 --- a/lib/dispatcher/dispatcher-base.js +++ b/lib/dispatcher/dispatcher-base.js @@ -39,7 +39,7 @@ class DispatcherBase extends Dispatcher { */ get webSocketOptions () { return { - maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 64 * 1024 * 1024 // 64 MB default + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 // 128 MB default } } diff --git a/lib/web/websocket/permessage-deflate.js b/lib/web/websocket/permessage-deflate.js index 7e789572c54..16f176e4eec 100644 --- a/lib/web/websocket/permessage-deflate.js +++ b/lib/web/websocket/permessage-deflate.js @@ -8,8 +8,8 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff]) const kBuffer = Symbol('kBuffer') const kLength = Symbol('kLength') -// Default maximum decompressed message size: 64 MB -const kDefaultMaxDecompressedSize = 64 * 1024 * 1024 +// Default maximum decompressed message size: 128 MB +const kDefaultMaxDecompressedSize = 128 * 1024 * 1024 // Maximum expansion ratio for estimated size check (conservative DEFLATE upper bound) const kMaxExpansionRatio = 10 diff --git a/lib/web/websocket/receiver.js b/lib/web/websocket/receiver.js index 500a605223b..60c3889a7c3 100644 --- a/lib/web/websocket/receiver.js +++ b/lib/web/websocket/receiver.js @@ -71,6 +71,20 @@ class ByteParser extends Writable { this.run(callback) } + #validatePayloadLength () { + if ( + this.#maxPayloadSize > 0 && + !isControlFrame(this.#info.opcode) && + !this.#info.compressed && + this.#fragmentsBytes + this.#info.payloadLength > this.#maxPayloadSize + ) { + failWebsocketConnection(this.#handler, 1009, 'Payload size exceeds maximum allowed size') + return false + } + + return true + } + /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -174,6 +188,10 @@ class ByteParser extends Writable { this.#info.masked = masked this.#info.fin = fin this.#info.fragmented = fragmented + + if (this.#state === parserStates.READ_DATA && !this.#validatePayloadLength()) { + return + } } else if (this.#state === parserStates.PAYLOADLENGTH_16) { if (this.#byteOffset < 2) { return callback() @@ -183,6 +201,10 @@ class ByteParser extends Writable { this.#info.payloadLength = buffer.readUInt16BE(0) this.#state = parserStates.READ_DATA + + if (!this.#validatePayloadLength()) { + return + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback() @@ -205,6 +227,10 @@ class ByteParser extends Writable { this.#info.payloadLength = lower this.#state = parserStates.READ_DATA + + if (!this.#validatePayloadLength()) { + return + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback() @@ -217,12 +243,6 @@ class ByteParser extends Writable { this.#state = parserStates.INFO } else { if (!this.#info.compressed) { - // Check raw payload size before accepting uncompressed data - if (this.#maxPayloadSize > 0 && this.#info.payloadLength > this.#maxPayloadSize) { - failWebsocketConnection(this.#handler, 1009, 'Payload size exceeds maximum allowed size') - return - } - this.writeFragments(body) // If the frame is not fragmented, a message has been received. diff --git a/test/websocket/permessage-deflate-config.js b/test/websocket/permessage-deflate-config.js index 30812a9bb4c..4a3cc5c4d4c 100644 --- a/test/websocket/permessage-deflate-config.js +++ b/test/websocket/permessage-deflate-config.js @@ -19,13 +19,13 @@ test('Agent webSocketOptions.maxPayloadSize is read correctly', async (t) => { t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, customLimit) }) -test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { +test('Agent with default webSocketOptions uses 128 MB limit', async (t) => { const agent = new Agent() t.after(() => agent.close()) - // Default should be 64 MB - t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 64 * 1024 * 1024) + // Default should be 128 MB + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 128 * 1024 * 1024) }) test('Custom maxPayloadSize allows messages under limit', async (t) => { diff --git a/test/websocket/permessage-deflate-limit.js b/test/websocket/permessage-deflate-limit.js index 5bd46f0b3ca..b2ac8935494 100644 --- a/test/websocket/permessage-deflate-limit.js +++ b/test/websocket/permessage-deflate-limit.js @@ -42,13 +42,13 @@ test('Agent webSocketOptions.maxPayloadSize is read correctly', async (t) => { t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, customLimit) }) -test('Agent with default webSocketOptions uses 64 MB limit', async (t) => { +test('Agent with default webSocketOptions uses 128 MB limit', async (t) => { const agent = new Agent() t.after(() => agent.close()) - // Default should be 64 MB - t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 64 * 1024 * 1024) + // Default should be 128 MB + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 128 * 1024 * 1024) }) test('Custom maxPayloadSize allows messages under limit', async (t) => { @@ -199,7 +199,87 @@ test('Limit can be disabled by setting maxPayloadSize to 0', async (t) => { } }) -test('Raw uncompressed payload over limit is rejected', async (t) => { +test('Raw uncompressed payload over immediate limit is rejected', async (t) => { + const limit = 100 + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: false // Disable compression + }) + + t.after(() => server.close()) + await once(server, 'listening') + + let messageReceived = false + + server.on('connection', (ws) => { + // Send 101 bytes uncompressed so the inline payload length path is used. + ws.send(Buffer.alloc(101, 0x41), { binary: true }) + }) + + const agent = new Agent({ + webSocket: { + maxPayloadSize: limit + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + client.addEventListener('message', () => { + messageReceived = true + }) + + const closePromise = once(client, 'close') + const timeoutPromise = sleep(5000) + + await Promise.race([closePromise, timeoutPromise]) + + t.assert.strictEqual(messageReceived, false, 'Raw uncompressed message over limit should be rejected') + t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') +}) + +test('Raw uncompressed payload over 16-bit extended limit is rejected', async (t) => { + const limit = 1 * 1024 // 1 KB + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: false // Disable compression + }) + + t.after(() => server.close()) + await once(server, 'listening') + + let messageReceived = false + + server.on('connection', (ws) => { + // Send 2 KB uncompressed so the extended 16-bit payload length path is used. + ws.send(Buffer.alloc(2 * 1024, 0x41), { binary: true }) + }) + + const agent = new Agent({ + webSocket: { + maxPayloadSize: limit + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + client.addEventListener('message', () => { + messageReceived = true + }) + + const closePromise = once(client, 'close') + const timeoutPromise = sleep(5000) + + await Promise.race([closePromise, timeoutPromise]) + + t.assert.strictEqual(messageReceived, false, 'Raw uncompressed message over limit should be rejected') + t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') +}) + +test('Raw uncompressed payload over 64-bit extended limit is rejected', async (t) => { const limit = 1 * 1024 * 1024 // 1 MB const server = new WebSocketServer({ port: 0, @@ -212,7 +292,7 @@ test('Raw uncompressed payload over limit is rejected', async (t) => { let messageReceived = false server.on('connection', (ws) => { - // Send 2 MB uncompressed + // Send 2 MB uncompressed so the extended 64-bit payload length path is used. ws.send(Buffer.alloc(2 * 1024 * 1024, 0x41), { binary: true }) }) diff --git a/types/client.d.ts b/types/client.d.ts index c5dfb69ab03..895ca68dd72 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -126,7 +126,7 @@ export declare namespace Client { * Applied to both uncompressed and decompressed (permessage-deflate) messages. * For decompressed messages, uses a conservative 10x expansion ratio estimate for early rejection. * Set to 0 to disable the limit. - * @default 67108864 (64 MB) + * @default 134217728 (128 MB) */ maxPayloadSize?: number; } From c0e871e77366173c1a6aa34c2a6eee488c94c56a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 9 Apr 2026 14:39:48 +0000 Subject: [PATCH 5/5] refactor(websocket): rename internal max payload field --- lib/web/websocket/permessage-deflate.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/web/websocket/permessage-deflate.js b/lib/web/websocket/permessage-deflate.js index 16f176e4eec..23ad3346053 100644 --- a/lib/web/websocket/permessage-deflate.js +++ b/lib/web/websocket/permessage-deflate.js @@ -20,7 +20,7 @@ class PerMessageDeflate { #options = {} /** @type {number} */ - #maxDecompressedSize + #maxPayloadSize /** @type {boolean} */ #aborted = false @@ -36,7 +36,7 @@ class PerMessageDeflate { this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') // 0 disables the limit - this.#maxDecompressedSize = options.maxPayloadSize ?? kDefaultMaxDecompressedSize + this.#maxPayloadSize = options.maxPayloadSize ?? kDefaultMaxDecompressedSize } /** @@ -47,8 +47,8 @@ class PerMessageDeflate { */ #exceedsEstimatedLimit (compressedLength) { // 0 disables the limit - if (this.#maxDecompressedSize <= 0) return false - return compressedLength * kMaxExpansionRatio > this.#maxDecompressedSize + if (this.#maxPayloadSize <= 0) return false + return compressedLength * kMaxExpansionRatio > this.#maxPayloadSize } /** @@ -104,7 +104,7 @@ class PerMessageDeflate { this.#inflate[kLength] += data.length // 0 disables the limit - if (this.#maxDecompressedSize > 0 && this.#inflate[kLength] > this.#maxDecompressedSize) { + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { this.#aborted = true this.#inflate.removeAllListeners() this.#inflate.destroy()