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.
- package/docs/docs/api/Client.md +2 -0
- package/lib/dispatcher/agent.js +1 -1
- package/lib/dispatcher/client.js +3 -2
- package/lib/dispatcher/dispatcher-base.js +18 -0
- package/lib/dispatcher/pool.js +1 -1
- package/lib/web/websocket/permessage-deflate.js +13 -31
- package/lib/web/websocket/receiver.js +62 -22
- package/lib/web/websocket/websocket.js +6 -1
- package/package.json +1 -1
- package/types/client.d.ts +11 -0
package/docs/docs/api/Client.md
CHANGED
|
@@ -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.
|
package/lib/dispatcher/agent.js
CHANGED
package/lib/dispatcher/client.js
CHANGED
|
@@ -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]
|
package/lib/dispatcher/pool.js
CHANGED
|
@@ -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
|
-
|
|
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] >
|
|
74
|
-
|
|
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 (
|
|
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(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|