diff --git a/docs/docs/api/WebSocket.md b/docs/docs/api/WebSocket.md index 418983f56bf..55a76aae64e 100644 --- a/docs/docs/api/WebSocket.md +++ b/docs/docs/api/WebSocket.md @@ -18,7 +18,6 @@ When passing an object as the second argument, the following options are availab * **protocols** `string | string[]` (optional) - Subprotocol(s) to request the server use. * **dispatcher** `Dispatcher` (optional) - A custom [`Dispatcher`](/docs/docs/api/Dispatcher.md) to use for the connection. * **headers** `HeadersInit` (optional) - Custom headers to include in the WebSocket handshake request. -* **maxDecompressedMessageSize** `number` (optional) - Maximum allowed size in bytes for decompressed messages when using the `permessage-deflate` extension. **Default:** `4194304` (4 MB). ### Example: @@ -43,26 +42,6 @@ import { WebSocket } from 'undici' const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat']) ``` -### Example with custom decompression limit: - -To protect against decompression bombs (small compressed payloads that expand to very large sizes), you can set a custom limit: - -```mjs -import { WebSocket } from 'undici' - -// Limit decompressed messages to 1 MB -const ws = new WebSocket('wss://echo.websocket.events', { - maxDecompressedMessageSize: 1 * 1024 * 1024 -}) - -ws.addEventListener('error', (event) => { - // Connection will be closed if a message exceeds the limit - console.error('WebSocket error:', event.error) -}) -``` - -> ⚠️ **Security Note**: The `maxDecompressedMessageSize` option protects against memory exhaustion attacks where a malicious server sends a small compressed payload that decompresses to an extremely large size. If you increase this limit significantly above the default, ensure your application can handle the increased memory usage. - ### Example with HTTP/2: > ⚠️ Warning: WebSocket over HTTP/2 is experimental, it is likely to change in the future. diff --git a/lib/web/websocket/permessage-deflate.js b/lib/web/websocket/permessage-deflate.js index 9ff22ef2965..1f1a13038af 100644 --- a/lib/web/websocket/permessage-deflate.js +++ b/lib/web/websocket/permessage-deflate.js @@ -17,9 +17,6 @@ class PerMessageDeflate { #options = {} - /** @type {number} */ - #maxDecompressedSize - /** @type {boolean} */ #aborted = false @@ -28,12 +25,10 @@ class PerMessageDeflate { /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor (extensions, options = {}) { + constructor (extensions) { this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize } decompress (chunk, fin, callback) { @@ -75,7 +70,7 @@ class PerMessageDeflate { this.#inflate[kLength] += data.length - if (this.#inflate[kLength] > this.#maxDecompressedSize) { + if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) { this.#aborted = true this.#inflate.removeAllListeners() this.#inflate.destroy() diff --git a/lib/web/websocket/receiver.js b/lib/web/websocket/receiver.js index 13ad8b48201..384808d1b7e 100644 --- a/lib/web/websocket/receiver.js +++ b/lib/web/websocket/receiver.js @@ -39,23 +39,18 @@ class ByteParser extends Writable { /** @type {import('./websocket').Handler} */ #handler - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options - /** * @param {import('./websocket').Handler} handler * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor (handler, extensions, options = {}) { + constructor (handler, extensions) { super() this.#handler = handler this.#extensions = extensions == null ? new Map() : extensions - this.#options = options if (this.#extensions.has('permessage-deflate')) { - this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options)) + this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions)) } } diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js index 676b20164df..64ead0d41c3 100644 --- a/lib/web/websocket/websocket.js +++ b/lib/web/websocket/websocket.js @@ -109,8 +109,6 @@ class WebSocket extends EventTarget { #binaryType /** @type {import('./receiver').ByteParser} */ #parser - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options /** * @param {string} url @@ -156,11 +154,6 @@ class WebSocket extends EventTarget { // 5. Set this's url to urlRecord. this.#url = new URL(urlRecord.href) - // Store options for later use (e.g., maxDecompressedMessageSize) - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - } - // 6. Let client be this's relevant settings object. const client = environmentSettingsObject.settingsObject @@ -463,7 +456,7 @@ class WebSocket extends EventTarget { // once this happens, the connection is open this.#handler.socket = response.socket - const parser = new ByteParser(this.#handler, parsedExtensions, this.#options) + const parser = new ByteParser(this.#handler, parsedExtensions) parser.on('drain', () => this.#handler.onParserDrain()) parser.on('error', (err) => this.#handler.onParserError(err)) @@ -715,19 +708,6 @@ webidl.converters.WebSocketInit = webidl.dictionaryConverter([ { key: 'headers', converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: 'maxDecompressedMessageSize', - converter: webidl.nullableConverter((V) => { - V = webidl.converters['unsigned long long'](V) - if (V <= 0) { - throw webidl.errors.exception({ - header: 'WebSocket constructor', - message: 'maxDecompressedMessageSize must be greater than 0' - }) - } - return V - }) } ]) diff --git a/test/websocket/permessage-deflate-limit.js b/test/websocket/permessage-deflate-limit.js index d2a7b2193c6..8764ab9eaf6 100644 --- a/test/websocket/permessage-deflate-limit.js +++ b/test/websocket/permessage-deflate-limit.js @@ -2,61 +2,9 @@ const { test } = require('node:test') const { once } = require('node:events') -const http = require('node:http') -const crypto = require('node:crypto') -const zlib = require('node:zlib') const { WebSocketServer } = require('ws') const { WebSocket } = require('../..') -/** - * Creates a WebSocket frame. - * @param {object} options - * @param {number} options.opcode - Frame opcode (1=text, 2=binary) - * @param {boolean} options.fin - Final frame flag - * @param {boolean} options.rsv1 - RSV1 flag (compression) - * @param {Buffer} options.payload - Frame payload - * @returns {Buffer} - */ -function createWebSocketFrame ({ opcode, fin = true, rsv1 = false, payload }) { - const payloadLength = payload.length - let headerLength = 2 - - if (payloadLength > 65535) { - headerLength += 8 - } else if (payloadLength > 125) { - headerLength += 2 - } - - const header = Buffer.alloc(headerLength) - - // First byte: FIN + RSV1 + opcode - header[0] = (fin ? 0x80 : 0x00) | (rsv1 ? 0x40 : 0x00) | opcode - - // Second byte: MASK (0) + payload length - if (payloadLength > 65535) { - header[1] = 127 - header.writeBigUInt64BE(BigInt(payloadLength), 2) - } else if (payloadLength > 125) { - header[1] = 126 - header.writeUInt16BE(payloadLength, 2) - } else { - header[1] = payloadLength - } - - return Buffer.concat([header, payload]) -} - -/** - * Creates a compressed payload using DEFLATE raw. - * @param {number} targetSize - Target decompressed size in bytes - * @returns {Buffer} - */ -function createCompressedPayload (targetSize) { - // Create highly compressible data (repeated 'A' characters) - const data = Buffer.alloc(targetSize, 0x41) - return zlib.deflateRawSync(data) -} - test('Compressed message under limit decompresses successfully', async (t) => { const server = new WebSocketServer({ port: 0, @@ -78,205 +26,3 @@ test('Compressed message under limit decompresses successfully', async (t) => { t.assert.strictEqual(event.data.size, 1024) client.close() }) - -test('Custom maxDecompressedMessageSize is enforced', async (t) => { - const server = new WebSocketServer({ - port: 0, - perMessageDeflate: true - }) - - t.after(() => server.close()) - - await once(server, 'listening') - - let messageReceived = false - - server.on('connection', (ws) => { - // Send 2 MB of data - ws.send(Buffer.alloc(2 * 1024 * 1024, 0x41), { binary: true }) - }) - - // Set custom limit of 1 MB - const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { - maxDecompressedMessageSize: 1 * 1024 * 1024 - }) - - client.addEventListener('message', () => { - messageReceived = true - }) - - // Wait for the connection to close - await once(client, 'close') - - // The message should NOT have been received due to size limit - t.assert.strictEqual(messageReceived, false) -}) - -test('Message exactly at limit succeeds', 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 client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { - maxDecompressedMessageSize: limit - }) - - const [event] = await once(client, 'message') - t.assert.strictEqual(event.data.size, limit) - client.close() -}) - -test('Message one byte over limit fails', 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 + 1, 0x41), { binary: true }) - }) - - const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { - maxDecompressedMessageSize: limit - }) - - const [event] = await once(client, 'error') - t.assert.ok(event.error instanceof Error) -}) - -test('Connection closes when limit exceeded', async (t) => { - const server = new WebSocketServer({ - port: 0, - perMessageDeflate: true - }) - - t.after(() => server.close()) - - await once(server, 'listening') - - server.on('connection', (ws) => { - ws.send(Buffer.alloc(2 * 1024 * 1024, 0x41), { binary: true }) - }) - - const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { - maxDecompressedMessageSize: 1 * 1024 * 1024 - }) - - const [event] = await once(client, 'close') - // Connection should be closed - code 1006 (abnormal) or 1009 (too big) - t.assert.ok(event.code === 1006 || event.code === 1009) -}) - -test('Non-compressed messages are not affected by decompression limit', async (t) => { - const server = new WebSocketServer({ - port: 0, - perMessageDeflate: false // Compression disabled - }) - - t.after(() => server.close()) - - await once(server, 'listening') - - server.on('connection', (ws) => { - ws.send(Buffer.alloc(2 * 1024 * 1024, 0x41), { binary: true }) - }) - - const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { - maxDecompressedMessageSize: 1 * 1024 * 1024 - }) - - // Should succeed because compression is not used - const [event] = await once(client, 'message') - t.assert.strictEqual(event.data.size, 2 * 1024 * 1024) - client.close() -}) - -test('Decompression bomb is mitigated via raw WebSocket handshake', async (t) => { - // This test validates the fix using a technique similar to the original PoC - // by creating a minimal malicious server that sends a compressed payload - const server = http.createServer() - - let messageReceived = false - - server.on('upgrade', (req, socket) => { - const key = req.headers['sec-websocket-key'] - const accept = crypto - .createHash('sha1') - .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') - .digest('base64') - - socket.write([ - 'HTTP/1.1 101 Switching Protocols', - 'Upgrade: websocket', - 'Connection: Upgrade', - `Sec-WebSocket-Accept: ${accept}`, - 'Sec-WebSocket-Extensions: permessage-deflate', - '', '' - ].join('\r\n')) - - // Send a small payload that decompresses to ~10 MB - setTimeout(() => { - const bomb = createCompressedPayload(10 * 1024 * 1024) - const frame = createWebSocketFrame({ opcode: 2, rsv1: true, payload: bomb }) - socket.write(frame) - }, 100) - }) - - await new Promise(resolve => server.listen(0, resolve)) - t.after(() => server.close()) - - const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { - maxDecompressedMessageSize: 1 * 1024 * 1024 // 1 MB limit - }) - - client.addEventListener('message', () => { - messageReceived = true - }) - - // Wait for the connection to close - await once(client, 'close') - - // The message should NOT have been received due to size limit - t.assert.strictEqual(messageReceived, false) -}) - -test('Higher custom limit allows larger messages', async (t) => { - const server = new WebSocketServer({ - port: 0, - perMessageDeflate: true - }) - - t.after(() => server.close()) - - await once(server, 'listening') - - const dataSize = 5 * 1024 * 1024 // 5 MB - - server.on('connection', (ws) => { - ws.send(Buffer.alloc(dataSize, 0x41), { binary: true }) - }) - - // Set custom limit of 10 MB - const client = new WebSocket(`ws://127.0.0.1:${server.address().port}`, { - maxDecompressedMessageSize: 10 * 1024 * 1024 - }) - - const [event] = await once(client, 'message') - t.assert.strictEqual(event.data.size, dataSize) - client.close() -}) diff --git a/types/websocket.d.ts b/types/websocket.d.ts index 7260e939ac7..d48b9dadc01 100644 --- a/types/websocket.d.ts +++ b/types/websocket.d.ts @@ -147,14 +147,7 @@ export declare const ErrorEvent: { interface WebSocketInit { protocols?: string | string[], dispatcher?: Dispatcher, - headers?: HeadersInit, - /** - * Maximum size in bytes for decompressed WebSocket messages. - * When a message exceeds this limit during decompression, the connection - * will be closed with status code 1009 (Message Too Big). - * @default 4194304 (4 MB) - */ - maxDecompressedMessageSize?: number + headers?: HeadersInit } interface WebSocketStreamOptions {