undici 8.0.3 → 8.1.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.
@@ -24,6 +24,8 @@ Returns: `Client`
24
24
  * **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.
25
25
  * **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.
26
26
  * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
27
+ * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
28
+ * **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.
27
29
  * **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.
28
30
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
29
31
  * **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.
@@ -35,7 +35,7 @@ class Agent extends DispatcherBase {
35
35
  throw new InvalidArgumentError('maxOrigins must be a number greater than 0')
36
36
  }
37
37
 
38
- super()
38
+ super(options)
39
39
 
40
40
  if (connect && typeof connect !== 'function') {
41
41
  connect = { ...connect }
@@ -114,7 +114,8 @@ class Client extends DispatcherBase {
114
114
  useH2c,
115
115
  initialWindowSize,
116
116
  connectionWindowSize,
117
- pingInterval
117
+ pingInterval,
118
+ webSocket
118
119
  } = {}) {
119
120
  if (keepAlive !== undefined) {
120
121
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -222,7 +223,7 @@ class Client extends DispatcherBase {
222
223
  throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
223
224
  }
224
225
 
225
- super()
226
+ super({ webSocket })
226
227
 
227
228
  if (typeof connect !== 'function') {
228
229
  connect = buildConnector({
@@ -10,6 +10,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy
10
10
 
11
11
  const kOnDestroyed = Symbol('onDestroyed')
12
12
  const kOnClosed = Symbol('onClosed')
13
+ const kWebSocketOptions = Symbol('webSocketOptions')
13
14
 
14
15
  class DispatcherBase extends Dispatcher {
15
16
  /** @type {boolean} */
@@ -24,6 +25,23 @@ class DispatcherBase extends Dispatcher {
24
25
  /** @type {Array<Function>|null} */
25
26
  [kOnClosed] = null
26
27
 
28
+ /**
29
+ * @param {import('../../types/dispatcher').DispatcherOptions} [opts]
30
+ */
31
+ constructor (opts) {
32
+ super()
33
+ this[kWebSocketOptions] = opts?.webSocket ?? {}
34
+ }
35
+
36
+ /**
37
+ * @returns {import('../../types/dispatcher').WebSocketOptions}
38
+ */
39
+ get webSocketOptions () {
40
+ return {
41
+ maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 // 128 MB default
42
+ }
43
+ }
44
+
27
45
  /** @returns {boolean} */
28
46
  get destroyed () {
29
47
  return this[kDestroyed]
@@ -63,7 +63,7 @@ class Pool extends PoolBase {
63
63
  })
64
64
  }
65
65
 
66
- super()
66
+ super(options)
67
67
 
68
68
  this[kConnections] = connections || null
69
69
  this[kUrl] = util.parseOrigin(origin)
@@ -8,40 +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 {boolean} */
21
- #aborted = false
22
-
23
- /** @type {Function|null} */
24
- #currentCallback = null
17
+ #maxPayloadSize = 0
25
18
 
26
19
  /**
27
20
  * @param {Map<string, string>} extensions
28
21
  */
29
- constructor (extensions) {
22
+ constructor (extensions, options) {
30
23
  this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
31
24
  this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
25
+
26
+ this.#maxPayloadSize = options.maxPayloadSize
32
27
  }
33
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
+ */
34
35
  decompress (chunk, fin, callback) {
35
36
  // An endpoint uses the following algorithm to decompress a message.
36
37
  // 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
37
38
  // payload of the message.
38
39
  // 2. Decompress the resulting data using DEFLATE.
39
-
40
- if (this.#aborted) {
41
- callback(new MessageSizeExceededError())
42
- return
43
- }
44
-
45
40
  if (!this.#inflate) {
46
41
  let windowBits = Z_DEFAULT_WINDOWBITS
47
42
 
@@ -64,23 +59,12 @@ class PerMessageDeflate {
64
59
  this.#inflate[kLength] = 0
65
60
 
66
61
  this.#inflate.on('data', (data) => {
67
- if (this.#aborted) {
68
- return
69
- }
70
-
71
62
  this.#inflate[kLength] += data.length
72
63
 
73
- if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) {
74
- this.#aborted = true
64
+ if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
65
+ callback(new MessageSizeExceededError())
75
66
  this.#inflate.removeAllListeners()
76
- this.#inflate.destroy()
77
67
  this.#inflate = null
78
-
79
- if (this.#currentCallback) {
80
- const cb = this.#currentCallback
81
- this.#currentCallback = null
82
- cb(new MessageSizeExceededError())
83
- }
84
68
  return
85
69
  }
86
70
 
@@ -93,14 +77,13 @@ class PerMessageDeflate {
93
77
  })
94
78
  }
95
79
 
96
- this.#currentCallback = callback
97
80
  this.#inflate.write(chunk)
98
81
  if (fin) {
99
82
  this.#inflate.write(tail)
100
83
  }
101
84
 
102
85
  this.#inflate.flush(() => {
103
- if (this.#aborted || !this.#inflate) {
86
+ if (!this.#inflate) {
104
87
  return
105
88
  }
106
89
 
@@ -108,7 +91,6 @@ class PerMessageDeflate {
108
91
 
109
92
  this.#inflate[kBuffer].length = 0
110
93
  this.#inflate[kLength] = 0
111
- this.#currentCallback = null
112
94
 
113
95
  callback(null, full)
114
96
  })
@@ -39,18 +39,23 @@ class ByteParser extends Writable {
39
39
  /** @type {import('./websocket').Handler} */
40
40
  #handler
41
41
 
42
+ /** @type {number} */
43
+ #maxPayloadSize
44
+
42
45
  /**
43
46
  * @param {import('./websocket').Handler} handler
44
47
  * @param {Map<string, string>|null} extensions
48
+ * @param {{ maxPayloadSize?: number }} [options]
45
49
  */
46
- constructor (handler, extensions) {
50
+ constructor (handler, extensions, options = {}) {
47
51
  super()
48
52
 
49
53
  this.#handler = handler
50
54
  this.#extensions = extensions == null ? new Map() : extensions
55
+ this.#maxPayloadSize = options.maxPayloadSize ?? 0
51
56
 
52
57
  if (this.#extensions.has('permessage-deflate')) {
53
- this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
58
+ this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
54
59
  }
55
60
  }
56
61
 
@@ -66,6 +71,19 @@ class ByteParser extends Writable {
66
71
  this.run(callback)
67
72
  }
68
73
 
74
+ #validatePayloadLength () {
75
+ if (
76
+ this.#maxPayloadSize > 0 &&
77
+ !isControlFrame(this.#info.opcode) &&
78
+ this.#info.payloadLength > this.#maxPayloadSize
79
+ ) {
80
+ failWebsocketConnection(this.#handler, 1009, 'Payload size exceeds maximum allowed size')
81
+ return false
82
+ }
83
+
84
+ return true
85
+ }
86
+
69
87
  /**
70
88
  * Runs whenever a new chunk is received.
71
89
  * Callback is called whenever there are no more chunks buffering,
@@ -154,6 +172,10 @@ class ByteParser extends Writable {
154
172
  if (payloadLength <= 125) {
155
173
  this.#info.payloadLength = payloadLength
156
174
  this.#state = parserStates.READ_DATA
175
+
176
+ if (!this.#validatePayloadLength()) {
177
+ return
178
+ }
157
179
  } else if (payloadLength === 126) {
158
180
  this.#state = parserStates.PAYLOADLENGTH_16
159
181
  } else if (payloadLength === 127) {
@@ -178,6 +200,10 @@ class ByteParser extends Writable {
178
200
 
179
201
  this.#info.payloadLength = buffer.readUInt16BE(0)
180
202
  this.#state = parserStates.READ_DATA
203
+
204
+ if (!this.#validatePayloadLength()) {
205
+ return
206
+ }
181
207
  } else if (this.#state === parserStates.PAYLOADLENGTH_64) {
182
208
  if (this.#byteOffset < 8) {
183
209
  return callback()
@@ -200,6 +226,10 @@ class ByteParser extends Writable {
200
226
 
201
227
  this.#info.payloadLength = lower
202
228
  this.#state = parserStates.READ_DATA
229
+
230
+ if (!this.#validatePayloadLength()) {
231
+ return
232
+ }
203
233
  } else if (this.#state === parserStates.READ_DATA) {
204
234
  if (this.#byteOffset < this.#info.payloadLength) {
205
235
  return callback()
@@ -224,29 +254,39 @@ class ByteParser extends Writable {
224
254
 
225
255
  this.#state = parserStates.INFO
226
256
  } else {
227
- this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
228
- if (error) {
229
- // Use 1009 (Message Too Big) for decompression size limit errors
230
- const code = error instanceof MessageSizeExceededError ? 1009 : 1007
231
- failWebsocketConnection(this.#handler, code, error.message)
232
- return
233
- }
234
-
235
- this.writeFragments(data)
257
+ this.#extensions.get('permessage-deflate').decompress(
258
+ body,
259
+ this.#info.fin,
260
+ (error, data) => {
261
+ if (error) {
262
+ const code = error instanceof MessageSizeExceededError ? 1009 : 1007
263
+ failWebsocketConnection(this.#handler, code, error.message)
264
+ return
265
+ }
266
+
267
+ this.writeFragments(data)
268
+
269
+ // Check cumulative fragment size
270
+ if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
271
+ failWebsocketConnection(this.#handler, 1009, new MessageSizeExceededError().message)
272
+ return
273
+ }
274
+
275
+ if (!this.#info.fin) {
276
+ this.#state = parserStates.INFO
277
+ this.#loop = true
278
+ this.run(callback)
279
+ return
280
+ }
281
+
282
+ websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments())
236
283
 
237
- if (!this.#info.fin) {
238
- this.#state = parserStates.INFO
239
284
  this.#loop = true
285
+ this.#state = parserStates.INFO
240
286
  this.run(callback)
241
- return
242
- }
243
-
244
- websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments())
245
-
246
- this.#loop = true
247
- this.#state = parserStates.INFO
248
- this.run(callback)
249
- })
287
+ },
288
+ this.#fragmentsBytes
289
+ )
250
290
 
251
291
  this.#loop = false
252
292
  break
@@ -468,7 +468,12 @@ class WebSocket extends EventTarget {
468
468
  // once this happens, the connection is open
469
469
  this.#handler.socket = response.socket
470
470
 
471
- const parser = new ByteParser(this.#handler, parsedExtensions)
471
+ // Get maxPayloadSize from dispatcher options
472
+ const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize
473
+
474
+ const parser = new ByteParser(this.#handler, parsedExtensions, {
475
+ maxPayloadSize
476
+ })
472
477
  parser.on('drain', () => this.#handler.onParserDrain())
473
478
  parser.on('error', (err) => this.#handler.onParserError(err))
474
479
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "8.0.3",
3
+ "version": "8.1.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
@@ -73,6 +73,8 @@ export declare namespace Client {
73
73
  localAddress?: string;
74
74
  /** Max response body size in bytes, -1 is disabled */
75
75
  maxResponseSize?: number;
76
+ /** WebSocket-specific options */
77
+ webSocket?: Client.WebSocketOptions;
76
78
  /** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */
77
79
  autoSelectFamily?: boolean;
78
80
  /** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */
@@ -113,6 +115,15 @@ export declare namespace Client {
113
115
  bytesWritten?: number
114
116
  bytesRead?: number
115
117
  }
118
+ export interface WebSocketOptions {
119
+ /**
120
+ * Maximum allowed payload size in bytes for WebSocket messages.
121
+ * Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages.
122
+ * Set to 0 to disable the limit.
123
+ * @default 134217728 (128 MB)
124
+ */
125
+ maxPayloadSize?: number;
126
+ }
116
127
  }
117
128
 
118
129
  export default Client