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.
- package/docs/docs/api/Client.md +2 -0
- package/lib/core/connect.js +10 -4
- package/lib/core/symbols.js +1 -0
- package/lib/dispatcher/agent.js +6 -2
- package/lib/dispatcher/client-h1.js +0 -2
- package/lib/dispatcher/client-h2.js +0 -4
- package/lib/dispatcher/client.js +3 -2
- package/lib/dispatcher/dispatcher-base.js +12 -0
- package/lib/handler/cache-handler.js +48 -0
- package/lib/handler/deduplication-handler.js +57 -13
- package/lib/handler/redirect-handler.js +8 -1
- package/lib/interceptor/decompress.js +15 -5
- package/lib/web/cookies/index.js +3 -1
- package/lib/web/cookies/parse.js +138 -137
- package/lib/web/cookies/util.js +21 -2
- package/lib/web/eventsource/eventsource-stream.js +90 -9
- package/lib/web/eventsource/eventsource.js +4 -1
- package/lib/web/fetch/index.js +3 -3
- package/lib/web/infra/index.js +14 -0
- package/lib/web/subresource-integrity/subresource-integrity.js +5 -5
- package/lib/web/websocket/receiver.js +13 -0
- package/package.json +1 -1
- package/types/client.d.ts +14 -0
package/docs/docs/api/Client.md
CHANGED
|
@@ -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.
|
package/lib/core/connect.js
CHANGED
|
@@ -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
|
-
|
|
50
|
+
evicted = true
|
|
51
|
+
break
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
package/lib/core/symbols.js
CHANGED
package/lib/dispatcher/agent.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/lib/dispatcher/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
if (waitingController.aborted) {
|
|
136
|
+
waitingHandler.done = true
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
121
139
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
this.#controller.
|
|
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
|
-
|
|
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 () {
|
package/lib/web/cookies/index.js
CHANGED
|
@@ -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
|
package/lib/web/cookies/parse.js
CHANGED
|
@@ -92,220 +92,221 @@ function parseSetCookie (header) {
|
|
|
92
92
|
* @param {Object.<string, unknown>} [cookieAttributeList={}]
|
|
93
93
|
*/
|
|
94
94
|
function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
+
let cookieAv = ''
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
cookieAv = collectASequenceOfCodePointsFast(
|
|
115
|
+
';',
|
|
116
|
+
unparsedAttributes,
|
|
117
|
+
{ position: 0 }
|
|
118
|
+
)
|
|
119
|
+
unparsedAttributes = unparsedAttributes.slice(cookieAv.length)
|
|
120
|
+
} else {
|
|
120
121
|
// Otherwise:
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// 1. Consume the remainder of the unparsed-attributes.
|
|
124
|
+
cookieAv = unparsedAttributes
|
|
125
|
+
unparsedAttributes = ''
|
|
126
|
+
}
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
// Let the cookie-av string be the characters consumed in this step.
|
|
128
129
|
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
let attributeName = ''
|
|
131
|
+
let attributeValue = ''
|
|
131
132
|
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
+
const expiryTime = new Date(attributeValue)
|
|
178
179
|
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
// 2. If the attribute-value failed to parse as a cookie date, ignore
|
|
181
|
+
// the cookie-av.
|
|
181
182
|
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') {
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
195
196
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
203
|
+
// 3. Let delta-seconds be the attribute-value converted to an integer.
|
|
204
|
+
const deltaSeconds = Number(attributeValue)
|
|
204
205
|
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
227
|
-
|
|
227
|
+
// 1. Let cookie-domain be the attribute-value.
|
|
228
|
+
let cookieDomain = attributeValue
|
|
228
229
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
236
|
+
// 3. Convert the cookie-domain to lower case.
|
|
237
|
+
cookieDomain = cookieDomain.toLowerCase()
|
|
237
238
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
252
|
+
cookiePath = '/'
|
|
253
|
+
} else {
|
|
253
254
|
// Otherwise:
|
|
254
255
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
256
|
+
// 1. Let cookie-path be the attribute-value.
|
|
257
|
+
cookiePath = attributeValue
|
|
258
|
+
}
|
|
258
259
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
283
|
+
const attributeValueLowercase = attributeValue.toLowerCase()
|
|
283
284
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
cookieAttributeList.sameSite = 'Lax'
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
cookieAttributeList.unparsed ??= []
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`)
|
|
306
|
+
}
|
|
306
307
|
|
|
307
308
|
// 8. Return to Step 1 of this algorithm.
|
|
308
|
-
|
|
309
|
+
}
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
module.exports = {
|
package/lib/web/cookies/util.js
CHANGED
|
@@ -315,8 +315,27 @@ function stringify (cookie) {
|
|
|
315
315
|
out.push(`Path=${cookie.path}`)
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
388
|
-
this.
|
|
389
|
-
this.
|
|
390
|
-
this.
|
|
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
|
package/lib/web/fetch/index.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
package/lib/web/infra/index.js
CHANGED
|
@@ -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
|
-
|
|
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
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
|