undici 7.23.0 → 7.24.1

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.
@@ -1245,6 +1245,7 @@ The `deduplicate` interceptor deduplicates concurrent identical requests. When m
1245
1245
  - `methods` - The [**safe** HTTP methods](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) to deduplicate. Default `['GET']`.
1246
1246
  - `skipHeaderNames` - Header names that, if present in a request, will cause the request to skip deduplication entirely. Useful for headers like `idempotency-key` where presence indicates unique processing. Header name matching is case-insensitive. Default `[]`.
1247
1247
  - `excludeHeaderNames` - Header names to exclude from the deduplication key. Requests with different values for these headers will still be deduplicated together. Useful for headers like `x-request-id` that vary per request but shouldn't affect deduplication. Header name matching is case-insensitive. Default `[]`.
1248
+ - `maxBufferSize` - Maximum bytes buffered per paused waiting deduplicated handler. If a waiting handler remains paused and exceeds this threshold, it is failed with an abort error to prevent unbounded memory growth. Default `5 * 1024 * 1024`.
1248
1249
 
1249
1250
  **Usage**
1250
1251
 
@@ -26,6 +26,7 @@ import { errors } from 'undici'
26
26
  | `InformationalError` | `UND_ERR_INFO` | expected error with reason |
27
27
  | `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed |
28
28
  | `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed |
29
+ | `MessageSizeExceededError` | `UND_ERR_WS_MESSAGE_SIZE_EXCEEDED` | WebSocket decompressed message exceeded the maximum allowed size |
29
30
 
30
31
  Be aware of the possible difference between the global dispatcher version and the actual undici version you might be using. We recommend to avoid the check `instanceof errors.UndiciError` and seek for the `error.code === '<error_code>'` instead to avoid inconsistencies.
31
32
  ### `SocketError`
@@ -11,6 +11,15 @@ Arguments:
11
11
  * **url** `URL | string`
12
12
  * **protocol** `string | string[] | WebSocketInit` (optional) - Subprotocol(s) to request the server use, or a [`Dispatcher`](/docs/docs/api/Dispatcher.md).
13
13
 
14
+ ### WebSocketInit
15
+
16
+ When passing an object as the second argument, the following options are available:
17
+
18
+ * **protocols** `string | string[]` (optional) - Subprotocol(s) to request the server use.
19
+ * **dispatcher** `Dispatcher` (optional) - A custom [`Dispatcher`](/docs/docs/api/Dispatcher.md) to use for the connection.
20
+ * **headers** `HeadersInit` (optional) - Custom headers to include in the WebSocket handshake request.
21
+ * **maxDecompressedMessageSize** `number` (optional) - Maximum allowed size in bytes for decompressed messages when using the `permessage-deflate` extension. **Default:** `4194304` (4 MB).
22
+
14
23
  ### Example:
15
24
 
16
25
  This example will not work in browsers or other platforms that don't allow passing an object.
@@ -34,6 +43,26 @@ import { WebSocket } from 'undici'
34
43
  const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
35
44
  ```
36
45
 
46
+ ### Example with custom decompression limit:
47
+
48
+ To protect against decompression bombs (small compressed payloads that expand to very large sizes), you can set a custom limit:
49
+
50
+ ```mjs
51
+ import { WebSocket } from 'undici'
52
+
53
+ // Limit decompressed messages to 1 MB
54
+ const ws = new WebSocket('wss://echo.websocket.events', {
55
+ maxDecompressedMessageSize: 1 * 1024 * 1024
56
+ })
57
+
58
+ ws.addEventListener('error', (event) => {
59
+ // Connection will be closed if a message exceeds the limit
60
+ console.error('WebSocket error:', event.error)
61
+ })
62
+ ```
63
+
64
+ > ⚠️ **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.
65
+
37
66
  ### Example with HTTP/2:
38
67
 
39
68
  > ⚠️ Warning: WebSocket over HTTP/2 is experimental, it is likely to change in the future.
@@ -430,6 +430,24 @@ class Socks5ProxyError extends UndiciError {
430
430
  }
431
431
  }
432
432
 
433
+ const kMessageSizeExceededError = Symbol.for('undici.error.UND_ERR_WS_MESSAGE_SIZE_EXCEEDED')
434
+ class MessageSizeExceededError extends UndiciError {
435
+ constructor (message) {
436
+ super(message)
437
+ this.name = 'MessageSizeExceededError'
438
+ this.message = message || 'Max decompressed message size exceeded'
439
+ this.code = 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'
440
+ }
441
+
442
+ static [Symbol.hasInstance] (instance) {
443
+ return instance && instance[kMessageSizeExceededError] === true
444
+ }
445
+
446
+ get [kMessageSizeExceededError] () {
447
+ return true
448
+ }
449
+ }
450
+
433
451
  module.exports = {
434
452
  AbortError,
435
453
  HTTPParserError,
@@ -454,5 +472,6 @@ module.exports = {
454
472
  ResponseError,
455
473
  SecureProxyConnectionError,
456
474
  MaxOriginsReachedError,
457
- Socks5ProxyError
475
+ Socks5ProxyError,
476
+ MessageSizeExceededError
458
477
  }
@@ -70,6 +70,10 @@ class Request {
70
70
  throw new InvalidArgumentError('upgrade must be a string')
71
71
  }
72
72
 
73
+ if (upgrade && !isValidHeaderValue(upgrade)) {
74
+ throw new InvalidArgumentError('invalid upgrade header')
75
+ }
76
+
73
77
  if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) {
74
78
  throw new InvalidArgumentError('invalid headersTimeout')
75
79
  }
@@ -385,13 +389,19 @@ function processHeader (request, key, val) {
385
389
  val = `${val}`
386
390
  }
387
391
 
388
- if (request.host === null && headerName === 'host') {
392
+ if (headerName === 'host') {
393
+ if (request.host !== null) {
394
+ throw new InvalidArgumentError('duplicate host header')
395
+ }
389
396
  if (typeof val !== 'string') {
390
397
  throw new InvalidArgumentError('invalid host header')
391
398
  }
392
399
  // Consumed by Client
393
400
  request.host = val
394
- } else if (request.contentLength === null && headerName === 'content-length') {
401
+ } else if (headerName === 'content-length') {
402
+ if (request.contentLength !== null) {
403
+ throw new InvalidArgumentError('duplicate content-length header')
404
+ }
395
405
  request.contentLength = parseInt(val, 10)
396
406
  if (!Number.isFinite(request.contentLength)) {
397
407
  throw new InvalidArgumentError('invalid content-length header')
@@ -14,7 +14,7 @@ const {
14
14
  } = require('./pool-base')
15
15
  const Pool = require('./pool')
16
16
  const { kUrl } = require('../core/symbols')
17
- const { parseOrigin } = require('../core/util')
17
+ const util = require('../core/util')
18
18
  const kFactory = Symbol('factory')
19
19
 
20
20
  const kOptions = Symbol('options')
@@ -56,7 +56,10 @@ class BalancedPool extends PoolBase {
56
56
 
57
57
  super()
58
58
 
59
- this[kOptions] = opts
59
+ this[kOptions] = { ...util.deepClone(opts) }
60
+ this[kOptions].interceptors = opts.interceptors
61
+ ? { ...opts.interceptors }
62
+ : undefined
60
63
  this[kIndex] = -1
61
64
  this[kCurrentWeight] = 0
62
65
 
@@ -76,7 +79,7 @@ class BalancedPool extends PoolBase {
76
79
  }
77
80
 
78
81
  addUpstream (upstream) {
79
- const upstreamOrigin = parseOrigin(upstream).origin
82
+ const upstreamOrigin = util.parseOrigin(upstream).origin
80
83
 
81
84
  if (this[kClients].find((pool) => (
82
85
  pool[kUrl].origin === upstreamOrigin &&
@@ -85,7 +88,7 @@ class BalancedPool extends PoolBase {
85
88
  ))) {
86
89
  return this
87
90
  }
88
- const pool = this[kFactory](upstreamOrigin, Object.assign({}, this[kOptions]))
91
+ const pool = this[kFactory](upstreamOrigin, this[kOptions])
89
92
 
90
93
  this[kAddClient](pool)
91
94
  pool.on('connect', () => {
@@ -125,7 +128,7 @@ class BalancedPool extends PoolBase {
125
128
  }
126
129
 
127
130
  removeUpstream (upstream) {
128
- const upstreamOrigin = parseOrigin(upstream).origin
131
+ const upstreamOrigin = util.parseOrigin(upstream).origin
129
132
 
130
133
  const pool = this[kClients].find((pool) => (
131
134
  pool[kUrl].origin === upstreamOrigin &&
@@ -141,7 +144,7 @@ class BalancedPool extends PoolBase {
141
144
  }
142
145
 
143
146
  getUpstream (upstream) {
144
- const upstreamOrigin = parseOrigin(upstream).origin
147
+ const upstreamOrigin = util.parseOrigin(upstream).origin
145
148
 
146
149
  return this[kClients].find((pool) => (
147
150
  pool[kUrl].origin === upstreamOrigin &&
@@ -1,11 +1,25 @@
1
1
  'use strict'
2
2
 
3
+ const { RequestAbortedError } = require('../core/errors')
4
+
3
5
  /**
4
6
  * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
5
7
  */
6
8
 
9
+ const DEFAULT_MAX_BUFFER_SIZE = 5 * 1024 * 1024
10
+
11
+ /**
12
+ * @typedef {Object} WaitingHandler
13
+ * @property {DispatchHandler} handler
14
+ * @property {import('../../types/dispatcher.d.ts').default.DispatchController} controller
15
+ * @property {Buffer[]} bufferedChunks
16
+ * @property {number} bufferedBytes
17
+ * @property {object | null} pendingTrailers
18
+ * @property {boolean} done
19
+ */
20
+
7
21
  /**
8
- * Handler that buffers response data and notifies multiple waiting handlers.
22
+ * Handler that forwards response events to multiple waiting handlers.
9
23
  * Used for request deduplication.
10
24
  *
11
25
  * @implements {DispatchHandler}
@@ -17,14 +31,14 @@ class DeduplicationHandler {
17
31
  #primaryHandler
18
32
 
19
33
  /**
20
- * @type {DispatchHandler[]}
34
+ * @type {WaitingHandler[]}
21
35
  */
22
36
  #waitingHandlers = []
23
37
 
24
38
  /**
25
- * @type {Buffer[]}
39
+ * @type {number}
26
40
  */
27
- #chunks = []
41
+ #maxBufferSize = DEFAULT_MAX_BUFFER_SIZE
28
42
 
29
43
  /**
30
44
  * @type {number}
@@ -46,6 +60,21 @@ class DeduplicationHandler {
46
60
  */
47
61
  #aborted = false
48
62
 
63
+ /**
64
+ * @type {boolean}
65
+ */
66
+ #responseStarted = false
67
+
68
+ /**
69
+ * @type {boolean}
70
+ */
71
+ #responseDataStarted = false
72
+
73
+ /**
74
+ * @type {boolean}
75
+ */
76
+ #completed = false
77
+
49
78
  /**
50
79
  * @type {import('../../types/dispatcher.d.ts').default.DispatchController | null}
51
80
  */
@@ -59,22 +88,60 @@ class DeduplicationHandler {
59
88
  /**
60
89
  * @param {DispatchHandler} primaryHandler The primary handler
61
90
  * @param {() => void} onComplete Callback when request completes
91
+ * @param {number} [maxBufferSize] Maximum paused buffer size per waiting handler
62
92
  */
63
- constructor (primaryHandler, onComplete) {
93
+ constructor (primaryHandler, onComplete, maxBufferSize = DEFAULT_MAX_BUFFER_SIZE) {
64
94
  this.#primaryHandler = primaryHandler
65
95
  this.#onComplete = onComplete
96
+ this.#maxBufferSize = maxBufferSize
66
97
  }
67
98
 
68
99
  /**
69
- * Add a waiting handler that will receive the buffered response
100
+ * Add a waiting handler that will receive response events.
101
+ * Returns false if deduplication can no longer safely attach this handler.
102
+ *
70
103
  * @param {DispatchHandler} handler
104
+ * @returns {boolean}
71
105
  */
72
106
  addWaitingHandler (handler) {
73
- this.#waitingHandlers.push(handler)
107
+ if (this.#completed || this.#responseDataStarted) {
108
+ return false
109
+ }
110
+
111
+ const waitingHandler = this.#createWaitingHandler(handler)
112
+ const waitingController = waitingHandler.controller
113
+
114
+ try {
115
+ handler.onRequestStart?.(waitingController, null)
116
+
117
+ if (waitingController.aborted) {
118
+ waitingHandler.done = true
119
+ return true
120
+ }
121
+
122
+ if (this.#responseStarted) {
123
+ handler.onResponseStart?.(
124
+ waitingController,
125
+ this.#statusCode,
126
+ this.#headers,
127
+ this.#statusMessage
128
+ )
129
+ }
130
+ } catch {
131
+ // Ignore errors from waiting handlers
132
+ waitingHandler.done = true
133
+ return true
134
+ }
135
+
136
+ if (!waitingController.aborted) {
137
+ this.#waitingHandlers.push(waitingHandler)
138
+ }
139
+
140
+ return true
74
141
  }
75
142
 
76
143
  /**
77
- * @param {() => void} abort
144
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
78
145
  * @param {any} context
79
146
  */
80
147
  onRequestStart (controller, context) {
@@ -99,10 +166,38 @@ class DeduplicationHandler {
99
166
  * @param {string} statusMessage
100
167
  */
101
168
  onResponseStart (controller, statusCode, headers, statusMessage) {
169
+ this.#responseStarted = true
102
170
  this.#statusCode = statusCode
103
171
  this.#headers = headers
104
172
  this.#statusMessage = statusMessage
173
+
105
174
  this.#primaryHandler.onResponseStart?.(controller, statusCode, headers, statusMessage)
175
+
176
+ for (const waitingHandler of this.#waitingHandlers) {
177
+ const { handler, controller: waitingController } = waitingHandler
178
+
179
+ if (waitingHandler.done || waitingController.aborted) {
180
+ waitingHandler.done = true
181
+ continue
182
+ }
183
+
184
+ try {
185
+ handler.onResponseStart?.(
186
+ waitingController,
187
+ statusCode,
188
+ headers,
189
+ statusMessage
190
+ )
191
+ } catch {
192
+ // Ignore errors from waiting handlers
193
+ }
194
+
195
+ if (waitingController.aborted) {
196
+ waitingHandler.done = true
197
+ }
198
+ }
199
+
200
+ this.#pruneDoneWaitingHandlers()
106
201
  }
107
202
 
108
203
  /**
@@ -110,9 +205,41 @@ class DeduplicationHandler {
110
205
  * @param {Buffer} chunk
111
206
  */
112
207
  onResponseData (controller, chunk) {
113
- // Buffer the chunk for waiting handlers
114
- this.#chunks.push(Buffer.from(chunk))
208
+ if (this.#aborted || this.#completed) {
209
+ return
210
+ }
211
+
212
+ this.#responseDataStarted = true
213
+
115
214
  this.#primaryHandler.onResponseData?.(controller, chunk)
215
+
216
+ for (const waitingHandler of this.#waitingHandlers) {
217
+ const { handler, controller: waitingController } = waitingHandler
218
+
219
+ if (waitingHandler.done || waitingController.aborted) {
220
+ waitingHandler.done = true
221
+ continue
222
+ }
223
+
224
+ if (waitingController.paused) {
225
+ this.#bufferWaitingChunk(waitingHandler, chunk)
226
+ continue
227
+ }
228
+
229
+ try {
230
+ handler.onResponseData?.(waitingController, chunk)
231
+ } catch {
232
+ // Ignore errors from waiting handlers
233
+ }
234
+
235
+ if (waitingController.aborted) {
236
+ waitingHandler.done = true
237
+ waitingHandler.bufferedChunks = []
238
+ waitingHandler.bufferedBytes = 0
239
+ }
240
+ }
241
+
242
+ this.#pruneDoneWaitingHandlers()
116
243
  }
117
244
 
118
245
  /**
@@ -120,8 +247,41 @@ class DeduplicationHandler {
120
247
  * @param {object} trailers
121
248
  */
122
249
  onResponseEnd (controller, trailers) {
250
+ if (this.#aborted || this.#completed) {
251
+ return
252
+ }
253
+
254
+ this.#completed = true
123
255
  this.#primaryHandler.onResponseEnd?.(controller, trailers)
124
- this.#notifyWaitingHandlers()
256
+
257
+ for (const waitingHandler of this.#waitingHandlers) {
258
+ if (waitingHandler.done || waitingHandler.controller.aborted) {
259
+ waitingHandler.done = true
260
+ continue
261
+ }
262
+
263
+ this.#flushWaitingHandler(waitingHandler)
264
+
265
+ if (waitingHandler.done || waitingHandler.controller.aborted) {
266
+ waitingHandler.done = true
267
+ continue
268
+ }
269
+
270
+ if (waitingHandler.controller.paused && waitingHandler.bufferedChunks.length > 0) {
271
+ waitingHandler.pendingTrailers = trailers
272
+ continue
273
+ }
274
+
275
+ try {
276
+ waitingHandler.handler.onResponseEnd?.(waitingHandler.controller, trailers)
277
+ } catch {
278
+ // Ignore errors from waiting handlers
279
+ }
280
+
281
+ waitingHandler.done = true
282
+ }
283
+
284
+ this.#pruneDoneWaitingHandlers()
125
285
  this.#onComplete?.()
126
286
  }
127
287
 
@@ -130,86 +290,170 @@ class DeduplicationHandler {
130
290
  * @param {Error} err
131
291
  */
132
292
  onResponseError (controller, err) {
293
+ if (this.#completed) {
294
+ return
295
+ }
296
+
133
297
  this.#aborted = true
298
+ this.#completed = true
299
+
134
300
  this.#primaryHandler.onResponseError?.(controller, err)
135
- this.#notifyWaitingHandlersError(err)
301
+
302
+ for (const waitingHandler of this.#waitingHandlers) {
303
+ this.#errorWaitingHandler(waitingHandler, err)
304
+ }
305
+
306
+ this.#waitingHandlers = []
136
307
  this.#onComplete?.()
137
308
  }
138
309
 
139
310
  /**
140
- * Notify all waiting handlers with the buffered response
311
+ * @param {DispatchHandler} handler
312
+ * @returns {WaitingHandler}
141
313
  */
142
- #notifyWaitingHandlers () {
143
- const body = Buffer.concat(this.#chunks)
144
-
145
- for (const handler of this.#waitingHandlers) {
146
- // Create a simple controller for each waiting handler
147
- const waitingController = {
148
- resume () {},
149
- pause () {},
150
- get paused () { return false },
151
- get aborted () { return false },
152
- get reason () { return null },
153
- abort () {}
154
- }
314
+ #createWaitingHandler (handler) {
315
+ /** @type {WaitingHandler} */
316
+ const waitingHandler = {
317
+ handler,
318
+ controller: null,
319
+ bufferedChunks: [],
320
+ bufferedBytes: 0,
321
+ pendingTrailers: null,
322
+ done: false
323
+ }
155
324
 
156
- try {
157
- handler.onRequestStart?.(waitingController, null)
325
+ const state = {
326
+ aborted: false,
327
+ paused: false,
328
+ reason: null
329
+ }
158
330
 
159
- if (waitingController.aborted) {
160
- continue
331
+ waitingHandler.controller = {
332
+ resume: () => {
333
+ if (state.aborted) {
334
+ return
161
335
  }
162
336
 
163
- handler.onResponseStart?.(
164
- waitingController,
165
- this.#statusCode,
166
- this.#headers,
167
- this.#statusMessage
168
- )
337
+ state.paused = false
338
+ this.#flushWaitingHandler(waitingHandler)
169
339
 
170
- if (waitingController.aborted) {
171
- continue
172
- }
340
+ if (
341
+ this.#completed &&
342
+ waitingHandler.pendingTrailers &&
343
+ waitingHandler.bufferedChunks.length === 0 &&
344
+ !state.paused &&
345
+ !state.aborted
346
+ ) {
347
+ try {
348
+ waitingHandler.handler.onResponseEnd?.(waitingHandler.controller, waitingHandler.pendingTrailers)
349
+ } catch {
350
+ // Ignore errors from waiting handlers
351
+ }
173
352
 
174
- if (body.length > 0) {
175
- handler.onResponseData?.(waitingController, body)
353
+ waitingHandler.pendingTrailers = null
354
+ waitingHandler.done = true
176
355
  }
177
356
 
178
- handler.onResponseEnd?.(waitingController, {})
179
- } catch {
180
- // Ignore errors from waiting handlers
357
+ this.#pruneDoneWaitingHandlers()
358
+ },
359
+ pause: () => {
360
+ if (!state.aborted) {
361
+ state.paused = true
362
+ }
363
+ },
364
+ get paused () { return state.paused },
365
+ get aborted () { return state.aborted },
366
+ get reason () { return state.reason },
367
+ abort: (reason) => {
368
+ state.aborted = true
369
+ state.reason = reason ?? null
370
+ waitingHandler.done = true
371
+ waitingHandler.pendingTrailers = null
372
+ waitingHandler.bufferedChunks = []
373
+ waitingHandler.bufferedBytes = 0
181
374
  }
182
375
  }
183
376
 
184
- this.#waitingHandlers = []
185
- this.#chunks = []
377
+ return waitingHandler
186
378
  }
187
379
 
188
380
  /**
189
- * Notify all waiting handlers of an error
190
- * @param {Error} err
381
+ * @param {WaitingHandler} waitingHandler
382
+ * @param {Buffer} chunk
191
383
  */
192
- #notifyWaitingHandlersError (err) {
193
- for (const handler of this.#waitingHandlers) {
194
- const waitingController = {
195
- resume () {},
196
- pause () {},
197
- get paused () { return false },
198
- get aborted () { return true },
199
- get reason () { return err },
200
- abort () {}
201
- }
384
+ #bufferWaitingChunk (waitingHandler, chunk) {
385
+ if (waitingHandler.done || waitingHandler.controller.aborted) {
386
+ waitingHandler.done = true
387
+ waitingHandler.bufferedChunks = []
388
+ waitingHandler.bufferedBytes = 0
389
+ return
390
+ }
391
+
392
+ const bufferedChunk = Buffer.from(chunk)
393
+ waitingHandler.bufferedChunks.push(bufferedChunk)
394
+ waitingHandler.bufferedBytes += bufferedChunk.length
395
+
396
+ if (waitingHandler.bufferedBytes > this.#maxBufferSize) {
397
+ const err = new RequestAbortedError(`Deduplicated waiting handler exceeded maxBufferSize (${this.#maxBufferSize} bytes) while paused`)
398
+ this.#errorWaitingHandler(waitingHandler, err)
399
+ }
400
+ }
401
+
402
+ /**
403
+ * @param {WaitingHandler} waitingHandler
404
+ */
405
+ #flushWaitingHandler (waitingHandler) {
406
+ const { handler, controller } = waitingHandler
407
+
408
+ while (
409
+ !waitingHandler.done &&
410
+ !controller.aborted &&
411
+ !controller.paused &&
412
+ waitingHandler.bufferedChunks.length > 0
413
+ ) {
414
+ const bufferedChunk = waitingHandler.bufferedChunks.shift()
415
+ waitingHandler.bufferedBytes -= bufferedChunk.length
202
416
 
203
417
  try {
204
- handler.onRequestStart?.(waitingController, null)
205
- handler.onResponseError?.(waitingController, err)
418
+ handler.onResponseData?.(controller, bufferedChunk)
206
419
  } catch {
207
420
  // Ignore errors from waiting handlers
208
421
  }
422
+
423
+ if (controller.aborted) {
424
+ waitingHandler.done = true
425
+ waitingHandler.pendingTrailers = null
426
+ waitingHandler.bufferedChunks = []
427
+ waitingHandler.bufferedBytes = 0
428
+ break
429
+ }
209
430
  }
431
+ }
210
432
 
211
- this.#waitingHandlers = []
212
- this.#chunks = []
433
+ /**
434
+ * @param {WaitingHandler} waitingHandler
435
+ * @param {Error} err
436
+ */
437
+ #errorWaitingHandler (waitingHandler, err) {
438
+ if (waitingHandler.done) {
439
+ return
440
+ }
441
+
442
+ waitingHandler.done = true
443
+ waitingHandler.pendingTrailers = null
444
+ waitingHandler.bufferedChunks = []
445
+ waitingHandler.bufferedBytes = 0
446
+
447
+ try {
448
+ waitingHandler.controller.abort(err)
449
+ waitingHandler.handler.onResponseError?.(waitingHandler.controller, err)
450
+ } catch {
451
+ // Ignore errors from waiting handlers
452
+ }
453
+ }
454
+
455
+ #pruneDoneWaitingHandlers () {
456
+ this.#waitingHandlers = this.#waitingHandlers.filter(waitingHandler => waitingHandler.done === false)
213
457
  }
214
458
  }
215
459
 
@@ -15,7 +15,8 @@ module.exports = (opts = {}) => {
15
15
  const {
16
16
  methods = ['GET'],
17
17
  skipHeaderNames = [],
18
- excludeHeaderNames = []
18
+ excludeHeaderNames = [],
19
+ maxBufferSize = 5 * 1024 * 1024
19
20
  } = opts
20
21
 
21
22
  if (typeof opts !== 'object' || opts === null) {
@@ -40,6 +41,10 @@ module.exports = (opts = {}) => {
40
41
  throw new TypeError(`expected opts.excludeHeaderNames to be an array, got ${typeof excludeHeaderNames}`)
41
42
  }
42
43
 
44
+ if (!Number.isFinite(maxBufferSize) || maxBufferSize <= 0) {
45
+ throw new TypeError(`expected opts.maxBufferSize to be a positive finite number, got ${maxBufferSize}`)
46
+ }
47
+
43
48
  // Convert to lowercase Set for case-insensitive header matching
44
49
  const skipHeaderNamesSet = new Set(skipHeaderNames.map(name => name.toLowerCase()))
45
50
 
@@ -78,9 +83,13 @@ module.exports = (opts = {}) => {
78
83
  // Check if there's already a pending request for this key
79
84
  const pendingHandler = pendingRequests.get(dedupeKey)
80
85
  if (pendingHandler) {
81
- // Add this handler to the waiting list
82
- pendingHandler.addWaitingHandler(handler)
83
- return true
86
+ // Add this handler to the waiting list when safe.
87
+ // If body streaming has already started, this request must be sent independently.
88
+ if (pendingHandler.addWaitingHandler(handler)) {
89
+ return true
90
+ }
91
+
92
+ return dispatch(opts, handler)
84
93
  }
85
94
 
86
95
  // Create a new deduplication handler
@@ -92,7 +101,8 @@ module.exports = (opts = {}) => {
92
101
  if (pendingRequestsChannel.hasSubscribers) {
93
102
  pendingRequestsChannel.publish({ size: pendingRequests.size, key: dedupeKey, type: 'removed' })
94
103
  }
95
- }
104
+ },
105
+ maxBufferSize
96
106
  )
97
107
 
98
108
  // Register the pending request
@@ -2,20 +2,38 @@
2
2
 
3
3
  const { createInflateRaw, Z_DEFAULT_WINDOWBITS } = require('node:zlib')
4
4
  const { isValidClientWindowBits } = require('./util')
5
+ const { MessageSizeExceededError } = require('../../core/errors')
5
6
 
6
7
  const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
7
8
  const kBuffer = Symbol('kBuffer')
8
9
  const kLength = Symbol('kLength')
9
10
 
11
+ // Default maximum decompressed message size: 4 MB
12
+ const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
13
+
10
14
  class PerMessageDeflate {
11
15
  /** @type {import('node:zlib').InflateRaw} */
12
16
  #inflate
13
17
 
14
18
  #options = {}
15
19
 
16
- constructor (extensions) {
20
+ /** @type {number} */
21
+ #maxDecompressedSize
22
+
23
+ /** @type {boolean} */
24
+ #aborted = false
25
+
26
+ /** @type {Function|null} */
27
+ #currentCallback = null
28
+
29
+ /**
30
+ * @param {Map<string, string>} extensions
31
+ * @param {{ maxDecompressedMessageSize?: number }} [options]
32
+ */
33
+ constructor (extensions, options = {}) {
17
34
  this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
18
35
  this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
36
+ this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize
19
37
  }
20
38
 
21
39
  decompress (chunk, fin, callback) {
@@ -24,6 +42,11 @@ class PerMessageDeflate {
24
42
  // payload of the message.
25
43
  // 2. Decompress the resulting data using DEFLATE.
26
44
 
45
+ if (this.#aborted) {
46
+ callback(new MessageSizeExceededError())
47
+ return
48
+ }
49
+
27
50
  if (!this.#inflate) {
28
51
  let windowBits = Z_DEFAULT_WINDOWBITS
29
52
 
@@ -36,13 +59,37 @@ class PerMessageDeflate {
36
59
  windowBits = Number.parseInt(this.#options.serverMaxWindowBits)
37
60
  }
38
61
 
39
- this.#inflate = createInflateRaw({ windowBits })
62
+ try {
63
+ this.#inflate = createInflateRaw({ windowBits })
64
+ } catch (err) {
65
+ callback(err)
66
+ return
67
+ }
40
68
  this.#inflate[kBuffer] = []
41
69
  this.#inflate[kLength] = 0
42
70
 
43
71
  this.#inflate.on('data', (data) => {
44
- this.#inflate[kBuffer].push(data)
72
+ if (this.#aborted) {
73
+ return
74
+ }
75
+
45
76
  this.#inflate[kLength] += data.length
77
+
78
+ if (this.#inflate[kLength] > this.#maxDecompressedSize) {
79
+ this.#aborted = true
80
+ this.#inflate.removeAllListeners()
81
+ this.#inflate.destroy()
82
+ this.#inflate = null
83
+
84
+ if (this.#currentCallback) {
85
+ const cb = this.#currentCallback
86
+ this.#currentCallback = null
87
+ cb(new MessageSizeExceededError())
88
+ }
89
+ return
90
+ }
91
+
92
+ this.#inflate[kBuffer].push(data)
46
93
  })
47
94
 
48
95
  this.#inflate.on('error', (err) => {
@@ -51,16 +98,22 @@ class PerMessageDeflate {
51
98
  })
52
99
  }
53
100
 
101
+ this.#currentCallback = callback
54
102
  this.#inflate.write(chunk)
55
103
  if (fin) {
56
104
  this.#inflate.write(tail)
57
105
  }
58
106
 
59
107
  this.#inflate.flush(() => {
108
+ if (this.#aborted || !this.#inflate) {
109
+ return
110
+ }
111
+
60
112
  const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength])
61
113
 
62
114
  this.#inflate[kBuffer].length = 0
63
115
  this.#inflate[kLength] = 0
116
+ this.#currentCallback = null
64
117
 
65
118
  callback(null, full)
66
119
  })
@@ -15,6 +15,7 @@ const {
15
15
  const { failWebsocketConnection } = require('./connection')
16
16
  const { WebsocketFrameSend } = require('./frame')
17
17
  const { PerMessageDeflate } = require('./permessage-deflate')
18
+ const { MessageSizeExceededError } = require('../../core/errors')
18
19
 
19
20
  // This code was influenced by ws released under the MIT license.
20
21
  // Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@@ -38,14 +39,23 @@ class ByteParser extends Writable {
38
39
  /** @type {import('./websocket').Handler} */
39
40
  #handler
40
41
 
41
- constructor (handler, extensions) {
42
+ /** @type {{ maxDecompressedMessageSize?: number }} */
43
+ #options
44
+
45
+ /**
46
+ * @param {import('./websocket').Handler} handler
47
+ * @param {Map<string, string>|null} extensions
48
+ * @param {{ maxDecompressedMessageSize?: number }} [options]
49
+ */
50
+ constructor (handler, extensions, options = {}) {
42
51
  super()
43
52
 
44
53
  this.#handler = handler
45
54
  this.#extensions = extensions == null ? new Map() : extensions
55
+ this.#options = options
46
56
 
47
57
  if (this.#extensions.has('permessage-deflate')) {
48
- this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
58
+ this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
49
59
  }
50
60
  }
51
61
 
@@ -180,6 +190,7 @@ class ByteParser extends Writable {
180
190
 
181
191
  const buffer = this.consume(8)
182
192
  const upper = buffer.readUInt32BE(0)
193
+ const lower = buffer.readUInt32BE(4)
183
194
 
184
195
  // 2^31 is the maximum bytes an arraybuffer can contain
185
196
  // on 32-bit systems. Although, on 64-bit systems, this is
@@ -187,14 +198,12 @@ class ByteParser extends Writable {
187
198
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length
188
199
  // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;drc=1946212ac0100668f14eb9e2843bdd846e510a1e;bpv=1;bpt=1;l=1275
189
200
  // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e
190
- if (upper > 2 ** 31 - 1) {
201
+ if (upper !== 0 || lower > 2 ** 31 - 1) {
191
202
  failWebsocketConnection(this.#handler, 1009, 'Received payload length > 2^31 bytes.')
192
203
  return
193
204
  }
194
205
 
195
- const lower = buffer.readUInt32BE(4)
196
-
197
- this.#info.payloadLength = (upper << 8) + lower
206
+ this.#info.payloadLength = lower
198
207
  this.#state = parserStates.READ_DATA
199
208
  } else if (this.#state === parserStates.READ_DATA) {
200
209
  if (this.#byteOffset < this.#info.payloadLength) {
@@ -222,7 +231,9 @@ class ByteParser extends Writable {
222
231
  } else {
223
232
  this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
224
233
  if (error) {
225
- failWebsocketConnection(this.#handler, 1007, error.message)
234
+ // Use 1009 (Message Too Big) for decompression size limit errors
235
+ const code = error instanceof MessageSizeExceededError ? 1009 : 1007
236
+ failWebsocketConnection(this.#handler, code, error.message)
226
237
  return
227
238
  }
228
239
 
@@ -227,6 +227,12 @@ function parseExtensions (extensions) {
227
227
  * @returns {boolean}
228
228
  */
229
229
  function isValidClientWindowBits (value) {
230
+ // Must have at least one character
231
+ if (value.length === 0) {
232
+ return false
233
+ }
234
+
235
+ // Check all characters are ASCII digits
230
236
  for (let i = 0; i < value.length; i++) {
231
237
  const byte = value.charCodeAt(i)
232
238
 
@@ -235,7 +241,9 @@ function isValidClientWindowBits (value) {
235
241
  }
236
242
  }
237
243
 
238
- return true
244
+ // Check numeric range: zlib requires windowBits in range 8-15
245
+ const num = Number.parseInt(value, 10)
246
+ return num >= 8 && num <= 15
239
247
  }
240
248
 
241
249
  /**
@@ -109,6 +109,8 @@ class WebSocket extends EventTarget {
109
109
  #binaryType
110
110
  /** @type {import('./receiver').ByteParser} */
111
111
  #parser
112
+ /** @type {{ maxDecompressedMessageSize?: number }} */
113
+ #options
112
114
 
113
115
  /**
114
116
  * @param {string} url
@@ -154,6 +156,11 @@ class WebSocket extends EventTarget {
154
156
  // 5. Set this's url to urlRecord.
155
157
  this.#url = new URL(urlRecord.href)
156
158
 
159
+ // Store options for later use (e.g., maxDecompressedMessageSize)
160
+ this.#options = {
161
+ maxDecompressedMessageSize: options.maxDecompressedMessageSize
162
+ }
163
+
157
164
  // 6. Let client be this's relevant settings object.
158
165
  const client = environmentSettingsObject.settingsObject
159
166
 
@@ -452,11 +459,11 @@ class WebSocket extends EventTarget {
452
459
  * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol
453
460
  */
454
461
  #onConnectionEstablished (response, parsedExtensions) {
455
- // processResponse is called when the "responses header list has been received and initialized."
462
+ // processResponse is called when the "response's header list has been received and initialized."
456
463
  // once this happens, the connection is open
457
464
  this.#handler.socket = response.socket
458
465
 
459
- const parser = new ByteParser(this.#handler, parsedExtensions)
466
+ const parser = new ByteParser(this.#handler, parsedExtensions, this.#options)
460
467
  parser.on('drain', () => this.#handler.onParserDrain())
461
468
  parser.on('error', (err) => this.#handler.onParserError(err))
462
469
 
@@ -708,6 +715,19 @@ webidl.converters.WebSocketInit = webidl.dictionaryConverter([
708
715
  {
709
716
  key: 'headers',
710
717
  converter: webidl.nullableConverter(webidl.converters.HeadersInit)
718
+ },
719
+ {
720
+ key: 'maxDecompressedMessageSize',
721
+ converter: webidl.nullableConverter((V) => {
722
+ V = webidl.converters['unsigned long long'](V)
723
+ if (V <= 0) {
724
+ throw webidl.errors.exception({
725
+ header: 'WebSocket constructor',
726
+ message: 'maxDecompressedMessageSize must be greater than 0'
727
+ })
728
+ }
729
+ return V
730
+ })
711
731
  }
712
732
  ])
713
733
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.23.0",
3
+ "version": "7.24.1",
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/errors.d.ts CHANGED
@@ -168,4 +168,10 @@ declare namespace Errors {
168
168
  name: 'Socks5ProxyError'
169
169
  code: string
170
170
  }
171
+
172
+ /** WebSocket decompressed message exceeded maximum size. */
173
+ export class MessageSizeExceededError extends UndiciError {
174
+ name: 'MessageSizeExceededError'
175
+ code: 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'
176
+ }
171
177
  }
@@ -60,6 +60,13 @@ declare namespace Interceptors {
60
60
  * @default []
61
61
  */
62
62
  excludeHeaderNames?: string[]
63
+ /**
64
+ * Maximum bytes buffered per paused waiting deduplicated handler.
65
+ * If a waiting handler remains paused and exceeds this threshold,
66
+ * it is failed with an abort error to prevent unbounded memory growth.
67
+ * @default 5 * 1024 * 1024
68
+ */
69
+ maxBufferSize?: number
63
70
  }
64
71
 
65
72
  export function dump (opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
@@ -147,7 +147,14 @@ export declare const ErrorEvent: {
147
147
  interface WebSocketInit {
148
148
  protocols?: string | string[],
149
149
  dispatcher?: Dispatcher,
150
- headers?: HeadersInit
150
+ headers?: HeadersInit,
151
+ /**
152
+ * Maximum size in bytes for decompressed WebSocket messages.
153
+ * When a message exceeds this limit during decompression, the connection
154
+ * will be closed with status code 1009 (Message Too Big).
155
+ * @default 4194304 (4 MB)
156
+ */
157
+ maxDecompressedMessageSize?: number
151
158
  }
152
159
 
153
160
  interface WebSocketStreamOptions {