undici 6.24.0 → 6.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,8 @@ Returns: `Client`
26
26
  * **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.
27
27
  * **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.
28
28
  * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
29
+ * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
30
+ * **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.
29
31
  * **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.
30
32
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
31
33
  * **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.
@@ -20,7 +20,6 @@ When passing an object as the second argument, the following options are availab
20
20
  * **protocols** `string | string[]` (optional) - Subprotocol(s) to request the server use.
21
21
  * **dispatcher** `Dispatcher` (optional) - A custom [`Dispatcher`](/docs/docs/api/Dispatcher.md) to use for the connection.
22
22
  * **headers** `HeadersInit` (optional) - Custom headers to include in the WebSocket handshake request.
23
- * **maxDecompressedMessageSize** `number` (optional) - Maximum allowed size in bytes for decompressed messages when using the `permessage-deflate` extension. **Default:** `4194304` (4 MB).
24
23
 
25
24
  ### Example:
26
25
 
@@ -45,20 +44,6 @@ import { WebSocket } from 'undici'
45
44
  const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
46
45
  ```
47
46
 
48
- ### Example with custom decompression limit:
49
-
50
- To protect against decompression bombs (small compressed payloads that expand to very large sizes), you can set a custom limit:
51
-
52
- ```mjs
53
- import { WebSocket } from 'undici'
54
-
55
- const ws = new WebSocket('wss://echo.websocket.events', {
56
- maxDecompressedMessageSize: 1 * 1024 * 1024
57
- })
58
- ```
59
-
60
- > ⚠️ **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.
61
-
62
47
  ## Read More
63
48
 
64
49
  - [MDN - WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
@@ -24,7 +24,6 @@ function defaultFactory (origin, opts) {
24
24
 
25
25
  class Agent extends DispatcherBase {
26
26
  constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
27
- super()
28
27
 
29
28
  if (typeof factory !== 'function') {
30
29
  throw new InvalidArgumentError('factory must be a function.')
@@ -38,6 +37,8 @@ class Agent extends DispatcherBase {
38
37
  throw new InvalidArgumentError('maxRedirections must be a positive number')
39
38
  }
40
39
 
40
+ super(options)
41
+
41
42
  if (connect && typeof connect !== 'function') {
42
43
  connect = { ...connect }
43
44
  }
@@ -106,9 +106,10 @@ class Client extends DispatcherBase {
106
106
  autoSelectFamilyAttemptTimeout,
107
107
  // h2
108
108
  maxConcurrentStreams,
109
- allowH2
109
+ allowH2,
110
+ webSocket
110
111
  } = {}) {
111
- super()
112
+ super({ webSocket })
112
113
 
113
114
  if (keepAlive !== undefined) {
114
115
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -11,15 +11,23 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch, kInterceptors } = requ
11
11
  const kOnDestroyed = Symbol('onDestroyed')
12
12
  const kOnClosed = Symbol('onClosed')
13
13
  const kInterceptedDispatch = Symbol('Intercepted Dispatch')
14
+ const kWebSocketOptions = Symbol('webSocketOptions')
14
15
 
15
16
  class DispatcherBase extends Dispatcher {
16
- constructor () {
17
+ constructor (opts) {
17
18
  super()
18
19
 
19
20
  this[kDestroyed] = false
20
21
  this[kOnDestroyed] = null
21
22
  this[kClosed] = false
22
23
  this[kOnClosed] = []
24
+ this[kWebSocketOptions] = opts?.webSocket ?? {}
25
+ }
26
+
27
+ get webSocketOptions () {
28
+ return {
29
+ maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024
30
+ }
23
31
  }
24
32
 
25
33
  get destroyed () {
@@ -19,8 +19,8 @@ const kRemoveClient = Symbol('remove client')
19
19
  const kStats = Symbol('stats')
20
20
 
21
21
  class PoolBase extends DispatcherBase {
22
- constructor () {
23
- super()
22
+ constructor (opts) {
23
+ super(opts)
24
24
 
25
25
  this[kQueue] = new FixedQueue()
26
26
  this[kClients] = []
@@ -37,8 +37,6 @@ class Pool extends PoolBase {
37
37
  allowH2,
38
38
  ...options
39
39
  } = {}) {
40
- super()
41
-
42
40
  if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
43
41
  throw new InvalidArgumentError('invalid connections')
44
42
  }
@@ -63,6 +61,8 @@ class Pool extends PoolBase {
63
61
  })
64
62
  }
65
63
 
64
+ super(options)
65
+
66
66
  this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool)
67
67
  ? options.interceptors.Pool
68
68
  : []
@@ -8,45 +8,35 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
8
8
  const kBuffer = Symbol('kBuffer')
9
9
  const kLength = Symbol('kLength')
10
10
 
11
- // Default maximum decompressed message size: 4 MB
12
- const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
13
-
14
11
  class PerMessageDeflate {
15
12
  /** @type {import('node:zlib').InflateRaw} */
16
13
  #inflate
17
14
 
18
15
  #options = {}
19
16
 
20
- /** @type {number} */
21
- #maxDecompressedSize
22
-
23
- /** @type {boolean} */
24
- #aborted = false
25
-
26
- /** @type {Function|null} */
27
- #currentCallback = null
17
+ #maxPayloadSize = 0
28
18
 
29
19
  /**
30
20
  * @param {Map<string, string>} extensions
31
- * @param {{ maxDecompressedMessageSize?: number }} [options]
32
21
  */
33
- constructor (extensions, options = {}) {
22
+ constructor (extensions, options) {
34
23
  this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
35
24
  this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
36
- this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize
25
+
26
+ this.#maxPayloadSize = options.maxPayloadSize
37
27
  }
38
28
 
29
+ /**
30
+ * Decompress a compressed payload.
31
+ * @param {Buffer} chunk Compressed data
32
+ * @param {boolean} fin Final fragment flag
33
+ * @param {Function} callback Callback function
34
+ */
39
35
  decompress (chunk, fin, callback) {
40
36
  // An endpoint uses the following algorithm to decompress a message.
41
37
  // 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
42
38
  // payload of the message.
43
39
  // 2. Decompress the resulting data using DEFLATE.
44
-
45
- if (this.#aborted) {
46
- callback(new MessageSizeExceededError())
47
- return
48
- }
49
-
50
40
  if (!this.#inflate) {
51
41
  let windowBits = Z_DEFAULT_WINDOWBITS
52
42
 
@@ -69,23 +59,12 @@ class PerMessageDeflate {
69
59
  this.#inflate[kLength] = 0
70
60
 
71
61
  this.#inflate.on('data', (data) => {
72
- if (this.#aborted) {
73
- return
74
- }
75
-
76
62
  this.#inflate[kLength] += data.length
77
63
 
78
- if (this.#inflate[kLength] > this.#maxDecompressedSize) {
79
- this.#aborted = true
64
+ if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
65
+ callback(new MessageSizeExceededError())
80
66
  this.#inflate.removeAllListeners()
81
- this.#inflate.destroy()
82
67
  this.#inflate = null
83
-
84
- if (this.#currentCallback) {
85
- const cb = this.#currentCallback
86
- this.#currentCallback = null
87
- cb(new MessageSizeExceededError())
88
- }
89
68
  return
90
69
  }
91
70
 
@@ -98,14 +77,13 @@ class PerMessageDeflate {
98
77
  })
99
78
  }
100
79
 
101
- this.#currentCallback = callback
102
80
  this.#inflate.write(chunk)
103
81
  if (fin) {
104
82
  this.#inflate.write(tail)
105
83
  }
106
84
 
107
85
  this.#inflate.flush(() => {
108
- if (this.#aborted || !this.#inflate) {
86
+ if (!this.#inflate) {
109
87
  return
110
88
  }
111
89
 
@@ -113,7 +91,6 @@ class PerMessageDeflate {
113
91
 
114
92
  this.#inflate[kBuffer].length = 0
115
93
  this.#inflate[kLength] = 0
116
- this.#currentCallback = null
117
94
 
118
95
  callback(null, full)
119
96
  })
@@ -18,6 +18,7 @@ const {
18
18
  const { WebsocketFrameSend } = require('./frame')
19
19
  const { closeWebSocketConnection } = require('./connection')
20
20
  const { PerMessageDeflate } = require('./permessage-deflate')
21
+ const { MessageSizeExceededError } = require('../../core/errors')
21
22
 
22
23
  // This code was influenced by ws released under the MIT license.
23
24
  // Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@@ -26,6 +27,7 @@ const { PerMessageDeflate } = require('./permessage-deflate')
26
27
 
27
28
  class ByteParser extends Writable {
28
29
  #buffers = []
30
+ #fragmentsBytes = 0
29
31
  #byteOffset = 0
30
32
  #loop = false
31
33
 
@@ -37,20 +39,20 @@ class ByteParser extends Writable {
37
39
  /** @type {Map<string, PerMessageDeflate>} */
38
40
  #extensions
39
41
 
40
- /** @type {{ maxDecompressedMessageSize?: number }} */
41
- #options
42
+ /** @type {number} */
43
+ #maxPayloadSize
42
44
 
43
45
  /**
44
46
  * @param {import('./websocket').WebSocket} ws
45
47
  * @param {Map<string, string>|null} extensions
46
- * @param {{ maxDecompressedMessageSize?: number }} [options]
48
+ * @param {{ maxPayloadSize?: number }} [options]
47
49
  */
48
50
  constructor (ws, extensions, options = {}) {
49
51
  super()
50
52
 
51
53
  this.ws = ws
52
54
  this.#extensions = extensions == null ? new Map() : extensions
53
- this.#options = options
55
+ this.#maxPayloadSize = options.maxPayloadSize ?? 0
54
56
 
55
57
  if (this.#extensions.has('permessage-deflate')) {
56
58
  this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
@@ -69,6 +71,19 @@ class ByteParser extends Writable {
69
71
  this.run(callback)
70
72
  }
71
73
 
74
+ #validatePayloadLength () {
75
+ if (
76
+ this.#maxPayloadSize > 0 &&
77
+ !isControlFrame(this.#info.opcode) &&
78
+ this.#info.payloadLength > this.#maxPayloadSize
79
+ ) {
80
+ failWebsocketConnection(this.ws, 'Payload size exceeds maximum allowed size')
81
+ return false
82
+ }
83
+
84
+ return true
85
+ }
86
+
72
87
  /**
73
88
  * Runs whenever a new chunk is received.
74
89
  * Callback is called whenever there are no more chunks buffering,
@@ -157,6 +172,10 @@ class ByteParser extends Writable {
157
172
  if (payloadLength <= 125) {
158
173
  this.#info.payloadLength = payloadLength
159
174
  this.#state = parserStates.READ_DATA
175
+
176
+ if (!this.#validatePayloadLength()) {
177
+ return
178
+ }
160
179
  } else if (payloadLength === 126) {
161
180
  this.#state = parserStates.PAYLOADLENGTH_16
162
181
  } else if (payloadLength === 127) {
@@ -181,6 +200,10 @@ class ByteParser extends Writable {
181
200
 
182
201
  this.#info.payloadLength = buffer.readUInt16BE(0)
183
202
  this.#state = parserStates.READ_DATA
203
+
204
+ if (!this.#validatePayloadLength()) {
205
+ return
206
+ }
184
207
  } else if (this.#state === parserStates.PAYLOADLENGTH_64) {
185
208
  if (this.#byteOffset < 8) {
186
209
  return callback()
@@ -203,6 +226,10 @@ class ByteParser extends Writable {
203
226
 
204
227
  this.#info.payloadLength = lower
205
228
  this.#state = parserStates.READ_DATA
229
+
230
+ if (!this.#validatePayloadLength()) {
231
+ return
232
+ }
206
233
  } else if (this.#state === parserStates.READ_DATA) {
207
234
  if (this.#byteOffset < this.#info.payloadLength) {
208
235
  return callback()
@@ -215,42 +242,53 @@ class ByteParser extends Writable {
215
242
  this.#state = parserStates.INFO
216
243
  } else {
217
244
  if (!this.#info.compressed) {
218
- this.#fragments.push(body)
245
+ this.writeFragments(body)
246
+
247
+ if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
248
+ failWebsocketConnection(this.ws, new MessageSizeExceededError().message)
249
+ return
250
+ }
219
251
 
220
252
  // If the frame is not fragmented, a message has been received.
221
253
  // If the frame is fragmented, it will terminate with a fin bit set
222
254
  // and an opcode of 0 (continuation), therefore we handle that when
223
255
  // parsing continuation frames, not here.
224
256
  if (!this.#info.fragmented && this.#info.fin) {
225
- const fullMessage = Buffer.concat(this.#fragments)
226
- websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage)
227
- this.#fragments.length = 0
257
+ websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
228
258
  }
229
259
 
230
260
  this.#state = parserStates.INFO
231
261
  } else {
232
- this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
233
- if (error) {
234
- failWebsocketConnection(this.ws, error.message)
235
- return
236
- }
262
+ this.#extensions.get('permessage-deflate').decompress(
263
+ body,
264
+ this.#info.fin,
265
+ (error, data) => {
266
+ if (error) {
267
+ failWebsocketConnection(this.ws, error.message)
268
+ return
269
+ }
270
+
271
+ this.writeFragments(data)
272
+
273
+ if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
274
+ failWebsocketConnection(this.ws, new MessageSizeExceededError().message)
275
+ return
276
+ }
277
+
278
+ if (!this.#info.fin) {
279
+ this.#state = parserStates.INFO
280
+ this.#loop = true
281
+ this.run(callback)
282
+ return
283
+ }
284
+
285
+ websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
237
286
 
238
- this.#fragments.push(data)
239
-
240
- if (!this.#info.fin) {
241
- this.#state = parserStates.INFO
242
287
  this.#loop = true
288
+ this.#state = parserStates.INFO
243
289
  this.run(callback)
244
- return
245
290
  }
246
-
247
- websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments))
248
-
249
- this.#loop = true
250
- this.#state = parserStates.INFO
251
- this.#fragments.length = 0
252
- this.run(callback)
253
- })
291
+ )
254
292
 
255
293
  this.#loop = false
256
294
  break
@@ -302,6 +340,26 @@ class ByteParser extends Writable {
302
340
  return buffer
303
341
  }
304
342
 
343
+ writeFragments (fragment) {
344
+ this.#fragmentsBytes += fragment.length
345
+ this.#fragments.push(fragment)
346
+ }
347
+
348
+ consumeFragments () {
349
+ const fragments = this.#fragments
350
+
351
+ if (fragments.length === 1) {
352
+ this.#fragmentsBytes = 0
353
+ return fragments.shift()
354
+ }
355
+
356
+ const output = Buffer.concat(fragments, this.#fragmentsBytes)
357
+ this.#fragments = []
358
+ this.#fragmentsBytes = 0
359
+
360
+ return output
361
+ }
362
+
305
363
  parseCloseBody (data) {
306
364
  assert(data.length !== 1)
307
365
 
@@ -44,9 +44,6 @@ class WebSocket extends EventTarget {
44
44
  /** @type {SendQueue} */
45
45
  #sendQueue
46
46
 
47
- /** @type {{ maxDecompressedMessageSize?: number }} */
48
- #options
49
-
50
47
  /**
51
48
  * @param {string} url
52
49
  * @param {string|string[]} protocols
@@ -120,11 +117,6 @@ class WebSocket extends EventTarget {
120
117
  // 10. Set this's url to urlRecord.
121
118
  this[kWebSocketURL] = new URL(urlRecord.href)
122
119
 
123
- // Store options for later use (e.g., maxDecompressedMessageSize)
124
- this.#options = {
125
- maxDecompressedMessageSize: options.maxDecompressedMessageSize
126
- }
127
-
128
120
  // 11. Let client be this's relevant settings object.
129
121
  const client = environmentSettingsObject.settingsObject
130
122
 
@@ -443,7 +435,11 @@ class WebSocket extends EventTarget {
443
435
  // once this happens, the connection is open
444
436
  this[kResponse] = response
445
437
 
446
- const parser = new ByteParser(this, parsedExtensions, this.#options)
438
+ const maxPayloadSize = this[kController]?.dispatcher?.webSocketOptions?.maxPayloadSize
439
+
440
+ const parser = new ByteParser(this, parsedExtensions, {
441
+ maxPayloadSize
442
+ })
447
443
  parser.on('drain', onParserDrain)
448
444
  parser.on('error', onParserError.bind(this))
449
445
 
@@ -546,19 +542,6 @@ webidl.converters.WebSocketInit = webidl.dictionaryConverter([
546
542
  {
547
543
  key: 'headers',
548
544
  converter: webidl.nullableConverter(webidl.converters.HeadersInit)
549
- },
550
- {
551
- key: 'maxDecompressedMessageSize',
552
- converter: webidl.nullableConverter((V) => {
553
- V = webidl.converters['unsigned long long'](V)
554
- if (V <= 0) {
555
- throw webidl.errors.exception({
556
- header: 'WebSocket constructor',
557
- message: 'maxDecompressedMessageSize must be greater than 0'
558
- })
559
- }
560
- return V
561
- })
562
545
  }
563
546
  ])
564
547
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.24.0",
3
+ "version": "6.25.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
package/types/client.d.ts CHANGED
@@ -78,6 +78,8 @@ export declare namespace Client {
78
78
  localAddress?: string;
79
79
  /** Max response body size in bytes, -1 is disabled */
80
80
  maxResponseSize?: number;
81
+ /** WebSocket-specific options */
82
+ webSocket?: Client.WebSocketOptions;
81
83
  /** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */
82
84
  autoSelectFamily?: boolean;
83
85
  /** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */
@@ -103,6 +105,15 @@ export declare namespace Client {
103
105
  bytesWritten?: number
104
106
  bytesRead?: number
105
107
  }
108
+ export interface WebSocketOptions {
109
+ /**
110
+ * Maximum allowed payload size in bytes for WebSocket messages.
111
+ * Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages.
112
+ * Set to 0 to disable the limit.
113
+ * @default 134217728 (128 MB)
114
+ */
115
+ maxPayloadSize?: number;
116
+ }
106
117
  }
107
118
 
108
119
  export default Client;
@@ -146,12 +146,5 @@ export declare const ErrorEvent: {
146
146
  interface WebSocketInit {
147
147
  protocols?: string | string[],
148
148
  dispatcher?: Dispatcher,
149
- headers?: HeadersInit,
150
- /**
151
- * Maximum size in bytes for decompressed WebSocket messages.
152
- * When a message exceeds this limit during decompression, the connection
153
- * will be closed with status code 1009 (Message Too Big).
154
- * @default 4194304 (4 MB)
155
- */
156
- maxDecompressedMessageSize?: number
149
+ headers?: HeadersInit
157
150
  }