@@ -30,2 +30,3 @@ # Errors | ||
| | `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed | | ||
| | `MessageSizeExceededError` | `UND_ERR_WS_MESSAGE_SIZE_EXCEEDED` | WebSocket decompressed message exceeded the maximum allowed size | | ||
@@ -32,0 +33,0 @@ ### `SocketError` |
@@ -16,2 +16,11 @@ # Class: WebSocket | ||
| ### WebSocketInit | ||
| When passing an object as the second argument, the following options are available: | ||
| * **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: | ||
@@ -40,2 +49,16 @@ | ||
| ### 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' | ||
| const ws = new WebSocket('wss://echo.websocket.events', { | ||
| maxDecompressedMessageSize: 1 * 1024 * 1024 | ||
| }) | ||
| ``` | ||
| > ⚠️ **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. | ||
| ## Read More | ||
@@ -42,0 +65,0 @@ |
+20
-1
@@ -382,2 +382,20 @@ 'use strict' | ||
| const kMessageSizeExceededError = Symbol.for('undici.error.UND_ERR_WS_MESSAGE_SIZE_EXCEEDED') | ||
| class MessageSizeExceededError extends UndiciError { | ||
| constructor (message) { | ||
| super(message) | ||
| this.name = 'MessageSizeExceededError' | ||
| this.message = message || 'Max decompressed message size exceeded' | ||
| this.code = 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED' | ||
| } | ||
| static [Symbol.hasInstance] (instance) { | ||
| return instance && instance[kMessageSizeExceededError] === true | ||
| } | ||
| get [kMessageSizeExceededError] () { | ||
| return true | ||
| } | ||
| } | ||
| module.exports = { | ||
@@ -406,3 +424,4 @@ AbortError, | ||
| ResponseError, | ||
| SecureProxyConnectionError | ||
| SecureProxyConnectionError, | ||
| MessageSizeExceededError | ||
| } |
+12
-2
@@ -69,2 +69,6 @@ 'use strict' | ||
| if (upgrade && !isValidHeaderValue(upgrade)) { | ||
| throw new InvalidArgumentError('invalid upgrade header') | ||
| } | ||
| if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) { | ||
@@ -364,3 +368,6 @@ throw new InvalidArgumentError('invalid headersTimeout') | ||
| if (request.host === null && headerName === 'host') { | ||
| if (headerName === 'host') { | ||
| if (request.host !== null) { | ||
| throw new InvalidArgumentError('duplicate host header') | ||
| } | ||
| if (typeof val !== 'string') { | ||
@@ -371,3 +378,6 @@ throw new InvalidArgumentError('invalid host header') | ||
| request.host = val | ||
| } else if (request.contentLength === null && headerName === 'content-length') { | ||
| } else if (headerName === 'content-length') { | ||
| if (request.contentLength !== null) { | ||
| throw new InvalidArgumentError('duplicate content-length header') | ||
| } | ||
| request.contentLength = parseInt(val, 10) | ||
@@ -374,0 +384,0 @@ if (!Number.isFinite(request.contentLength)) { |
@@ -5,2 +5,3 @@ 'use strict' | ||
| const { isValidClientWindowBits } = require('./util') | ||
| const { MessageSizeExceededError } = require('../../core/errors') | ||
@@ -11,2 +12,5 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff]) | ||
| // Default maximum decompressed message size: 4 MB | ||
| const kDefaultMaxDecompressedSize = 4 * 1024 * 1024 | ||
| class PerMessageDeflate { | ||
@@ -18,5 +22,19 @@ /** @type {import('node:zlib').InflateRaw} */ | ||
| constructor (extensions) { | ||
| /** @type {number} */ | ||
| #maxDecompressedSize | ||
| /** @type {boolean} */ | ||
| #aborted = false | ||
| /** @type {Function|null} */ | ||
| #currentCallback = null | ||
| /** | ||
| * @param {Map<string, string>} extensions | ||
| * @param {{ maxDecompressedMessageSize?: 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 | ||
| } | ||
@@ -30,2 +48,7 @@ | ||
| if (this.#aborted) { | ||
| callback(new MessageSizeExceededError()) | ||
| return | ||
| } | ||
| if (!this.#inflate) { | ||
@@ -43,3 +66,8 @@ let windowBits = Z_DEFAULT_WINDOWBITS | ||
| this.#inflate = createInflateRaw({ windowBits }) | ||
| try { | ||
| this.#inflate = createInflateRaw({ windowBits }) | ||
| } catch (err) { | ||
| callback(err) | ||
| return | ||
| } | ||
| this.#inflate[kBuffer] = [] | ||
@@ -49,4 +77,23 @@ this.#inflate[kLength] = 0 | ||
| this.#inflate.on('data', (data) => { | ||
| if (this.#aborted) { | ||
| return | ||
| } | ||
| this.#inflate[kLength] += data.length | ||
| if (this.#inflate[kLength] > this.#maxDecompressedSize) { | ||
| this.#aborted = true | ||
| this.#inflate.removeAllListeners() | ||
| this.#inflate.destroy() | ||
| this.#inflate = null | ||
| if (this.#currentCallback) { | ||
| const cb = this.#currentCallback | ||
| this.#currentCallback = null | ||
| cb(new MessageSizeExceededError()) | ||
| } | ||
| return | ||
| } | ||
| this.#inflate[kBuffer].push(data) | ||
| this.#inflate[kLength] += data.length | ||
| }) | ||
@@ -60,2 +107,3 @@ | ||
| this.#currentCallback = callback | ||
| this.#inflate.write(chunk) | ||
@@ -67,2 +115,6 @@ if (fin) { | ||
| this.#inflate.flush(() => { | ||
| if (this.#aborted || !this.#inflate) { | ||
| return | ||
| } | ||
| const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]) | ||
@@ -72,2 +124,3 @@ | ||
| this.#inflate[kLength] = 0 | ||
| this.#currentCallback = null | ||
@@ -74,0 +127,0 @@ callback(null, full) |
@@ -40,3 +40,11 @@ 'use strict' | ||
| constructor (ws, extensions) { | ||
| /** @type {{ maxDecompressedMessageSize?: number }} */ | ||
| #options | ||
| /** | ||
| * @param {import('./websocket').WebSocket} ws | ||
| * @param {Map<string, string>|null} extensions | ||
| * @param {{ maxDecompressedMessageSize?: number }} [options] | ||
| */ | ||
| constructor (ws, extensions, options = {}) { | ||
| super() | ||
@@ -46,5 +54,6 @@ | ||
| this.#extensions = extensions == null ? new Map() : extensions | ||
| this.#options = options | ||
| if (this.#extensions.has('permessage-deflate')) { | ||
| this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions)) | ||
| this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options)) | ||
| } | ||
@@ -184,2 +193,3 @@ } | ||
| const upper = buffer.readUInt32BE(0) | ||
| const lower = buffer.readUInt32BE(4) | ||
@@ -192,3 +202,3 @@ // 2^31 is the maximum bytes an arraybuffer can contain | ||
| // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e | ||
| if (upper > 2 ** 31 - 1) { | ||
| if (upper !== 0 || lower > 2 ** 31 - 1) { | ||
| failWebsocketConnection(this.ws, 'Received payload length > 2^31 bytes.') | ||
@@ -198,5 +208,3 @@ return | ||
| const lower = buffer.readUInt32BE(4) | ||
| this.#info.payloadLength = (upper << 8) + lower | ||
| this.#info.payloadLength = lower | ||
| this.#state = parserStates.READ_DATA | ||
@@ -231,3 +239,3 @@ } else if (this.#state === parserStates.READ_DATA) { | ||
| if (error) { | ||
| closeWebSocketConnection(this.ws, 1007, error.message, error.message.length) | ||
| failWebsocketConnection(this.ws, error.message) | ||
| return | ||
@@ -234,0 +242,0 @@ } |
@@ -269,2 +269,8 @@ 'use strict' | ||
| function isValidClientWindowBits (value) { | ||
| // Must have at least one character | ||
| if (value.length === 0) { | ||
| return false | ||
| } | ||
| // Check all characters are ASCII digits | ||
| for (let i = 0; i < value.length; i++) { | ||
@@ -278,3 +284,5 @@ const byte = value.charCodeAt(i) | ||
| return true | ||
| // Check numeric range: zlib requires windowBits in range 8-15 | ||
| const num = Number.parseInt(value, 10) | ||
| return num >= 8 && num <= 15 | ||
| } | ||
@@ -281,0 +289,0 @@ |
@@ -47,2 +47,5 @@ 'use strict' | ||
| /** @type {{ maxDecompressedMessageSize?: number }} */ | ||
| #options | ||
| /** | ||
@@ -121,2 +124,7 @@ * @param {string} url | ||
| // Store options for later use (e.g., maxDecompressedMessageSize) | ||
| this.#options = { | ||
| maxDecompressedMessageSize: options.maxDecompressedMessageSize | ||
| } | ||
| // 11. Let client be this's relevant settings object. | ||
@@ -436,7 +444,7 @@ const client = environmentSettingsObject.settingsObject | ||
| #onConnectionEstablished (response, parsedExtensions) { | ||
| // processResponse is called when the "response’s header list has been received and initialized." | ||
| // processResponse is called when the "response's header list has been received and initialized." | ||
| // once this happens, the connection is open | ||
| this[kResponse] = response | ||
| const parser = new ByteParser(this, parsedExtensions) | ||
| const parser = new ByteParser(this, parsedExtensions, this.#options) | ||
| parser.on('drain', onParserDrain) | ||
@@ -544,2 +552,15 @@ parser.on('error', onParserError.bind(this)) | ||
| 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 | ||
| }) | ||
| } | ||
@@ -546,0 +567,0 @@ ]) |
+2
-2
| { | ||
| "name": "undici", | ||
| "version": "6.23.0", | ||
| "version": "6.24.0", | ||
| "description": "An HTTP/1.1 client, written from scratch for Node.js", | ||
@@ -110,2 +110,3 @@ "homepage": "https://undici.nodejs.org", | ||
| "@matteo.collina/tspl": "^0.1.1", | ||
| "@metcoder95/https-pem": "^1.0.0", | ||
| "@sinonjs/fake-timers": "^11.1.0", | ||
@@ -121,3 +122,2 @@ "@types/node": "~18.19.50", | ||
| "formdata-node": "^6.0.3", | ||
| "https-pem": "^3.0.0", | ||
| "husky": "^9.0.7", | ||
@@ -124,0 +124,0 @@ "jest": "^29.0.2", |
@@ -149,2 +149,8 @@ import { IncomingHttpHeaders } from "./header"; | ||
| } | ||
| /** WebSocket decompressed message exceeded maximum size. */ | ||
| export class MessageSizeExceededError extends UndiciError { | ||
| name: 'MessageSizeExceededError' | ||
| code: 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED' | ||
| } | ||
| } |
@@ -149,3 +149,10 @@ /// <reference types="node" /> | ||
| dispatcher?: Dispatcher, | ||
| headers?: HeadersInit | ||
| 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 | ||
| } |
Network access
Supply chain riskThis module accesses the network.
Found 3 instances in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 10 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 3 instances in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 10 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
1187096
0.45%25209
0.45%65
1.56%