diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index dd24ca75b04..d3d115fdef6 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -25,7 +25,8 @@ 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. - * **maxFragments** `number` (optional) - Defailt: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit. + * **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. 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 uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages. 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 fa79fbea589..feb2571abc2 100644 --- a/lib/dispatcher/dispatcher-base.js +++ b/lib/dispatcher/dispatcher-base.js @@ -11,7 +11,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy const kOnDestroyed = Symbol('onDestroyed') const kOnClosed = Symbol('onClosed') -const kWebSocketOptions = Symbol('web socket options') +const kWebSocketOptions = Symbol('webSocketOptions') class DispatcherBase extends Dispatcher { /** @type {boolean} */ @@ -27,16 +27,20 @@ class DispatcherBase extends Dispatcher { [kOnClosed] = null /** - * @param {{ webSocket?: { maxFragments?: number } }} [opts] + * @param {import('../../types/dispatcher').DispatcherOptions} [opts] */ constructor (opts) { super() this[kWebSocketOptions] = opts?.webSocket ?? {} } + /** + * @returns {import('../../types/dispatcher').WebSocketOptions} + */ get webSocketOptions () { return { - maxFragments: this[kWebSocketOptions].maxFragments ?? 131072 + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + 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 1f1a13038af..6a6e43899c5 100644 --- a/lib/web/websocket/permessage-deflate.js +++ b/lib/web/websocket/permessage-deflate.js @@ -8,40 +8,35 @@ 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 - class PerMessageDeflate { /** @type {import('node:zlib').InflateRaw} */ #inflate #options = {} - /** @type {boolean} */ - #aborted = false - - /** @type {Function|null} */ - #currentCallback = null + #maxPayloadSize = 0 /** * @param {Map} extensions */ - constructor (extensions) { + constructor (extensions, options) { this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') + + this.#maxPayloadSize = options.maxPayloadSize } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress (chunk, fin, callback) { // 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. - - if (this.#aborted) { - callback(new MessageSizeExceededError()) - return - } - if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS @@ -64,23 +59,12 @@ class PerMessageDeflate { this.#inflate[kLength] = 0 this.#inflate.on('data', (data) => { - if (this.#aborted) { - return - } - this.#inflate[kLength] += data.length - if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) { - this.#aborted = true + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()) this.#inflate.removeAllListeners() - this.#inflate.destroy() this.#inflate = null - - if (this.#currentCallback) { - const cb = this.#currentCallback - this.#currentCallback = null - cb(new MessageSizeExceededError()) - } return } @@ -93,14 +77,13 @@ class PerMessageDeflate { }) } - this.#currentCallback = callback this.#inflate.write(chunk) if (fin) { this.#inflate.write(tail) } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return } @@ -108,7 +91,6 @@ class PerMessageDeflate { this.#inflate[kBuffer].length = 0 this.#inflate[kLength] = 0 - this.#currentCallback = null callback(null, full) }) diff --git a/lib/web/websocket/receiver.js b/lib/web/websocket/receiver.js index 57353652f79..a37bd22265a 100644 --- a/lib/web/websocket/receiver.js +++ b/lib/web/websocket/receiver.js @@ -42,10 +42,13 @@ class ByteParser extends Writable { /** @type {number} */ #maxFragments + /** @type {number} */ + #maxPayloadSize + /** * @param {import('./websocket').Handler} handler * @param {Map|null} extensions - * @param {{ maxFragments?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor (handler, extensions, options = {}) { super() @@ -53,9 +56,10 @@ class ByteParser extends Writable { this.#handler = handler this.#extensions = extensions == null ? new Map() : extensions this.#maxFragments = options.maxFragments ?? 0 + this.#maxPayloadSize = options.maxPayloadSize ?? 0 if (this.#extensions.has('permessage-deflate')) { - this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions)) + this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options)) } } @@ -71,6 +75,19 @@ class ByteParser extends Writable { this.run(callback) } + #validatePayloadLength () { + if ( + this.#maxPayloadSize > 0 && + !isControlFrame(this.#info.opcode) && + this.#info.payloadLength + this.#fragmentsBytes > 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, @@ -159,6 +176,10 @@ class ByteParser extends Writable { if (payloadLength <= 125) { this.#info.payloadLength = payloadLength this.#state = parserStates.READ_DATA + + if (!this.#validatePayloadLength()) { + return + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16 } else if (payloadLength === 127) { @@ -183,6 +204,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 +230,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,7 +246,7 @@ class ByteParser extends Writable { this.#state = parserStates.INFO } else { if (!this.#info.compressed) { - if (body.length && !this.writeFragments(body)) { + if (!this.writeFragments(body)) { return } @@ -231,31 +260,41 @@ 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 - } - - if (data.length && !this.writeFragments(data)) { - return - } - - if (!this.#info.fin) { - this.#state = parserStates.INFO + this.#extensions.get('permessage-deflate').decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + const code = error instanceof MessageSizeExceededError ? 1009 : 1007 + failWebsocketConnection(this.#handler, code, error.message) + return + } + + if (!this.writeFragments(data)) { + return + } + + // Check cumulative fragment size + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnection(this.#handler, 1009, new MessageSizeExceededError().message) + return + } + + if (!this.#info.fin) { + this.#state = parserStates.INFO + this.#loop = true + this.run(callback) + return + } + + websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments()) + 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.#fragmentsBytes + ) this.#loop = false break diff --git a/lib/web/websocket/stream/websocketstream.js b/lib/web/websocket/stream/websocketstream.js index c248625e843..1da0292b465 100644 --- a/lib/web/websocket/stream/websocketstream.js +++ b/lib/web/websocket/stream/websocketstream.js @@ -258,7 +258,14 @@ class WebSocketStream { #onConnectionEstablished (response, parsedExtensions) { this.#handler.socket = response.socket - const parser = new ByteParser(this.#handler, parsedExtensions) + // Get options from dispatcher options + const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments + const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize + + const parser = new ByteParser(this.#handler, parsedExtensions, { + maxFragments, + maxPayloadSize + }) parser.on('drain', () => this.#handler.onParserDrain()) parser.on('error', (err) => this.#handler.onParserError(err)) diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js index 03f668ce586..4f816ef17a7 100644 --- a/lib/web/websocket/websocket.js +++ b/lib/web/websocket/websocket.js @@ -468,10 +468,13 @@ class WebSocket extends EventTarget { // once this happens, the connection is open this.#handler.socket = response.socket - const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments + const webSocketOptions = this.#handler.controller.dispatcher?.webSocketOptions + const maxFragments = webSocketOptions?.maxFragments + const maxPayloadSize = webSocketOptions?.maxPayloadSize const parser = new ByteParser(this.#handler, parsedExtensions, { - maxFragments + maxFragments, + maxPayloadSize }) parser.on('drain', () => this.#handler.onParserDrain()) parser.on('error', (err) => this.#handler.onParserError(err)) diff --git a/test/websocket/fragments.js b/test/websocket/fragments.js index 474a1e5bec8..0c79b017a86 100644 --- a/test/websocket/fragments.js +++ b/test/websocket/fragments.js @@ -43,6 +43,15 @@ test('Fragmented frame with a ping frame in the middle of it', (t) => { test('Too many fragments (uncompressed)', (t, done) => { t.plan(4) + function maybeDone () { + if (++maybeDone.callCount === 2) { + agent.close() + server.close(done) + } + } + + maybeDone.callCount = 0 + const agent = new Agent({ webSocket: { maxFragments: 3 @@ -61,6 +70,7 @@ test('Too many fragments (uncompressed)', (t, done) => { client.addEventListener('close', (event) => { t.assert.deepStrictEqual(event.code, 1006) + maybeDone() }) }) @@ -68,8 +78,7 @@ test('Too many fragments (uncompressed)', (t, done) => { ws.on('close', (code, reason) => { t.assert.deepStrictEqual(code, 1008) t.assert.deepStrictEqual(reason.toString(), 'Too many message fragments') - agent.close() - server.close(done) + maybeDone() }) const fragment = Buffer.from('a') @@ -85,6 +94,15 @@ test('Too many fragments (uncompressed)', (t, done) => { test('Too many fragments (compressed)', (t, done) => { t.plan(4) + function maybeDone () { + if (++maybeDone.callCount === 2) { + agent.close() + server.close(done) + } + } + + maybeDone.callCount = 0 + const agent = new Agent({ webSocket: { maxFragments: 3 @@ -106,6 +124,7 @@ test('Too many fragments (compressed)', (t, done) => { client.addEventListener('close', (event) => { t.assert.deepStrictEqual(event.code, 1006) + maybeDone() }) }) @@ -113,8 +132,7 @@ test('Too many fragments (compressed)', (t, done) => { ws.on('close', (code, reason) => { t.assert.deepStrictEqual(code, 1008) t.assert.deepStrictEqual(reason.toString(), 'Too many message fragments') - agent.close() - server.close(done) + maybeDone() }) const fragment = Buffer.from('a') @@ -126,3 +144,87 @@ test('Too many fragments (compressed)', (t, done) => { ws.send(fragment, options) }) }) + +test('Empty first fragment followed by non-empty continuation delivers the message', (t) => { + // RFC 6455 ยง5.4 allows zero-byte fragments. A conforming server that opens + // a fragmented message with an empty frame must be honored: the parser must + // recognize the in-progress fragmented message when the continuation arrives. + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + ws.send('', { fin: false }) // Text frame fin=0, len=0 + ws.send('hello', { fin: true }) // Continuation fin=1, "hello" + }) + + after(() => { + for (const client of server.clients) { + client.close() + } + + server.close() + }) + + const ws = new WebSocket(`ws://localhost:${server.address().port}`) + + return new Promise((resolve) => { + ws.addEventListener('message', ({ data }) => { + t.assert.strictEqual(data, 'hello') + + ws.close() + resolve() + }) + }) +}) + +test('Too many empty fragments triggers close 1008', (t, done) => { + // Empty fragments must still count toward maxFragments; otherwise a + // peer can flood zero-byte continuation frames forever. + t.plan(4) + + function maybeDone () { + if (++maybeDone.callCount === 2) { + agent.close() + server.close(done) + } + } + + maybeDone.callCount = 0 + + const agent = new Agent({ + webSocket: { + maxFragments: 3 + } + }) + + const server = new WebSocketServer({ port: 0 }, () => { + const { port } = server.address() + const client = new WebSocket(`ws://127.0.0.1:${port}`, { + dispatcher: agent + }) + + client.addEventListener('error', (event) => { + t.assert.ok(true) + }) + + client.addEventListener('close', (event) => { + t.assert.deepStrictEqual(event.code, 1006) + maybeDone() + }) + }) + + server.on('connection', (ws) => { + ws.on('close', (code, reason) => { + t.assert.deepStrictEqual(code, 1008) + t.assert.deepStrictEqual(reason.toString(), 'Too many message fragments') + maybeDone() + }) + + const fragment = '' + const options = { fin: false } + + ws.send(fragment, options) // Text frame fin=0, len=0 + ws.send(fragment, options) // Continuation fin=0, len=0 + ws.send(fragment, options) // Continuation fin=0, len=0 + ws.send(fragment, options) // Continuation fin=0, len=0 + }) +}) diff --git a/test/websocket/permessage-deflate-config.js b/test/websocket/permessage-deflate-config.js new file mode 100644 index 00000000000..4a3cc5c4d4c --- /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.maxPayloadSize is read correctly', async (t) => { + const customLimit = 128 * 1024 * 1024 // 128 MB + const agent = new Agent({ + webSocket: { + maxPayloadSize: customLimit + } + }) + + t.after(() => agent.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, customLimit) +}) + +test('Agent with default webSocketOptions uses 128 MB limit', async (t) => { + const agent = new Agent() + + t.after(() => agent.close()) + + // Default should be 128 MB + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 128 * 1024 * 1024) +}) + +test('Custom maxPayloadSize 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: { + maxPayloadSize: 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: { + maxPayloadSize: 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.maxPayloadSize is read correctly', async (t) => { + const customLimit = 32 * 1024 * 1024 // 32 MB + const client = new Client('http://localhost', { + webSocket: { + maxPayloadSize: customLimit + } + }) + + t.after(() => client.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(client.webSocketOptions.maxPayloadSize, customLimit) +}) + +test('Pool webSocketOptions.maxPayloadSize is read correctly', async (t) => { + const customLimit = 16 * 1024 * 1024 // 16 MB + const pool = new Pool('http://localhost', { + webSocket: { + maxPayloadSize: customLimit + } + }) + + t.after(() => pool.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(pool.webSocketOptions.maxPayloadSize, customLimit) +}) diff --git a/test/websocket/permessage-deflate-limit.js b/test/websocket/permessage-deflate-limit.js index 8764ab9eaf6..10ea94e7087 100644 --- a/test/websocket/permessage-deflate-limit.js +++ b/test/websocket/permessage-deflate-limit.js @@ -2,8 +2,11 @@ const { test } = require('node:test') const { once } = require('node:events') +const { randomFillSync } = require('node:crypto') +const { deflateRawSync } = require('node:zlib') +const { setTimeout: sleep } = require('node:timers/promises') 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 +29,439 @@ test('Compressed message under limit decompresses successfully', async (t) => { t.assert.strictEqual(event.data.size, 1024) client.close() }) + +test('Agent webSocketOptions.maxPayloadSize is read correctly', async (t) => { + const customLimit = 128 * 1024 * 1024 // 128 MB + const agent = new Agent({ + webSocket: { + maxPayloadSize: customLimit + } + }) + + t.after(() => agent.close()) + + // Verify the option is stored and retrievable + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, customLimit) +}) + +test('Agent with default webSocketOptions uses 128 MB limit', async (t) => { + const agent = new Agent() + + t.after(() => agent.close()) + + // Default should be 128 MB + t.assert.strictEqual(agent.webSocketOptions.maxPayloadSize, 128 * 1024 * 1024) +}) + +test('Custom maxPayloadSize 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: { + maxPayloadSize: 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: { + maxPayloadSize: 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('Compressed frame payload over wire-size limit is rejected', async (t) => { + const limit = 64 * 1024 + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + let payload = null + for (let i = 0; i < 10; i++) { + const candidate = randomFillSync(Buffer.alloc(limit)) + if (deflateRawSync(candidate).length > limit) { + payload = candidate + break + } + } + + t.assert.ok(payload, 'Expected incompressible payload with compressed wire size over the limit') + + let messageReceived = false + + server.on('connection', (ws) => { + ws.send(payload, { binary: true, compress: 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, 'Compressed frame over wire-size limit should be rejected') + t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') +}) + +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: { + 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 + }) + + 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 = sleep(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') +}) + +test('Limit can be disabled by setting maxPayloadSize to 0', async (t) => { + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + // Keep this comfortably above the smaller limits used elsewhere in this file, + // while avoiding the 100 MB transfer that can be slow on CI. + const dataSize = 2 * 1024 * 1024 // 2 MB + + server.on('connection', (ws) => { + ws.send(Buffer.alloc(dataSize, 0x41), { binary: true }) + }) + + // Set limit to 0 (disabled) + const agent = new Agent({ + webSocket: { + maxPayloadSize: 0 + } + }) + + t.after(() => agent.close()) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + const timeout = Symbol('timeout') + + const result = await Promise.race([ + once(client, 'message'), + sleep(10000, timeout) + ]) + + t.assert.notStrictEqual(result, timeout, 'Test timed out waiting for large message') + t.assert.strictEqual(result[0].data.size, dataSize, 'Large message should be received when limit is disabled') + client.close() +}) + +test('Fragmented compressed payload over total limit is rejected', async (t) => { + const limit = 1 * 1024 * 1024 // 1 MB + const fragmentSize = 768 * 1024 // 768 KB + const server = new WebSocketServer({ + port: 0, + perMessageDeflate: true + }) + + t.after(() => server.close()) + await once(server, 'listening') + + let messageReceived = false + + server.on('connection', (ws) => { + ws.send(Buffer.alloc(fragmentSize, 0x41), { + binary: true, + compress: true, + fin: false + }) + + ws.send(Buffer.alloc(fragmentSize, 0x41), { + binary: true, + compress: true, + fin: 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, 'Fragmented compressed message over total limit should be rejected') + t.assert.strictEqual(client.readyState, WebSocket.CLOSED, 'Connection should be closed after exceeding limit') +}) + +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, + perMessageDeflate: false // Disable compression + }) + + t.after(() => server.close()) + await once(server, 'listening') + + let messageReceived = false + + server.on('connection', (ws) => { + // Send 2 MB uncompressed so the extended 64-bit payload length path is used. + 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') +}) + +test('cumulative payload size', (t, done) => { + t.plan(1) + + const LIMIT = 100 + const FRAGMENT_SIZE = 60 + const NUM_FRAGMENTS = 10 + + const server = new WebSocketServer({ port: 0 }) + + server.on('connection', (ws) => { + const socket = ws._socket + const payload = Buffer.alloc(FRAGMENT_SIZE, 0x41) + + for (let i = 0; i < NUM_FRAGMENTS; i++) { + const fin = i === NUM_FRAGMENTS - 1 ? 0x80 : 0x00 + const opcode = i === 0 ? 0x02 : 0x00 + const header = Buffer.alloc(2) + header[0] = fin | opcode + header[1] = FRAGMENT_SIZE + socket.write(header) + socket.write(payload) + } + }) + + const agent = new Agent({ + webSocket: { + maxPayloadSize: LIMIT + } + }) + + const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { dispatcher: agent }) + + t.after(async () => { + client.close() + server.close() + await agent.close() + }) + + client.onmessage = t.assert.fail + + client.addEventListener('error', (event) => { + t.assert.ok(event) + done() + }) +}) diff --git a/test/websocket/stream/too-many-fragments.js b/test/websocket/stream/too-many-fragments.js new file mode 100644 index 00000000000..0777a4b9d43 --- /dev/null +++ b/test/websocket/stream/too-many-fragments.js @@ -0,0 +1,58 @@ +'use strict' + +const { test } = require('node:test') +const { WebSocketServer } = require('ws') +const { + Agent, + WebSocketStream, + getGlobalDispatcher, + setGlobalDispatcher +} = require('../../..') + +test('Too many fragments via WebSocketStream triggers close 1008', async (t) => { + // WebSocketStream reads its dispatcher from the global one (its options + // dictionary doesn't accept a dispatcher), so we swap it for the duration + // of this test. The fragment-count limit must apply to WebSocketStream + // the same way it applies to the WebSocket API. + const previous = getGlobalDispatcher() + const agent = new Agent({ + webSocket: { + maxFragments: 3 + } + }) + setGlobalDispatcher(agent) + + const server = new WebSocketServer({ port: 0 }) + + t.after(async () => { + setGlobalDispatcher(previous) + await agent.close() + server.close() + }) + + const serverClose = new Promise((resolve) => { + server.on('connection', (ws) => { + ws.on('close', (code, reason) => { + resolve({ code, reason: reason.toString() }) + }) + + const fragment = Buffer.from('a') + const options = { fin: false } + + ws.send(fragment, options) + ws.send(fragment, options) + ws.send(fragment, options) + ws.send(fragment, options) + }) + }) + + const wss = new WebSocketStream(`ws://localhost:${server.address().port}`) + + // The connection will be failed by the parser; both `opened` and `closed` + // settle. We only care that the server observed the policy-violation close. + await Promise.allSettled([wss.opened, wss.closed]) + + const observed = await serverClose + t.assert.deepStrictEqual(observed.code, 1008) + t.assert.deepStrictEqual(observed.reason, 'Too many message fragments') +}) diff --git a/types/client.d.ts b/types/client.d.ts index 4d9817db057..6541b49ee4c 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -126,6 +126,13 @@ export declare namespace Client { * @default 131072 */ maxFragments?: number; + /** + * Maximum allowed payload size in bytes for WebSocket messages. + * Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages. + * Set to 0 to disable the limit. + * @default 134217728 (128 MB) + */ + maxPayloadSize?: number; } }