undici 7.30.0 → 7.30.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.
@@ -27,6 +27,8 @@ Returns: `Client`
27
27
  * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
28
28
  * **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
29
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.
30
+ * **eventSource** `EventSourceOptions` (optional) - EventSource-specific configuration options.
31
+ * **maxEventSize** `number` (optional) - The maximum total bytes of data and metadata field values retained for a pending EventSource event. Each unfinished line is separately limited to this value plus seven syntax bytes. The number of `data:` lines per event and input chunks buffered for an unfinished line are each limited to `min(131072, max(1024, floor(maxEventSize / 128)))` to bound object overhead. Set to `0` to disable these limits. Default: `buffer.kStringMaxLength`.
30
32
  * **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.
31
33
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
32
34
  * **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.
@@ -41,16 +41,22 @@ const SessionCache = class WeakSessionCache {
41
41
  if (this._sessionCache.has(sessionKey)) {
42
42
  this._sessionCache.delete(sessionKey)
43
43
  } else if (this._sessionCache.size >= this._maxCachedSessions) {
44
+ // Make room for the new session: prefer an entry whose session has
45
+ // already been collected, otherwise evict the oldest one.
46
+ let evicted = false
44
47
  for (const [key, ref] of this._sessionCache) {
45
48
  if (ref.deref() === undefined) {
46
49
  this._sessionCache.delete(key)
47
- return
50
+ evicted = true
51
+ break
48
52
  }
49
53
  }
50
54
 
51
- const oldest = this._sessionCache.keys().next()
52
- if (!oldest.done) {
53
- this._sessionCache.delete(oldest.value)
55
+ if (!evicted) {
56
+ const oldest = this._sessionCache.keys().next()
57
+ if (!oldest.done) {
58
+ this._sessionCache.delete(oldest.value)
59
+ }
54
60
  }
55
61
  }
56
62
 
@@ -5,6 +5,7 @@ module.exports = {
5
5
  kDestroy: Symbol('destroy'),
6
6
  kDispatch: Symbol('dispatch'),
7
7
  kUrl: Symbol('url'),
8
+ kRedirectCacheControl: Symbol('redirect cache control'),
8
9
  kWriting: Symbol('writing'),
9
10
  kResuming: Symbol('resuming'),
10
11
  kQueue: Symbol('queue'),
@@ -22,7 +22,7 @@ function defaultFactory (origin, opts) {
22
22
  }
23
23
 
24
24
  class Agent extends DispatcherBase {
25
- constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
25
+ constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, tls, ...options } = {}) {
26
26
  if (typeof factory !== 'function') {
27
27
  throw new InvalidArgumentError('factory must be a function.')
28
28
  }
@@ -41,7 +41,11 @@ class Agent extends DispatcherBase {
41
41
  connect = { ...connect }
42
42
  }
43
43
 
44
- this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }
44
+ if (tls && typeof tls !== 'function') {
45
+ tls = { ...tls }
46
+ }
47
+
48
+ this[kOptions] = { ...util.deepClone(options), maxOrigins, connect, tls }
45
49
  this[kFactory] = factory
46
50
  this[kClients] = new Map()
47
51
  this[kOrigins] = new Set()
@@ -928,8 +928,6 @@ function connectH1 (client, socket) {
928
928
  }
929
929
 
930
930
  function onHttpSocketError (err) {
931
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
932
-
933
931
  const parser = this[kParser]
934
932
 
935
933
  // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
@@ -293,8 +293,6 @@ function onHttp2SendPing (session) {
293
293
  }
294
294
 
295
295
  function onHttp2SessionError (err) {
296
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
297
-
298
296
  this[kSocket][kError] = err
299
297
  this[kClient][kOnError](err)
300
298
  }
@@ -436,8 +434,6 @@ function onHttp2SocketClose () {
436
434
  }
437
435
 
438
436
  function onHttp2SocketError (err) {
439
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
440
-
441
437
  this[kError] = err
442
438
 
443
439
  this[kClient][kOnError](err)
@@ -115,7 +115,8 @@ class Client extends DispatcherBase {
115
115
  initialWindowSize,
116
116
  connectionWindowSize,
117
117
  pingInterval,
118
- webSocket
118
+ webSocket,
119
+ eventSource
119
120
  } = {}) {
120
121
  if (keepAlive !== undefined) {
121
122
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -223,7 +224,7 @@ class Client extends DispatcherBase {
223
224
  throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
224
225
  }
225
226
 
226
- super({ webSocket })
227
+ super({ webSocket, eventSource })
227
228
 
228
229
  if (typeof connect !== 'function') {
229
230
  connect = buildConnector({
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const buffer = require('node:buffer')
3
4
  const Dispatcher = require('./dispatcher')
4
5
  const UnwrapHandler = require('../handler/unwrap-handler')
5
6
  const {
@@ -12,6 +13,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy
12
13
  const kOnDestroyed = Symbol('onDestroyed')
13
14
  const kOnClosed = Symbol('onClosed')
14
15
  const kWebSocketOptions = Symbol('webSocketOptions')
16
+ const kEventSourceOptions = Symbol('eventSourceOptions')
15
17
 
16
18
  class DispatcherBase extends Dispatcher {
17
19
  /** @type {boolean} */
@@ -32,6 +34,7 @@ class DispatcherBase extends Dispatcher {
32
34
  constructor (opts) {
33
35
  super()
34
36
  this[kWebSocketOptions] = opts?.webSocket ?? {}
37
+ this[kEventSourceOptions] = opts?.eventSource ?? {}
35
38
  }
36
39
 
37
40
  /**
@@ -44,6 +47,15 @@ class DispatcherBase extends Dispatcher {
44
47
  }
45
48
  }
46
49
 
50
+ /**
51
+ * @returns {import('../../types/client').Client.EventSourceOptions}
52
+ */
53
+ get eventSourceOptions () {
54
+ return {
55
+ maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength
56
+ }
57
+ }
58
+
47
59
  /** @returns {boolean} */
48
60
  get destroyed () {
49
61
  return this[kDestroyed]
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const util = require('../core/util')
4
+ const { kRedirectCacheControl } = require('../core/symbols')
4
5
  const {
5
6
  parseCacheControlHeader,
6
7
  hasInvalidCacheControlDirective,
@@ -13,6 +14,19 @@ const { parseHttpDate } = require('../util/date.js')
13
14
 
14
15
  function noop () {}
15
16
 
17
+ /**
18
+ * @param {string | URL} a
19
+ * @param {string | URL} b
20
+ * @returns {boolean}
21
+ */
22
+ function sameOrigin (a, b) {
23
+ try {
24
+ return new URL(String(a)).origin === new URL(String(b)).origin
25
+ } catch {
26
+ return String(a) === String(b)
27
+ }
28
+ }
29
+
16
30
  // Status codes that we can use some heuristics on to cache
17
31
  const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
18
32
  200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
@@ -151,6 +165,14 @@ class CacheHandler {
151
165
  */
152
166
  #writeStream
153
167
 
168
+ /**
169
+ * The URLs visited while following redirects, shared by reference from the
170
+ * redirect handler through the request context. Carries the suppressed hops'
171
+ * cache-control under {@link kRedirectCacheControl}.
172
+ * @type {URL[] | undefined}
173
+ */
174
+ #history
175
+
154
176
  /**
155
177
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts
156
178
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
@@ -167,6 +189,9 @@ class CacheHandler {
167
189
  onRequestStart (controller, context) {
168
190
  this.#writeStream?.destroy()
169
191
  this.#writeStream = undefined
192
+ // The redirect handler mutates this shared history as redirects are
193
+ // followed, allowing the cache to identify the response's actual origin.
194
+ this.#history = context?.history
170
195
  this.#handler.onRequestStart?.(controller, context)
171
196
  }
172
197
 
@@ -206,6 +231,29 @@ class CacheHandler {
206
231
  return downstreamOnHeaders()
207
232
  }
208
233
 
234
+ // Never store a response produced by a different origin under the original
235
+ // request's cache key.
236
+ if (this.#history && this.#history.length > 0) {
237
+ const finalUrl = this.#history[this.#history.length - 1]
238
+ if (!sameOrigin(finalUrl.origin, this.#cacheKey.origin)) {
239
+ return downstreamOnHeaders()
240
+ }
241
+ }
242
+
243
+ // Preserve restrictive cache policy from suppressed redirect responses.
244
+ const redirectCacheControl = this.#history?.[kRedirectCacheControl]
245
+ if (redirectCacheControl) {
246
+ for (const hopCacheControl of redirectCacheControl) {
247
+ const hopDirectives = hopCacheControl ? parseCacheControlHeader(hopCacheControl) : {}
248
+ if (
249
+ hopDirectives['no-store'] === true ||
250
+ (this.#cacheType === 'shared' && hopDirectives.private === true)
251
+ ) {
252
+ return downstreamOnHeaders()
253
+ }
254
+ }
255
+ }
256
+
209
257
  const cacheControlHeader = resHeaders['cache-control']
210
258
  const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
211
259
 
@@ -16,6 +16,7 @@ const DEFAULT_MAX_BUFFER_SIZE = 5 * 1024 * 1024
16
16
  * @property {number} bufferedBytes
17
17
  * @property {object | null} pendingTrailers
18
18
  * @property {boolean} done
19
+ * @property {boolean} [pendingStart] onRequestStart deferred until the primary request's context is known
19
20
  */
20
21
 
21
22
  /**
@@ -30,6 +31,20 @@ class DeduplicationHandler {
30
31
  */
31
32
  #primaryHandler
32
33
 
34
+ /**
35
+ * Whether the primary request has started (onRequestStart received). Until
36
+ * then the request context is unknown and cannot be given to waiting handlers.
37
+ * @type {boolean}
38
+ */
39
+ #requestStarted = false
40
+
41
+ /**
42
+ * The primary request context, replayed to waiting handlers so they observe
43
+ * the same redirect history as the primary.
44
+ * @type {any}
45
+ */
46
+ #context = null
47
+
33
48
  /**
34
49
  * @type {WaitingHandler[]}
35
50
  */
@@ -112,20 +127,30 @@ class DeduplicationHandler {
112
127
  const waitingController = waitingHandler.controller
113
128
 
114
129
  try {
115
- handler.onRequestStart?.(waitingController, null)
130
+ if (this.#requestStarted) {
131
+ // The primary request has started, so its context (including the
132
+ // redirect history) is known and can be delivered immediately.
133
+ handler.onRequestStart?.(waitingController, this.#context)
116
134
 
117
- if (waitingController.aborted) {
118
- waitingHandler.done = true
119
- return true
120
- }
135
+ if (waitingController.aborted) {
136
+ waitingHandler.done = true
137
+ return true
138
+ }
121
139
 
122
- if (this.#responseStarted) {
123
- handler.onResponseStart?.(
124
- waitingController,
125
- this.#statusCode,
126
- this.#headers,
127
- this.#statusMessage
128
- )
140
+ if (this.#responseStarted) {
141
+ handler.onResponseStart?.(
142
+ waitingController,
143
+ this.#statusCode,
144
+ this.#headers,
145
+ this.#statusMessage
146
+ )
147
+ }
148
+ } else {
149
+ // The primary request has not started yet. Defer onRequestStart until
150
+ // its context is known, so the waiting handler observes the same
151
+ // redirect history as the primary (a cache handler relies on it to
152
+ // reject cross-origin redirect responses).
153
+ waitingHandler.pendingStart = true
129
154
  }
130
155
  } catch {
131
156
  // Ignore errors from waiting handlers
@@ -146,6 +171,24 @@ class DeduplicationHandler {
146
171
  */
147
172
  onRequestStart (controller, context) {
148
173
  this.#controller = controller
174
+ if (!this.#requestStarted) {
175
+ this.#requestStarted = true
176
+ this.#context = context
177
+ // Deliver the now-known context to handlers that joined before the
178
+ // primary request started.
179
+ for (const waitingHandler of this.#waitingHandlers) {
180
+ if (waitingHandler.pendingStart && !waitingHandler.done && !waitingHandler.controller.aborted) {
181
+ waitingHandler.pendingStart = false
182
+ try {
183
+ waitingHandler.handler.onRequestStart?.(waitingHandler.controller, context)
184
+ } catch {
185
+ // Ignore errors from waiting handlers
186
+ waitingHandler.done = true
187
+ }
188
+ }
189
+ }
190
+ this.#pruneDoneWaitingHandlers()
191
+ }
149
192
  this.#primaryHandler.onRequestStart?.(controller, context)
150
193
  }
151
194
 
@@ -319,7 +362,8 @@ class DeduplicationHandler {
319
362
  bufferedChunks: [],
320
363
  bufferedBytes: 0,
321
364
  pendingTrailers: null,
322
- done: false
365
+ done: false,
366
+ pendingStart: false
323
367
  }
324
368
 
325
369
  const state = {
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const util = require('../core/util')
4
- const { kBodyUsed } = require('../core/symbols')
4
+ const { kBodyUsed, kRedirectCacheControl } = require('../core/symbols')
5
5
  const assert = require('node:assert')
6
6
  const { InvalidArgumentError } = require('../core/errors')
7
7
  const EE = require('node:events')
@@ -47,6 +47,11 @@ class RedirectHandler {
47
47
  this.maxRedirections = maxRedirections
48
48
  this.handler = handler
49
49
  this.history = []
50
+ // Retain cache-control from suppressed redirect hops for an outer cache.
51
+ Object.defineProperty(this.history, kRedirectCacheControl, {
52
+ value: [],
53
+ enumerable: false
54
+ })
50
55
 
51
56
  if (util.isStream(this.opts.body)) {
52
57
  // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp
@@ -130,6 +135,8 @@ class RedirectHandler {
130
135
  return
131
136
  }
132
137
 
138
+ this.history[kRedirectCacheControl].push(headers['cache-control'])
139
+
133
140
  const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)))
134
141
  const path = search ? `${pathname}${search}` : pathname
135
142
 
@@ -220,9 +220,14 @@ class DecompressHandler extends DecoratorHandler {
220
220
  this.#decompressedSize = decompressedSize
221
221
  }
222
222
 
223
- const result = super.onResponseData(this.#controller, chunk)
224
- if (result === false && !this.#controller.paused) {
225
- this.#controller.pause()
223
+ try {
224
+ const result = super.onResponseData(this.#controller, chunk)
225
+ if (result === false && !this.#controller.paused) {
226
+ this.#controller.pause()
227
+ }
228
+ } catch (err) {
229
+ this.#fail(err)
230
+ return
226
231
  }
227
232
  }
228
233
  } while (this.#drainRequested && !this.#terminated && !this.#controller.paused)
@@ -239,9 +244,14 @@ class DecompressHandler extends DecoratorHandler {
239
244
  return
240
245
  }
241
246
 
242
- this.#terminated = true
243
247
  this.#cleanupDecompressors()
244
- super.onResponseEnd(this.#controller, this.#trailers)
248
+ try {
249
+ super.onResponseEnd(this.#controller, this.#trailers)
250
+ } catch (err) {
251
+ this.#fail(err)
252
+ return
253
+ }
254
+ this.#terminated = true
245
255
  }
246
256
 
247
257
  #onDecompressionEnd () {
@@ -32,8 +32,10 @@ function getCookies (headers) {
32
32
 
33
33
  const cookie = headers.get('cookie')
34
34
 
35
+ // A null prototype keeps a cookie named `__proto__` from hitting the
36
+ // Object.prototype setter, which would silently drop it.
35
37
  /** @type {Record<string, string>} */
36
- const out = {}
38
+ const out = { __proto__: null }
37
39
 
38
40
  if (!cookie) {
39
41
  return out
@@ -92,220 +92,221 @@ function parseSetCookie (header) {
92
92
  * @param {Object.<string, unknown>} [cookieAttributeList={}]
93
93
  */
94
94
  function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) {
95
- // 1. If the unparsed-attributes string is empty, skip the rest of
96
- // these steps.
97
- if (unparsedAttributes.length === 0) {
98
- return cookieAttributeList
99
- }
95
+ while (true) {
96
+ // 1. If the unparsed-attributes string is empty, skip the rest of
97
+ // these steps.
98
+ if (unparsedAttributes.length === 0) {
99
+ return cookieAttributeList
100
+ }
100
101
 
101
- // 2. Discard the first character of the unparsed-attributes (which
102
- // will be a %x3B (";") character).
103
- assert(unparsedAttributes[0] === ';')
104
- unparsedAttributes = unparsedAttributes.slice(1)
102
+ // 2. Discard the first character of the unparsed-attributes (which
103
+ // will be a %x3B (";") character).
104
+ assert(unparsedAttributes[0] === ';')
105
+ unparsedAttributes = unparsedAttributes.slice(1)
105
106
 
106
- let cookieAv = ''
107
+ let cookieAv = ''
107
108
 
108
- // 3. If the remaining unparsed-attributes contains a %x3B (";")
109
- // character:
110
- if (unparsedAttributes.includes(';')) {
109
+ // 3. If the remaining unparsed-attributes contains a %x3B (";")
110
+ // character:
111
+ if (unparsedAttributes.includes(';')) {
111
112
  // 1. Consume the characters of the unparsed-attributes up to, but
112
113
  // not including, the first %x3B (";") character.
113
- cookieAv = collectASequenceOfCodePointsFast(
114
- ';',
115
- unparsedAttributes,
116
- { position: 0 }
117
- )
118
- unparsedAttributes = unparsedAttributes.slice(cookieAv.length)
119
- } else {
114
+ cookieAv = collectASequenceOfCodePointsFast(
115
+ ';',
116
+ unparsedAttributes,
117
+ { position: 0 }
118
+ )
119
+ unparsedAttributes = unparsedAttributes.slice(cookieAv.length)
120
+ } else {
120
121
  // Otherwise:
121
122
 
122
- // 1. Consume the remainder of the unparsed-attributes.
123
- cookieAv = unparsedAttributes
124
- unparsedAttributes = ''
125
- }
123
+ // 1. Consume the remainder of the unparsed-attributes.
124
+ cookieAv = unparsedAttributes
125
+ unparsedAttributes = ''
126
+ }
126
127
 
127
- // Let the cookie-av string be the characters consumed in this step.
128
+ // Let the cookie-av string be the characters consumed in this step.
128
129
 
129
- let attributeName = ''
130
- let attributeValue = ''
130
+ let attributeName = ''
131
+ let attributeValue = ''
131
132
 
132
- // 4. If the cookie-av string contains a %x3D ("=") character:
133
- if (cookieAv.includes('=')) {
133
+ // 4. If the cookie-av string contains a %x3D ("=") character:
134
+ if (cookieAv.includes('=')) {
134
135
  // 1. The (possibly empty) attribute-name string consists of the
135
136
  // characters up to, but not including, the first %x3D ("=")
136
137
  // character, and the (possibly empty) attribute-value string
137
138
  // consists of the characters after the first %x3D ("=")
138
139
  // character.
139
- const position = { position: 0 }
140
-
141
- attributeName = collectASequenceOfCodePointsFast(
142
- '=',
143
- cookieAv,
144
- position
145
- )
146
- attributeValue = cookieAv.slice(position.position + 1)
147
- } else {
140
+ const position = { position: 0 }
141
+
142
+ attributeName = collectASequenceOfCodePointsFast(
143
+ '=',
144
+ cookieAv,
145
+ position
146
+ )
147
+ attributeValue = cookieAv.slice(position.position + 1)
148
+ } else {
148
149
  // Otherwise:
149
150
 
150
- // 1. The attribute-name string consists of the entire cookie-av
151
- // string, and the attribute-value string is empty.
152
- attributeName = cookieAv
153
- }
151
+ // 1. The attribute-name string consists of the entire cookie-av
152
+ // string, and the attribute-value string is empty.
153
+ attributeName = cookieAv
154
+ }
154
155
 
155
- // 5. Remove any leading or trailing WSP characters from the attribute-
156
- // name string and the attribute-value string.
157
- attributeName = attributeName.trim()
158
- attributeValue = attributeValue.trim()
156
+ // 5. Remove any leading or trailing WSP characters from the attribute-
157
+ // name string and the attribute-value string.
158
+ attributeName = attributeName.trim()
159
+ attributeValue = attributeValue.trim()
159
160
 
160
- // 6. If the attribute-value is longer than 1024 octets, ignore the
161
- // cookie-av string and return to Step 1 of this algorithm.
162
- if (attributeValue.length > maxAttributeValueSize) {
163
- return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList)
164
- }
161
+ // 6. If the attribute-value is longer than 1024 octets, ignore the
162
+ // cookie-av string and return to Step 1 of this algorithm.
163
+ if (attributeValue.length > maxAttributeValueSize) {
164
+ continue
165
+ }
165
166
 
166
- // 7. Process the attribute-name and attribute-value according to the
167
- // requirements in the following subsections. (Notice that
168
- // attributes with unrecognized attribute-names are ignored.)
169
- const attributeNameLowercase = attributeName.toLowerCase()
167
+ // 7. Process the attribute-name and attribute-value according to the
168
+ // requirements in the following subsections. (Notice that
169
+ // attributes with unrecognized attribute-names are ignored.)
170
+ const attributeNameLowercase = attributeName.toLowerCase()
170
171
 
171
- // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.1
172
- // If the attribute-name case-insensitively matches the string
173
- // "Expires", the user agent MUST process the cookie-av as follows.
174
- if (attributeNameLowercase === 'expires') {
172
+ // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.1
173
+ // If the attribute-name case-insensitively matches the string
174
+ // "Expires", the user agent MUST process the cookie-av as follows.
175
+ if (attributeNameLowercase === 'expires') {
175
176
  // 1. Let the expiry-time be the result of parsing the attribute-value
176
177
  // as cookie-date (see Section 5.1.1).
177
- const expiryTime = new Date(attributeValue)
178
+ const expiryTime = new Date(attributeValue)
178
179
 
179
- // 2. If the attribute-value failed to parse as a cookie date, ignore
180
- // the cookie-av.
180
+ // 2. If the attribute-value failed to parse as a cookie date, ignore
181
+ // the cookie-av.
181
182
 
182
- cookieAttributeList.expires = expiryTime
183
- } else if (attributeNameLowercase === 'max-age') {
183
+ cookieAttributeList.expires = expiryTime
184
+ } else if (attributeNameLowercase === 'max-age') {
184
185
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.2
185
186
  // If the attribute-name case-insensitively matches the string "Max-
186
187
  // Age", the user agent MUST process the cookie-av as follows.
187
188
 
188
- // 1. If the first character of the attribute-value is not a DIGIT or a
189
- // "-" character, ignore the cookie-av.
190
- const charCode = attributeValue.charCodeAt(0)
189
+ // 1. If the first character of the attribute-value is not a DIGIT or a
190
+ // "-" character, ignore the cookie-av.
191
+ const charCode = attributeValue.charCodeAt(0)
191
192
 
192
- if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') {
193
- return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList)
194
- }
193
+ if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') {
194
+ continue
195
+ }
195
196
 
196
- // 2. If the remainder of attribute-value contains a non-DIGIT
197
- // character, ignore the cookie-av.
198
- if (!/^\d+$/.test(attributeValue)) {
199
- return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList)
200
- }
197
+ // 2. If the remainder of attribute-value contains a non-DIGIT
198
+ // character, ignore the cookie-av.
199
+ if (!/^\d+$/.test(attributeValue)) {
200
+ continue
201
+ }
201
202
 
202
- // 3. Let delta-seconds be the attribute-value converted to an integer.
203
- const deltaSeconds = Number(attributeValue)
203
+ // 3. Let delta-seconds be the attribute-value converted to an integer.
204
+ const deltaSeconds = Number(attributeValue)
204
205
 
205
- // 4. Let cookie-age-limit be the maximum age of the cookie (which
206
- // SHOULD be 400 days or less, see Section 4.1.2.2).
206
+ // 4. Let cookie-age-limit be the maximum age of the cookie (which
207
+ // SHOULD be 400 days or less, see Section 4.1.2.2).
207
208
 
208
- // 5. Set delta-seconds to the smaller of its present value and cookie-
209
- // age-limit.
210
- // deltaSeconds = Math.min(deltaSeconds * 1000, maxExpiresMs)
209
+ // 5. Set delta-seconds to the smaller of its present value and cookie-
210
+ // age-limit.
211
+ // deltaSeconds = Math.min(deltaSeconds * 1000, maxExpiresMs)
211
212
 
212
- // 6. If delta-seconds is less than or equal to zero (0), let expiry-
213
- // time be the earliest representable date and time. Otherwise, let
214
- // the expiry-time be the current date and time plus delta-seconds
215
- // seconds.
216
- // const expiryTime = deltaSeconds <= 0 ? Date.now() : Date.now() + deltaSeconds
213
+ // 6. If delta-seconds is less than or equal to zero (0), let expiry-
214
+ // time be the earliest representable date and time. Otherwise, let
215
+ // the expiry-time be the current date and time plus delta-seconds
216
+ // seconds.
217
+ // const expiryTime = deltaSeconds <= 0 ? Date.now() : Date.now() + deltaSeconds
217
218
 
218
- // 7. Append an attribute to the cookie-attribute-list with an
219
- // attribute-name of Max-Age and an attribute-value of expiry-time.
220
- cookieAttributeList.maxAge = deltaSeconds
221
- } else if (attributeNameLowercase === 'domain') {
219
+ // 7. Append an attribute to the cookie-attribute-list with an
220
+ // attribute-name of Max-Age and an attribute-value of expiry-time.
221
+ cookieAttributeList.maxAge = deltaSeconds
222
+ } else if (attributeNameLowercase === 'domain') {
222
223
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.3
223
224
  // If the attribute-name case-insensitively matches the string "Domain",
224
225
  // the user agent MUST process the cookie-av as follows.
225
226
 
226
- // 1. Let cookie-domain be the attribute-value.
227
- let cookieDomain = attributeValue
227
+ // 1. Let cookie-domain be the attribute-value.
228
+ let cookieDomain = attributeValue
228
229
 
229
- // 2. If cookie-domain starts with %x2E ("."), let cookie-domain be
230
- // cookie-domain without its leading %x2E (".").
231
- if (cookieDomain[0] === '.') {
232
- cookieDomain = cookieDomain.slice(1)
233
- }
230
+ // 2. If cookie-domain starts with %x2E ("."), let cookie-domain be
231
+ // cookie-domain without its leading %x2E (".").
232
+ if (cookieDomain[0] === '.') {
233
+ cookieDomain = cookieDomain.slice(1)
234
+ }
234
235
 
235
- // 3. Convert the cookie-domain to lower case.
236
- cookieDomain = cookieDomain.toLowerCase()
236
+ // 3. Convert the cookie-domain to lower case.
237
+ cookieDomain = cookieDomain.toLowerCase()
237
238
 
238
- // 4. Append an attribute to the cookie-attribute-list with an
239
- // attribute-name of Domain and an attribute-value of cookie-domain.
240
- cookieAttributeList.domain = cookieDomain
241
- } else if (attributeNameLowercase === 'path') {
239
+ // 4. Append an attribute to the cookie-attribute-list with an
240
+ // attribute-name of Domain and an attribute-value of cookie-domain.
241
+ cookieAttributeList.domain = cookieDomain
242
+ } else if (attributeNameLowercase === 'path') {
242
243
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.4
243
244
  // If the attribute-name case-insensitively matches the string "Path",
244
245
  // the user agent MUST process the cookie-av as follows.
245
246
 
246
- // 1. If the attribute-value is empty or if the first character of the
247
- // attribute-value is not %x2F ("/"):
248
- let cookiePath = ''
249
- if (attributeValue.length === 0 || attributeValue[0] !== '/') {
247
+ // 1. If the attribute-value is empty or if the first character of the
248
+ // attribute-value is not %x2F ("/"):
249
+ let cookiePath = ''
250
+ if (attributeValue.length === 0 || attributeValue[0] !== '/') {
250
251
  // 1. Let cookie-path be the default-path.
251
- cookiePath = '/'
252
- } else {
252
+ cookiePath = '/'
253
+ } else {
253
254
  // Otherwise:
254
255
 
255
- // 1. Let cookie-path be the attribute-value.
256
- cookiePath = attributeValue
257
- }
256
+ // 1. Let cookie-path be the attribute-value.
257
+ cookiePath = attributeValue
258
+ }
258
259
 
259
- // 2. Append an attribute to the cookie-attribute-list with an
260
- // attribute-name of Path and an attribute-value of cookie-path.
261
- cookieAttributeList.path = cookiePath
262
- } else if (attributeNameLowercase === 'secure') {
260
+ // 2. Append an attribute to the cookie-attribute-list with an
261
+ // attribute-name of Path and an attribute-value of cookie-path.
262
+ cookieAttributeList.path = cookiePath
263
+ } else if (attributeNameLowercase === 'secure') {
263
264
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.5
264
265
  // If the attribute-name case-insensitively matches the string "Secure",
265
266
  // the user agent MUST append an attribute to the cookie-attribute-list
266
267
  // with an attribute-name of Secure and an empty attribute-value.
267
268
 
268
- cookieAttributeList.secure = true
269
- } else if (attributeNameLowercase === 'httponly') {
269
+ cookieAttributeList.secure = true
270
+ } else if (attributeNameLowercase === 'httponly') {
270
271
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.6
271
272
  // If the attribute-name case-insensitively matches the string
272
273
  // "HttpOnly", the user agent MUST append an attribute to the cookie-
273
274
  // attribute-list with an attribute-name of HttpOnly and an empty
274
275
  // attribute-value.
275
276
 
276
- cookieAttributeList.httpOnly = true
277
- } else if (attributeNameLowercase === 'samesite') {
277
+ cookieAttributeList.httpOnly = true
278
+ } else if (attributeNameLowercase === 'samesite') {
278
279
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7
279
280
  // If the attribute-name case-insensitively matches the string
280
281
  // "SameSite", the user agent MUST process the cookie-av as follows:
281
282
 
282
- const attributeValueLowercase = attributeValue.toLowerCase()
283
+ const attributeValueLowercase = attributeValue.toLowerCase()
283
284
 
284
- // 1. If cookie-av's attribute-value is a case-insensitive match for
285
- // "None", append an attribute to the cookie-attribute-list with an
286
- // attribute-name of "SameSite" and an attribute-value of "None".
287
- if (attributeValueLowercase === 'none') {
288
- cookieAttributeList.sameSite = 'None'
289
- } else if (attributeValueLowercase === 'strict') {
285
+ // 1. If cookie-av's attribute-value is a case-insensitive match for
286
+ // "None", append an attribute to the cookie-attribute-list with an
287
+ // attribute-name of "SameSite" and an attribute-value of "None".
288
+ if (attributeValueLowercase === 'none') {
289
+ cookieAttributeList.sameSite = 'None'
290
+ } else if (attributeValueLowercase === 'strict') {
290
291
  // 2. If cookie-av's attribute-value is a case-insensitive match for
291
292
  // "Strict", append an attribute to the cookie-attribute-list with
292
293
  // an attribute-name of "SameSite" and an attribute-value of
293
294
  // "Strict".
294
- cookieAttributeList.sameSite = 'Strict'
295
- } else if (attributeValueLowercase === 'lax') {
295
+ cookieAttributeList.sameSite = 'Strict'
296
+ } else if (attributeValueLowercase === 'lax') {
296
297
  // 3. If cookie-av's attribute-value is a case-insensitive match for
297
298
  // "Lax", append an attribute to the cookie-attribute-list with an
298
299
  // attribute-name of "SameSite" and an attribute-value of "Lax".
299
- cookieAttributeList.sameSite = 'Lax'
300
- }
301
- } else {
302
- cookieAttributeList.unparsed ??= []
300
+ cookieAttributeList.sameSite = 'Lax'
301
+ }
302
+ } else {
303
+ cookieAttributeList.unparsed ??= []
303
304
 
304
- cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`)
305
- }
305
+ cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`)
306
+ }
306
307
 
307
308
  // 8. Return to Step 1 of this algorithm.
308
- return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList)
309
+ }
309
310
  }
310
311
 
311
312
  module.exports = {
@@ -315,8 +315,27 @@ function stringify (cookie) {
315
315
  out.push(`Path=${cookie.path}`)
316
316
  }
317
317
 
318
- if (cookie.expires && cookie.expires.toString() !== 'Invalid Date') {
319
- out.push(`Expires=${toIMFDate(cookie.expires)}`)
318
+ // A numeric 0 is the Unix epoch, not an absent value -- the same reason the
319
+ // Max-Age check above tests the type rather than truthiness.
320
+ //
321
+ // expires is Date | number, so the "Invalid Date" check only guards the Date
322
+ // form. A number that is not a valid time value (NaN, +/-Infinity, or outside
323
+ // the Date range) would otherwise reach toIMFDate, which splices undefined and
324
+ // NaN into the header value instead of failing.
325
+ if (cookie.expires != null) {
326
+ const expiresMs = cookie.expires instanceof Date
327
+ ? cookie.expires.getTime()
328
+ : cookie.expires
329
+
330
+ // The largest time value a Date can represent, in milliseconds, i.e.
331
+ // 100,000,000 days either side of the epoch (ECMA-262 Date Time Values).
332
+ // Anything within it is safe for toIMFDate to format; anything beyond it
333
+ // produces an invalid Date and must be ignored instead.
334
+ const maxTimeValue = 8640000000000000
335
+
336
+ if (typeof expiresMs === 'number' && Number.isFinite(expiresMs) && Math.abs(expiresMs) <= maxTimeValue) {
337
+ out.push(`Expires=${toIMFDate(cookie.expires)}`)
338
+ }
320
339
  }
321
340
 
322
341
  if (cookie.sameSite) {
@@ -1,4 +1,5 @@
1
1
  'use strict'
2
+ const buffer = require('node:buffer')
2
3
  const { Transform } = require('node:stream')
3
4
  const { isASCIINumber, isValidLastEventId } = require('./util')
4
5
 
@@ -23,11 +24,19 @@ const COLON = 0x3A
23
24
  */
24
25
  const SPACE = 0x20
25
26
 
27
+ const defaultMaxEventSize = buffer.kStringMaxLength
28
+
26
29
  const DATA = Buffer.from('data')
27
30
  const EVENT = Buffer.from('event')
28
31
  const ID = Buffer.from('id')
29
32
  const RETRY = Buffer.from('retry')
30
33
 
34
+ // Each data field creates a string node, and each unconsumed input chunk keeps
35
+ // a Buffer wrapper. The absolute ceiling protects the large default byte limit.
36
+ const minParserItems = 1024
37
+ const maxParserItems = 131072
38
+ const bytesPerParserItem = 128
39
+
31
40
  function isASCIINumberBytes (buffer, start) {
32
41
  if (start >= buffer.length) {
33
42
  return false
@@ -66,6 +75,12 @@ function isFieldName (line, length, field) {
66
75
  return true
67
76
  }
68
77
 
78
+ function createMaxEventSizeExceededError () {
79
+ const error = new Error('EventSource message size exceeded')
80
+ error.aborted = false
81
+ return error
82
+ }
83
+
69
84
  /**
70
85
  * @typedef {object} EventSourceStreamEvent
71
86
  * @type {object}
@@ -114,6 +129,13 @@ class EventSourceStream extends Transform {
114
129
  pos = 0
115
130
  lineChunkIndex = 0
116
131
  linePos = 0
132
+ lineLength = 0
133
+ eventSize = 0
134
+ eventFieldSizes = { data: 0, event: 0, id: 0, retry: 0 }
135
+ dataLineCount = 0
136
+ maxEventSize
137
+ maxDataLines
138
+ maxBufferedChunks
117
139
 
118
140
  event = {
119
141
  data: undefined,
@@ -125,6 +147,7 @@ class EventSourceStream extends Transform {
125
147
  /**
126
148
  * @param {object} options
127
149
  * @param {boolean} [options.readableObjectMode]
150
+ * @param {number} [options.maxEventSize]
128
151
  * @param {eventSourceSettings} [options.eventSourceSettings]
129
152
  * @param {(chunk: any, encoding?: BufferEncoding | undefined) => boolean} [options.push]
130
153
  */
@@ -136,6 +159,12 @@ class EventSourceStream extends Transform {
136
159
  super(options)
137
160
 
138
161
  this.state = options.eventSourceSettings || {}
162
+ this.maxEventSize = options.maxEventSize ?? defaultMaxEventSize
163
+ const parserItemLimit = this.maxEventSize > 0
164
+ ? Math.min(maxParserItems, Math.max(minParserItems, Math.floor(this.maxEventSize / bytesPerParserItem)))
165
+ : 0
166
+ this.maxDataLines = parserItemLimit
167
+ this.maxBufferedChunks = parserItemLimit
139
168
  if (options.push) {
140
169
  this.push = options.push
141
170
  }
@@ -153,6 +182,14 @@ class EventSourceStream extends Transform {
153
182
  return
154
183
  }
155
184
 
185
+ // A leading line ending can immediately release the currently buffered
186
+ // line, even when it uses the final allowed chunk slot.
187
+ if (this.maxEventSize > 0 &&
188
+ this.chunks.length >= this.maxBufferedChunks &&
189
+ chunk[0] !== LF && chunk[0] !== CR) {
190
+ callback(createMaxEventSizeExceededError())
191
+ return
192
+ }
156
193
  this.chunks.push(chunk)
157
194
 
158
195
  // Strip leading byte-order-mark if we opened the stream and started
@@ -231,7 +268,12 @@ class EventSourceStream extends Transform {
231
268
 
232
269
  // In any case, we can process the line as we reached an
233
270
  // end-of-line character
234
- this.parseLine(this.readLine(), this.event)
271
+ try {
272
+ this.parseLine(this.readLine(), this.event)
273
+ } catch (error) {
274
+ callback(error)
275
+ return
276
+ }
235
277
  this.consumeCurrentByte()
236
278
  // A line was processed and this could be the end of the event. We need
237
279
  // to check if the next line is empty to determine if the event is
@@ -240,12 +282,25 @@ class EventSourceStream extends Transform {
240
282
  continue
241
283
  }
242
284
 
285
+ // readLine() is only called after CR or LF. Reject an unfinished line
286
+ // before it can retain an unbounded number of decoded chunks. The seven
287
+ // extra bytes allow the longest field prefix ("event: " or "retry: ").
288
+ if (this.maxEventSize > 0 && ++this.lineLength > this.maxEventSize + 7) {
289
+ callback(createMaxEventSizeExceededError())
290
+ return
291
+ }
243
292
  this.advanceCursor()
244
293
  }
245
294
 
246
295
  callback()
247
296
  }
248
297
 
298
+ _destroy (error, callback) {
299
+ this.chunks.length = 0
300
+ this.clearEvent()
301
+ super._destroy(error, callback)
302
+ }
303
+
249
304
  /**
250
305
  * @param {Buffer} line
251
306
  * @param {EventSourceStreamEvent} event
@@ -282,6 +337,13 @@ class EventSourceStream extends Transform {
282
337
  }
283
338
 
284
339
  if (isFieldName(line, fieldLength, DATA)) {
340
+ if (this.maxEventSize > 0 && this.dataLineCount >= this.maxDataLines) {
341
+ throw createMaxEventSizeExceededError()
342
+ }
343
+ const valueBytes = line.length - valueStart
344
+ const dataSize = this.eventFieldSizes.data + (event.data === undefined ? 0 : 1) + valueBytes
345
+ this.setEventFieldSize('data', dataSize)
346
+
285
347
  const value = line.toString('utf8', valueStart)
286
348
 
287
349
  if (event.data === undefined) {
@@ -289,11 +351,13 @@ class EventSourceStream extends Transform {
289
351
  } else {
290
352
  event.data += `\n${value}`
291
353
  }
354
+ this.dataLineCount++
292
355
  return
293
356
  }
294
357
 
295
358
  if (isFieldName(line, fieldLength, RETRY)) {
296
359
  if (isASCIINumberBytes(line, valueStart)) {
360
+ this.setEventFieldSize('retry', line.length - valueStart)
297
361
  event.retry = line.toString('utf8', valueStart)
298
362
  }
299
363
  return
@@ -301,20 +365,29 @@ class EventSourceStream extends Transform {
301
365
 
302
366
  if (isFieldName(line, fieldLength, ID)) {
303
367
  if (isValidLastEventIdBytes(line, valueStart)) {
368
+ this.setEventFieldSize('id', line.length - valueStart)
304
369
  event.id = line.toString('utf8', valueStart)
305
370
  }
306
371
  return
307
372
  }
308
373
 
309
374
  if (isFieldName(line, fieldLength, EVENT)) {
310
- const value = line.toString('utf8', valueStart)
311
-
312
- if (value.length > 0) {
313
- event.event = value
375
+ if (valueStart < line.length) {
376
+ this.setEventFieldSize('event', line.length - valueStart)
377
+ event.event = line.toString('utf8', valueStart)
314
378
  }
315
379
  }
316
380
  }
317
381
 
382
+ setEventFieldSize (field, size) {
383
+ const eventSize = this.eventSize - this.eventFieldSizes[field] + size
384
+ if (this.maxEventSize > 0 && eventSize > this.maxEventSize) {
385
+ throw createMaxEventSizeExceededError()
386
+ }
387
+ this.eventSize = eventSize
388
+ this.eventFieldSizes[field] = size
389
+ }
390
+
318
391
  /**
319
392
  * @param {EventSourceStreamEvent} event
320
393
  */
@@ -345,6 +418,12 @@ class EventSourceStream extends Transform {
345
418
  this.event.event = undefined
346
419
  this.event.id = undefined
347
420
  this.event.retry = undefined
421
+ this.eventSize = 0
422
+ this.dataLineCount = 0
423
+ this.eventFieldSizes.data = 0
424
+ this.eventFieldSizes.event = 0
425
+ this.eventFieldSizes.id = 0
426
+ this.eventFieldSizes.retry = 0
348
427
  }
349
428
 
350
429
  hasPendingEvent () {
@@ -380,14 +459,16 @@ class EventSourceStream extends Transform {
380
459
  syncLineStartToCursor () {
381
460
  this.lineChunkIndex = this.chunkIndex
382
461
  this.linePos = this.pos
462
+ this.lineLength = 0
383
463
  this.dropConsumedChunks()
384
464
  }
385
465
 
386
466
  dropConsumedChunks () {
387
- while (this.lineChunkIndex > 0) {
388
- this.chunks.shift()
389
- this.lineChunkIndex--
390
- this.chunkIndex--
467
+ if (this.lineChunkIndex > 0) {
468
+ const consumed = this.lineChunkIndex
469
+ this.chunks.splice(0, consumed)
470
+ this.lineChunkIndex = 0
471
+ this.chunkIndex -= consumed
391
472
  }
392
473
 
393
474
  if (this.chunkIndex === this.chunks.length) {
@@ -10,6 +10,7 @@ const { createFastMessageEvent } = require('../websocket/events')
10
10
  const { isNetworkError } = require('../fetch/response')
11
11
  const { kEnumerableProperty } = require('../../core/util')
12
12
  const { environmentSettingsObject } = require('../fetch/util')
13
+ const { getGlobalDispatcher } = require('../../global')
13
14
 
14
15
  let experimentalWarned = false
15
16
 
@@ -292,6 +293,7 @@ class EventSource extends EventTarget {
292
293
 
293
294
  const eventSourceStream = new EventSourceStream({
294
295
  eventSourceSettings: this.#state,
296
+ maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize,
295
297
  push: (event) => {
296
298
  this.dispatchEvent(createFastMessageEvent(
297
299
  event.type,
@@ -476,7 +478,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([
476
478
  },
477
479
  {
478
480
  key: 'dispatcher', // undici only
479
- converter: webidl.converters.any
481
+ converter: webidl.converters.any,
482
+ defaultValue: () => getGlobalDispatcher()
480
483
  },
481
484
  {
482
485
  key: 'node', // undici only
@@ -2215,8 +2215,6 @@ async function httpNetworkFetch (
2215
2215
  }
2216
2216
  const location = headersList.get('location', true)
2217
2217
 
2218
- this.body = new Readable({ read: resume })
2219
-
2220
2218
  const willFollow = location && request.redirect === 'follow' &&
2221
2219
  redirectStatusSet.has(status)
2222
2220
 
@@ -2234,7 +2232,7 @@ async function httpNetworkFetch (
2234
2232
  // CVE fix similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206).
2235
2233
  const maxContentEncodings = 5
2236
2234
  if (codings.length > maxContentEncodings) {
2237
- reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`))
2235
+ this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`))
2238
2236
  return true
2239
2237
  }
2240
2238
 
@@ -2272,6 +2270,8 @@ async function httpNetworkFetch (
2272
2270
  }
2273
2271
  }
2274
2272
 
2273
+ this.body = new Readable({ read: resume })
2274
+
2275
2275
  const onError = this.onError.bind(this)
2276
2276
 
2277
2277
  resolve({
@@ -52,6 +52,19 @@ function collectASequenceOfCodePointsFast (char, input, position) {
52
52
  }
53
53
 
54
54
  const ASCII_WHITESPACE_REPLACE_REGEX = /[\u0009\u000A\u000C\u000D\u0020]/g // eslint-disable-line no-control-regex
55
+ const nonASCIIRegex = /[^\x00-\x7F]/ // eslint-disable-line no-control-regex
56
+
57
+ /**
58
+ * @param {string} str
59
+ * @returns {string}
60
+ *
61
+ * @see https://infra.spec.whatwg.org/#ascii-lowercase
62
+ */
63
+ function asciiLowercase (str) {
64
+ return nonASCIIRegex.test(str)
65
+ ? str.replace(/[A-Z]+/g, (upper) => upper.toLowerCase())
66
+ : str.toLowerCase()
67
+ }
55
68
 
56
69
  /**
57
70
  * @param {string} data
@@ -216,6 +229,7 @@ function serializeJavascriptValueToJSONString (value) {
216
229
  }
217
230
 
218
231
  module.exports = {
232
+ asciiLowercase,
219
233
  collectASequenceOfCodePoints,
220
234
  collectASequenceOfCodePointsFast,
221
235
  forgivingBase64,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const assert = require('node:assert')
4
4
  const { runtimeFeatures } = require('../../util/runtime-features.js')
5
+ const { asciiLowercase } = require('../infra/index.js')
5
6
 
6
7
  /**
7
8
  * @typedef {object} Metadata
@@ -66,14 +67,12 @@ const getSRIHashAlgorithmIndex = /** @type {GetSRIHashAlgorithmIndex} */ (Map.pr
66
67
 
67
68
  /**
68
69
  * @typedef IsValidSRIHashAlgorithm
70
+ * @see https://w3c.github.io/webappsec-subresource-integrity/#valid-sri-hash-algorithm-token
69
71
  * @type {(algorithm: string) => algorithm is SRIHashAlgorithm}
70
72
  * @param {*} algorithm
71
73
  * @returns {algorithm is SRIHashAlgorithm}
72
74
  */
73
-
74
- const isValidSRIHashAlgorithm = /** @type {IsValidSRIHashAlgorithm} */ (
75
- Map.prototype.has.bind(validSRIHashAlgorithmTokenSet)
76
- )
75
+ const isValidSRIHashAlgorithm = (algorithm) => validSRIHashAlgorithmTokenSet.has(asciiLowercase(algorithm))
77
76
 
78
77
  /**
79
78
  * @param {Uint8Array} bytes
@@ -229,7 +228,8 @@ function parseMetadata (metadata) {
229
228
  // 8. Let metadata be the ordered map
230
229
  // «["alg" → algorithm, "val" → base64-value]».
231
230
  const metadata = {
232
- alg: algorithm,
231
+ // Possible spec bug?
232
+ alg: asciiLowercase(algorithm),
233
233
  val: base64Value
234
234
  }
235
235
 
@@ -244,6 +244,19 @@ class ByteParser extends Writable {
244
244
  if (isControlFrame(this.#info.opcode)) {
245
245
  this.#loop = this.parseControlFrame(body)
246
246
  this.#state = parserStates.INFO
247
+
248
+ // If the automatic PONG/CLOSE reply filled the socket's write buffer,
249
+ // stop parsing and resume on drain so a PING flood cannot grow the
250
+ // outbound queue without bound.
251
+ if (this.#loop && this.#handler.socket.writableNeedDrain) {
252
+ this.#loop = false
253
+ this.#handler.socket.once('drain', () => {
254
+ if (this.destroyed) return
255
+ this.#loop = true
256
+ this.run(callback)
257
+ })
258
+ return
259
+ }
247
260
  } else {
248
261
  if (!this.#info.compressed) {
249
262
  if (!this.writeFragments(body)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.30.0",
3
+ "version": "7.30.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/client.d.ts CHANGED
@@ -109,6 +109,8 @@ export declare namespace Client {
109
109
  pingInterval?: number;
110
110
  /** WebSocket-specific configuration options. */
111
111
  webSocket?: WebSocketOptions;
112
+ /** EventSource-specific configuration options. */
113
+ eventSource?: EventSourceOptions;
112
114
  }
113
115
  export interface SocketInfo {
114
116
  localAddress?: string
@@ -134,6 +136,18 @@ export declare namespace Client {
134
136
  */
135
137
  maxPayloadSize?: number;
136
138
  }
139
+ export interface EventSourceOptions {
140
+ /**
141
+ * Maximum total bytes of data and metadata field values retained for a
142
+ * pending EventSource event. Each unfinished line is separately bounded
143
+ * to this value plus seven syntax bytes. The number of data lines per
144
+ * event and buffered input chunks are each limited to
145
+ * min(131072, max(1024, floor(maxEventSize / 128))).
146
+ * Set to 0 to disable these limits.
147
+ * @default buffer.kStringMaxLength
148
+ */
149
+ maxEventSize?: number;
150
+ }
137
151
  }
138
152
 
139
153
  export default Client