undici 7.27.2 → 7.29.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,9 @@ 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
+ * **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
29
+ * **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
30
  * **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
31
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
29
32
  * **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.
@@ -80,6 +80,33 @@ Arguments:
80
80
 
81
81
  Returns: `Cookie[]`
82
82
 
83
+ ## `parseCookie(cookie)`
84
+
85
+ Parses a single `Set-Cookie` header value into a `Cookie` object.
86
+
87
+ ```js
88
+ import { parseCookie } from 'undici'
89
+
90
+ console.log(parseCookie('undici=getSetCookies; Secure; SameSite=Lax'))
91
+ // {
92
+ // name: 'undici',
93
+ // value: 'getSetCookies',
94
+ // secure: true,
95
+ // sameSite: 'Lax'
96
+ // }
97
+ ```
98
+
99
+ Notes:
100
+
101
+ * The cookie value is returned as it appears in the header. Percent-encoded sequences such as `%20` or `%0D%0A` are **not** decoded.
102
+ * `sameSite` is only set for exact case-insensitive matches of `Strict`, `Lax`, or `None`.
103
+
104
+ Arguments:
105
+
106
+ * **cookie** `string`
107
+
108
+ Returns: `Cookie | null`
109
+
83
110
  ## `setCookie(headers, cookie)`
84
111
 
85
112
  Appends a cookie to the `Set-Cookie` header.
@@ -22,6 +22,7 @@ Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions)
22
22
  * **password** `string` (optional) - SOCKS5 proxy password for authentication. Can also be provided in the proxy URL.
23
23
  * **connect** `Function` (optional) - Custom connector function for the proxy connection.
24
24
  * **proxyTls** `BuildOptions` (optional) - TLS options for the proxy connection (when using SOCKS5 over TLS).
25
+ * **requestTls** `BuildOptions` (optional) - TLS options applied to the HTTPS connection to the target server through the SOCKS5 tunnel. Use this to configure `ca`, `cert`, `key`, `rejectUnauthorized`, `servername`, etc. for the target HTTPS endpoint.
25
26
 
26
27
  Examples:
27
28
 
@@ -218,17 +218,62 @@ class MemoryCacheStore extends EventEmitter {
218
218
  }
219
219
 
220
220
  function findEntry (key, entries, now) {
221
- return entries.find((entry) => (
222
- entry.deleteAt > now &&
223
- entry.method === key.method &&
224
- (entry.vary == null || Object.keys(entry.vary).every(headerName => {
225
- if (entry.vary[headerName] === null) {
226
- return key.headers[headerName] === undefined
221
+ for (let i = 0; i < entries.length; i++) {
222
+ const entry = entries[i]
223
+ if (
224
+ entry.deleteAt > now &&
225
+ entry.method === key.method &&
226
+ varyMatches(key, entry)
227
+ ) {
228
+ return entry
229
+ }
230
+ }
231
+ }
232
+
233
+ function varyMatches (key, entry) {
234
+ if (entry.vary == null) {
235
+ return true
236
+ }
237
+
238
+ for (const headerName in entry.vary) {
239
+ if (Object.hasOwn(entry.vary, headerName) && !headerValueEquals(key.headers?.[headerName], entry.vary[headerName])) {
240
+ return false
241
+ }
242
+ }
243
+
244
+ return true
245
+ }
246
+
247
+ /**
248
+ * @param {string|string[]|null|undefined} lhs
249
+ * @param {string|string[]|null|undefined} rhs
250
+ * @returns {boolean}
251
+ */
252
+ function headerValueEquals (lhs, rhs) {
253
+ if (lhs == null && rhs == null) {
254
+ return true
255
+ }
256
+
257
+ if ((lhs == null && rhs != null) ||
258
+ (lhs != null && rhs == null)) {
259
+ return false
260
+ }
261
+
262
+ if (Array.isArray(lhs) && Array.isArray(rhs)) {
263
+ if (lhs.length !== rhs.length) {
264
+ return false
265
+ }
266
+
267
+ for (let i = 0; i < lhs.length; i++) {
268
+ if (lhs[i] !== rhs[i]) {
269
+ return false
227
270
  }
271
+ }
272
+
273
+ return true
274
+ }
228
275
 
229
- return entry.vary[headerName] === key.headers[headerName]
230
- }))
231
- ))
276
+ return lhs === rhs
232
277
  }
233
278
 
234
279
  module.exports = MemoryCacheStore
@@ -454,7 +454,13 @@ function headerValueEquals (lhs, rhs) {
454
454
  return false
455
455
  }
456
456
 
457
- return lhs.every((x, i) => x === rhs[i])
457
+ for (let i = 0; i < lhs.length; i++) {
458
+ if (lhs[i] !== rhs[i]) {
459
+ return false
460
+ }
461
+ }
462
+
463
+ return true
458
464
  }
459
465
 
460
466
  return lhs === rhs
@@ -390,7 +390,13 @@ function processHeader (request, key, val) {
390
390
  } else if (typeof val[i] === 'object') {
391
391
  throw new InvalidArgumentError(`invalid ${key} header`)
392
392
  } else {
393
- arr.push(`${val[i]}`)
393
+ // Coerce primitives (and reject unsafe coercions such as functions
394
+ // with a crafted toString/Symbol.toPrimitive).
395
+ const str = `${val[i]}`
396
+ if (!isValidHeaderValue(str)) {
397
+ throw new InvalidArgumentError(`invalid ${key} header`)
398
+ }
399
+ arr.push(str)
394
400
  }
395
401
  }
396
402
  val = arr
@@ -401,7 +407,12 @@ function processHeader (request, key, val) {
401
407
  } else if (val === null) {
402
408
  val = ''
403
409
  } else {
410
+ // Coerce primitives (and reject unsafe coercions such as functions
411
+ // with a crafted toString/Symbol.toPrimitive).
404
412
  val = `${val}`
413
+ if (!isValidHeaderValue(val)) {
414
+ throw new InvalidArgumentError(`invalid ${key} header`)
415
+ }
405
416
  }
406
417
 
407
418
  if (headerName === 'host') {
@@ -52,6 +52,7 @@ const STATES = {
52
52
  INITIAL: 'initial',
53
53
  HANDSHAKING: 'handshaking',
54
54
  AUTHENTICATING: 'authenticating',
55
+ AUTHENTICATED: 'authenticated',
55
56
  CONNECTING: 'connecting',
56
57
  CONNECTED: 'connected',
57
58
  ERROR: 'error',
@@ -143,6 +144,11 @@ class Socks5Client extends EventEmitter {
143
144
  }
144
145
  }
145
146
 
147
+ markAuthenticated () {
148
+ this.state = STATES.AUTHENTICATED
149
+ this.emit('authenticated')
150
+ }
151
+
146
152
  /**
147
153
  * Start the SOCKS5 handshake
148
154
  */
@@ -193,7 +199,7 @@ class Socks5Client extends EventEmitter {
193
199
  debug('server selected auth method', method)
194
200
 
195
201
  if (method === AUTH_METHODS.NO_AUTH) {
196
- this.emit('authenticated')
202
+ this.markAuthenticated()
197
203
  } else if (method === AUTH_METHODS.USERNAME_PASSWORD) {
198
204
  this.state = STATES.AUTHENTICATING
199
205
  this.sendAuthRequest()
@@ -258,7 +264,7 @@ class Socks5Client extends EventEmitter {
258
264
 
259
265
  this.buffer = this.buffer.subarray(2)
260
266
  debug('authentication successful')
261
- this.emit('authenticated')
267
+ this.markAuthenticated()
262
268
  }
263
269
 
264
270
  /**
@@ -267,8 +273,12 @@ class Socks5Client extends EventEmitter {
267
273
  * @param {number} port - Target port
268
274
  */
269
275
  connect (address, port) {
270
- if (this.state === STATES.CONNECTED) {
271
- throw new InvalidArgumentError('Already connected')
276
+ if (this.state === STATES.CONNECTING || this.state === STATES.CONNECTED) {
277
+ throw new InvalidArgumentError('Connection already in progress')
278
+ }
279
+
280
+ if (this.state !== STATES.AUTHENTICATED) {
281
+ throw new InvalidArgumentError('Client must be authenticated before CONNECT')
272
282
  }
273
283
 
274
284
  debug('connecting to', address, port)
@@ -46,12 +46,26 @@ function parseAddress (address) {
46
46
  */
47
47
  function parseIPv6 (address) {
48
48
  const buffer = Buffer.alloc(16)
49
+ let normalizedAddress = address
50
+
51
+ // Expand an embedded IPv4 tail into the last two IPv6 groups.
52
+ if (address.includes('.')) {
53
+ const lastColonIndex = address.lastIndexOf(':')
54
+ const ipv4Part = address.slice(lastColonIndex + 1)
55
+
56
+ if (net.isIPv4(ipv4Part)) {
57
+ const octets = ipv4Part.split('.').map(Number)
58
+ const high = ((octets[0] << 8) | octets[1]).toString(16)
59
+ const low = ((octets[2] << 8) | octets[3]).toString(16)
60
+ normalizedAddress = `${address.slice(0, lastColonIndex)}:${high}:${low}`
61
+ }
62
+ }
49
63
 
50
64
  // Handle compressed notation (::)
51
- const doubleColonIndex = address.indexOf('::')
65
+ const doubleColonIndex = normalizedAddress.indexOf('::')
52
66
  if (doubleColonIndex !== -1) {
53
- const before = address.slice(0, doubleColonIndex)
54
- const after = address.slice(doubleColonIndex + 2)
67
+ const before = normalizedAddress.slice(0, doubleColonIndex)
68
+ const after = normalizedAddress.slice(doubleColonIndex + 2)
55
69
  const beforeParts = before === '' ? [] : before.split(':')
56
70
  const afterParts = after === '' ? [] : after.split(':')
57
71
 
@@ -66,7 +80,7 @@ function parseIPv6 (address) {
66
80
  bufferIndex += 2
67
81
  }
68
82
  } else {
69
- const parts = address.split(':')
83
+ const parts = normalizedAddress.split(':')
70
84
  for (let i = 0; i < parts.length; i++) {
71
85
  buffer.writeUInt16BE(parseInt(parts[i], 16), i * 2)
72
86
  }
@@ -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 }
@@ -54,7 +54,7 @@ class BalancedPool extends PoolBase {
54
54
  throw new InvalidArgumentError('factory must be a function.')
55
55
  }
56
56
 
57
- super()
57
+ super(opts)
58
58
 
59
59
  this[kOptions] = { ...util.deepClone(opts) }
60
60
  this[kOptions].interceptors = opts.interceptors
@@ -10,6 +10,7 @@ const {
10
10
  RequestContentLengthMismatchError,
11
11
  ResponseContentLengthMismatchError,
12
12
  RequestAbortedError,
13
+ InvalidArgumentError,
13
14
  HeadersTimeoutError,
14
15
  HeadersOverflowError,
15
16
  SocketError,
@@ -57,6 +58,9 @@ const constants = require('../llhttp/constants.js')
57
58
  const EMPTY_BUF = Buffer.alloc(0)
58
59
  const FastBuffer = Buffer[Symbol.species]
59
60
  const removeAllListeners = util.removeAllListeners
61
+ const kIdleSocketValidation = Symbol('kIdleSocketValidation')
62
+ const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout')
63
+ const kSocketUsed = Symbol('kSocketUsed')
60
64
 
61
65
  let extractBody
62
66
 
@@ -440,6 +444,11 @@ class Parser {
440
444
  return -1
441
445
  }
442
446
 
447
+ if (client[kRunning] === 0) {
448
+ util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
449
+ return -1
450
+ }
451
+
443
452
  const request = client[kQueue][client[kRunningIdx]]
444
453
  if (!request) {
445
454
  return -1
@@ -568,6 +577,11 @@ class Parser {
568
577
  return -1
569
578
  }
570
579
 
580
+ if (client[kRunning] === 0) {
581
+ util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
582
+ return -1
583
+ }
584
+
571
585
  const request = client[kQueue][client[kRunningIdx]]
572
586
 
573
587
  if (!request) {
@@ -746,6 +760,7 @@ class Parser {
746
760
  request.onComplete(headers)
747
761
 
748
762
  client[kQueue][client[kRunningIdx]++] = null
763
+ socket[kSocketUsed] = client[kPending] === 0
749
764
 
750
765
  if (socket[kWriting]) {
751
766
  assert(client[kRunning] === 0)
@@ -822,6 +837,9 @@ function connectH1 (client, socket) {
822
837
  socket[kWriting] = false
823
838
  socket[kReset] = false
824
839
  socket[kBlocking] = false
840
+ socket[kIdleSocketValidation] = 0
841
+ socket[kIdleSocketValidationTimeout] = null
842
+ socket[kSocketUsed] = false
825
843
  socket[kParser] = new Parser(client, socket, llhttpInstance)
826
844
 
827
845
  util.addListener(socket, 'error', onHttpSocketError)
@@ -864,7 +882,7 @@ function connectH1 (client, socket) {
864
882
  * @returns {boolean}
865
883
  */
866
884
  busy (request) {
867
- if (socket[kWriting] || socket[kReset] || socket[kBlocking]) {
885
+ if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) {
868
886
  return true
869
887
  }
870
888
 
@@ -944,6 +962,8 @@ function onHttpSocketEnd () {
944
962
  function onHttpSocketClose () {
945
963
  const parser = this[kParser]
946
964
 
965
+ clearIdleSocketValidation(this)
966
+
947
967
  if (parser) {
948
968
  if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
949
969
  this[kError] = parser.finish() || this[kError]
@@ -990,6 +1010,28 @@ function onSocketClose () {
990
1010
  this[kClosed] = true
991
1011
  }
992
1012
 
1013
+ function clearIdleSocketValidation (socket) {
1014
+ if (socket[kIdleSocketValidationTimeout]) {
1015
+ clearTimeout(socket[kIdleSocketValidationTimeout])
1016
+ socket[kIdleSocketValidationTimeout] = null
1017
+ }
1018
+
1019
+ socket[kIdleSocketValidation] = 0
1020
+ }
1021
+
1022
+ function scheduleIdleSocketValidation (client, socket) {
1023
+ socket[kIdleSocketValidation] = 1
1024
+ socket[kIdleSocketValidationTimeout] = setTimeout(() => {
1025
+ socket[kIdleSocketValidationTimeout] = null
1026
+ socket[kIdleSocketValidation] = 2
1027
+
1028
+ if (client[kSocket] === socket && !socket.destroyed) {
1029
+ client[kResume]()
1030
+ }
1031
+ }, 0)
1032
+ socket[kIdleSocketValidationTimeout].unref?.()
1033
+ }
1034
+
993
1035
  /**
994
1036
  * @param {import('./client.js')} client
995
1037
  */
@@ -1007,6 +1049,32 @@ function resumeH1 (client) {
1007
1049
  socket[kNoRef] = false
1008
1050
  }
1009
1051
 
1052
+ if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) {
1053
+ if (socket[kIdleSocketValidation] === 0) {
1054
+ scheduleIdleSocketValidation(client, socket)
1055
+ socket[kParser].readMore()
1056
+ if (socket.destroyed) {
1057
+ return
1058
+ }
1059
+ return
1060
+ }
1061
+
1062
+ if (socket[kIdleSocketValidation] === 1) {
1063
+ socket[kParser].readMore()
1064
+ if (socket.destroyed) {
1065
+ return
1066
+ }
1067
+ return
1068
+ }
1069
+ }
1070
+
1071
+ if (client[kRunning] === 0) {
1072
+ socket[kParser].readMore()
1073
+ if (socket.destroyed) {
1074
+ return
1075
+ }
1076
+ }
1077
+
1010
1078
  if (client[kSize] === 0) {
1011
1079
  if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
1012
1080
  socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
@@ -1067,8 +1135,16 @@ function writeH1 (client, request) {
1067
1135
  }
1068
1136
  body = bodyStream.stream
1069
1137
  contentLength = bodyStream.length
1070
- } else if (util.isBlobLike(body) && request.contentType == null && body.type) {
1071
- headers.push('content-type', body.type)
1138
+ } else if (util.isBlobLike(body) && request.contentType == null) {
1139
+ const contentType = body.type
1140
+ if (contentType) {
1141
+ const contentTypeValue = `${contentType}`
1142
+ if (!util.isValidHeaderValue(contentTypeValue)) {
1143
+ util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header'))
1144
+ return false
1145
+ }
1146
+ headers.push('content-type', contentTypeValue)
1147
+ }
1072
1148
  }
1073
1149
 
1074
1150
  if (body && typeof body.read === 'function') {
@@ -1105,6 +1181,7 @@ function writeH1 (client, request) {
1105
1181
  }
1106
1182
 
1107
1183
  const socket = client[kSocket]
1184
+ clearIdleSocketValidation(socket)
1108
1185
 
1109
1186
  /**
1110
1187
  * @param {Error} [err]
@@ -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({
@@ -11,6 +11,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy
11
11
 
12
12
  const kOnDestroyed = Symbol('onDestroyed')
13
13
  const kOnClosed = Symbol('onClosed')
14
+ const kWebSocketOptions = Symbol('webSocketOptions')
14
15
 
15
16
  class DispatcherBase extends Dispatcher {
16
17
  /** @type {boolean} */
@@ -25,6 +26,24 @@ class DispatcherBase extends Dispatcher {
25
26
  /** @type {Array<Function>|null} */
26
27
  [kOnClosed] = null
27
28
 
29
+ /**
30
+ * @param {import('../../types/dispatcher').DispatcherOptions} [opts]
31
+ */
32
+ constructor (opts) {
33
+ super()
34
+ this[kWebSocketOptions] = opts?.webSocket ?? {}
35
+ }
36
+
37
+ /**
38
+ * @returns {import('../../types/dispatcher').WebSocketOptions}
39
+ */
40
+ get webSocketOptions () {
41
+ return {
42
+ maxFragments: this[kWebSocketOptions].maxFragments ?? 131072,
43
+ maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 // 128 MB default
44
+ }
45
+ }
46
+
28
47
  /** @returns {boolean} */
29
48
  get destroyed () {
30
49
  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)
@@ -142,7 +142,8 @@ class ProxyAgent extends DispatcherBase {
142
142
  factory: agentFactory,
143
143
  username: opts.username || username,
144
144
  password: opts.password || password,
145
- proxyTls: opts.proxyTls
145
+ proxyTls: opts.proxyTls,
146
+ requestTls: opts.requestTls
146
147
  })
147
148
  }
148
149