@nxtedition/nxt-undici 7.4.1 → 7.4.2
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/lib/index.d.ts +36 -8
- package/lib/index.js +1 -0
- package/lib/interceptor/cache.js +79 -40
- package/lib/interceptor/dns.js +73 -6
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +11 -0
- package/lib/interceptor/proxy.js +42 -14
- package/lib/interceptor/redirect.js +35 -5
- package/lib/interceptor/request-body-factory.js +59 -8
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +86 -9
- package/lib/utils.js +38 -15
- package/package.json +1 -1
|
@@ -326,6 +326,17 @@ class Handler extends DecoratorHandler {
|
|
|
326
326
|
return super.onHeaders(statusCode, headers, resume)
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
onUpgrade(statusCode, headers, socket) {
|
|
330
|
+
// A successful upgrade (HTTP 101) is its own terminal branch — neither
|
|
331
|
+
// onComplete nor onError follows it (see the dns interceptor's Handler for
|
|
332
|
+
// the same fix). Settle as a success here, otherwise `running` stays >= 1
|
|
333
|
+
// forever: the per-origin record can never satisfy the eviction condition
|
|
334
|
+
// and the sampling timer keeps firing for the process lifetime. An upgrade
|
|
335
|
+
// is a success, not an overload error.
|
|
336
|
+
this.#settle(false)
|
|
337
|
+
super.onUpgrade(statusCode, headers, socket)
|
|
338
|
+
}
|
|
339
|
+
|
|
329
340
|
onComplete(trailers) {
|
|
330
341
|
this.#settle(isErrorStatus(this.#statusCode))
|
|
331
342
|
super.onComplete(trailers)
|
package/lib/interceptor/proxy.js
CHANGED
|
@@ -2,13 +2,15 @@ import net from 'node:net'
|
|
|
2
2
|
import createError from 'http-errors'
|
|
3
3
|
import { DecoratorHandler } from '../utils.js'
|
|
4
4
|
|
|
5
|
+
function noop() {}
|
|
6
|
+
|
|
5
7
|
// Accumulator used by reduceHeaders on the response path. Hoisted to module
|
|
6
8
|
// scope so it is allocated once rather than on every onHeaders/onUpgrade call.
|
|
7
9
|
/**
|
|
8
|
-
* @param {Record<string, string>} acc
|
|
10
|
+
* @param {Record<string, string | string[]>} acc
|
|
9
11
|
* @param {string} key
|
|
10
|
-
* @param {string} val
|
|
11
|
-
* @returns {Record<string, string>}
|
|
12
|
+
* @param {string | string[]} val
|
|
13
|
+
* @returns {Record<string, string | string[]>}
|
|
12
14
|
*/
|
|
13
15
|
const copyHeader = (acc, key, val) => {
|
|
14
16
|
acc[key] = val
|
|
@@ -25,9 +27,9 @@ class Handler extends DecoratorHandler {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
onUpgrade(statusCode, headers, socket) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
reduceHeaders(
|
|
30
|
+
let reduced
|
|
31
|
+
try {
|
|
32
|
+
reduced = reduceHeaders(
|
|
31
33
|
{
|
|
32
34
|
headers,
|
|
33
35
|
httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
|
|
@@ -39,9 +41,21 @@ class Handler extends DecoratorHandler {
|
|
|
39
41
|
},
|
|
40
42
|
copyHeader,
|
|
41
43
|
{},
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// reduceHeaders throws on protocol errors (inbound Forwarded on a
|
|
47
|
+
// response → BadGateway, looping Via → LoopDetected). A throw must not
|
|
48
|
+
// escape onUpgrade: undici's H1 upgrade path nulls the request's queue
|
|
49
|
+
// slot before invoking onUpgrade and its catch only destroys the socket
|
|
50
|
+
// — no onError ever reaches the handler chain, leaving the caller
|
|
51
|
+
// waiting forever. Deliver the terminal error downstream ourselves
|
|
52
|
+
// (super.onError is once-guarded) and destroy the socket so it is not
|
|
53
|
+
// leaked; noop error listener since nothing downstream ever receives it.
|
|
54
|
+
socket.on('error', noop).destroy(err)
|
|
55
|
+
super.onError(err)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
super.onUpgrade(statusCode, reduced, socket)
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
onHeaders(statusCode, headers, resume) {
|
|
@@ -148,8 +162,10 @@ function isHopByHop(key) {
|
|
|
148
162
|
* @param {boolean} [options.isResponse] Response path marker. When set,
|
|
149
163
|
* Forwarded is never synthesised regardless of `socket` (it would leak the
|
|
150
164
|
* proxy's own addresses downstream); an inbound Forwarded is still rejected.
|
|
151
|
-
* @param {(acc: T, key: string, value: string) => T} fn Accumulator
|
|
152
|
-
* once per retained header.
|
|
165
|
+
* @param {(acc: T, key: string, value: string | string[]) => T} fn Accumulator
|
|
166
|
+
* invoked once per retained header. The value is an array when the field
|
|
167
|
+
* appeared more than once (parseHeaders shape) — never pre-joined, because
|
|
168
|
+
* fields like set-cookie (RFC 6265) must keep distinct field lines.
|
|
153
169
|
* @param {T} acc Initial accumulator value.
|
|
154
170
|
* @returns {T}
|
|
155
171
|
*/
|
|
@@ -262,7 +278,16 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket, isResponse },
|
|
|
262
278
|
(remove === null || !remove.includes(key.toLowerCase())) &&
|
|
263
279
|
!isHopByHop(key)
|
|
264
280
|
) {
|
|
265
|
-
|
|
281
|
+
// A repeated field-line arrives as an array (parseHeaders collects
|
|
282
|
+
// them). Pass it through as-is — .toString() would comma-join with no
|
|
283
|
+
// space, corrupting fields whose values legally contain commas and must
|
|
284
|
+
// never be combined: set-cookie (RFC 9110 §5.3 / RFC 6265, e.g. an
|
|
285
|
+
// Expires date) and multi-challenge www-authenticate. Array values are
|
|
286
|
+
// exactly the shape parseHeaders delivers without this interceptor, and
|
|
287
|
+
// both the request path (undici accepts array header values) and the
|
|
288
|
+
// response handler chain already relay it.
|
|
289
|
+
const value = headers[key]
|
|
290
|
+
acc = fn(acc, key, Array.isArray(value) ? value : value.toString())
|
|
266
291
|
}
|
|
267
292
|
}
|
|
268
293
|
|
|
@@ -358,7 +383,10 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
358
383
|
proxyName: opts.proxy.name,
|
|
359
384
|
},
|
|
360
385
|
(obj, key, val) => {
|
|
361
|
-
|
|
386
|
+
// Case-insensitive (eqiLower) like reduceHeaders' capture above: the
|
|
387
|
+
// standalone interceptors.proxy() composition may pass mixed-case keys
|
|
388
|
+
// (the production path lowercases via parseHeaders first).
|
|
389
|
+
if (!expectsPayload && key.length === 14 && eqiLower(key, 'content-length')) {
|
|
362
390
|
// https://tools.ietf.org/html/rfc7230#section-3.3.2
|
|
363
391
|
// A user agent SHOULD NOT send a Content-Length header field when
|
|
364
392
|
// the request message does not contain a payload body and the method
|
|
@@ -366,7 +394,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
366
394
|
// undici will error if provided an unexpected content-length: 0 header.
|
|
367
395
|
} else if (key[0] === ':') {
|
|
368
396
|
// strip pseudo headers
|
|
369
|
-
} else if (key === 'expect') {
|
|
397
|
+
} else if (key.length === 6 && eqiLower(key, 'expect')) {
|
|
370
398
|
// undici doesn't support expect header.
|
|
371
399
|
} else {
|
|
372
400
|
obj[key] = val
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
|
-
import { DecoratorHandler, isDisturbed, parseURL, parseHeaders } from '../utils.js'
|
|
2
|
+
import { DecoratorHandler, isDisturbed, parseURL, parseHeaders, buildURL } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
|
|
5
5
|
|
|
@@ -105,9 +105,15 @@ class Handler extends DecoratorHandler {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
// Build the base URL by concatenating origin + path via buildURL rather
|
|
109
|
+
// than `new URL(path, origin)`: the latter is unsafe when `path` is
|
|
110
|
+
// protocol-relative (e.g. `//evil-host/x`, reachable via a request URL like
|
|
111
|
+
// `https://good.com//evil-host/x`). WHATWG URL would then treat the path's
|
|
112
|
+
// leading host as the authority and discard the good origin, so a *relative*
|
|
113
|
+
// Location would resolve against the attacker-controlled host — an SSRF /
|
|
114
|
+
// request-misrouting pivot. See buildURL in ../utils.js.
|
|
115
|
+
const base = this.#opts.origin && buildURL(this.#opts.origin, this.#opts.path)
|
|
116
|
+
const { origin, pathname, search } = parseURL(new URL(this.#location, base))
|
|
111
117
|
const path = search ? `${pathname}${search}` : pathname
|
|
112
118
|
|
|
113
119
|
// Remove headers referring to the original URL.
|
|
@@ -118,7 +124,7 @@ class Handler extends DecoratorHandler {
|
|
|
118
124
|
headers: cleanRequestHeaders(
|
|
119
125
|
this.#opts.headers,
|
|
120
126
|
statusCode === 303,
|
|
121
|
-
this.#opts.origin
|
|
127
|
+
!isSameOrigin(this.#opts.origin, origin),
|
|
122
128
|
),
|
|
123
129
|
path,
|
|
124
130
|
origin,
|
|
@@ -191,6 +197,27 @@ class Handler extends DecoratorHandler {
|
|
|
191
197
|
}
|
|
192
198
|
}
|
|
193
199
|
|
|
200
|
+
// `target` is always a WHATWG-normalized origin (it comes off a parsed URL:
|
|
201
|
+
// lowercased host, default port elided, no path), while `optsOrigin` is
|
|
202
|
+
// caller-provided and may not be (trailing slash, explicit `:80`/`:443`,
|
|
203
|
+
// uppercase host — defaultLookup in index.js even produces `http://host:80`
|
|
204
|
+
// for object-form origins). A raw string compare misclassifies such
|
|
205
|
+
// same-origin redirects as cross-origin and strips authorization/cookie,
|
|
206
|
+
// breaking authenticated redirect flows with a confusing 401. Normalize
|
|
207
|
+
// through `new URL` before comparing; if optsOrigin is not parseable, keep
|
|
208
|
+
// the raw-compare result (already false here), which fails toward stripping —
|
|
209
|
+
// the safe direction.
|
|
210
|
+
function isSameOrigin(optsOrigin, target) {
|
|
211
|
+
if (optsOrigin === target) {
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
return new URL(optsOrigin).origin === target
|
|
216
|
+
} catch {
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
194
221
|
// https://tools.ietf.org/html/rfc7231#section-6.4.4
|
|
195
222
|
function shouldRemoveHeader(header, removeContent, unknownOrigin) {
|
|
196
223
|
return (
|
|
@@ -199,6 +226,9 @@ function shouldRemoveHeader(header, removeContent, unknownOrigin) {
|
|
|
199
226
|
(unknownOrigin &&
|
|
200
227
|
header.length === 13 &&
|
|
201
228
|
header.toString().toLowerCase() === 'authorization') ||
|
|
229
|
+
(unknownOrigin &&
|
|
230
|
+
header.length === 19 &&
|
|
231
|
+
header.toString().toLowerCase() === 'proxy-authorization') ||
|
|
202
232
|
(unknownOrigin && header.length === 6 && header.toString().toLowerCase() === 'cookie')
|
|
203
233
|
)
|
|
204
234
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Readable, finished } from 'node:stream'
|
|
2
|
-
import { isStream } from '../utils.js'
|
|
2
|
+
import { DecoratorHandler, isStream } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
function noop() {}
|
|
5
5
|
|
|
@@ -15,11 +15,26 @@ class FactoryStream extends Readable {
|
|
|
15
15
|
|
|
16
16
|
_construct(callback) {
|
|
17
17
|
this.#ac = new AbortController()
|
|
18
|
+
// Note: #ac is intentionally kept after the factory settles, so that a
|
|
19
|
+
// destroy with an error can still abort the factory's signal (see
|
|
20
|
+
// _destroy) — e.g. the request failing before undici started writing
|
|
21
|
+
// the body.
|
|
18
22
|
Promise.resolve(this.#factory({ signal: this.#ac.signal })).then(
|
|
19
23
|
(body) => {
|
|
20
|
-
this.#ac = null
|
|
21
24
|
try {
|
|
22
|
-
|
|
25
|
+
// Normalize binary bodies to Buffer (zero-copy: reinterpret the same
|
|
26
|
+
// memory). Without this a TypedArray/DataView falls through to
|
|
27
|
+
// Readable.from(), which iterates e.g. a Uint8Array element-wise and
|
|
28
|
+
// push(number) then throws an uncaught ERR_INVALID_ARG_TYPE inside
|
|
29
|
+
// the 'data' emit. Mirrors utils.js isBuffer() treating Uint8Array
|
|
30
|
+
// as a buffer, extended to all ArrayBuffer views.
|
|
31
|
+
if (ArrayBuffer.isView(body) && !Buffer.isBuffer(body)) {
|
|
32
|
+
body = Buffer.from(body.buffer, body.byteOffset, body.byteLength)
|
|
33
|
+
} else if (body instanceof ArrayBuffer) {
|
|
34
|
+
body = Buffer.from(body)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof body === 'string' || Buffer.isBuffer(body)) {
|
|
23
38
|
this.push(body)
|
|
24
39
|
this.push(null)
|
|
25
40
|
} else if (isStream(body)) {
|
|
@@ -67,7 +82,13 @@ class FactoryStream extends Readable {
|
|
|
67
82
|
|
|
68
83
|
_destroy(err, callback) {
|
|
69
84
|
if (this.#ac) {
|
|
70
|
-
|
|
85
|
+
// Abort the factory's signal on any error, and on a premature destroy
|
|
86
|
+
// (destroyed before 'end'), so the factory can cancel whatever it is
|
|
87
|
+
// producing. A clean destroy after a fully consumed body (autoDestroy
|
|
88
|
+
// after 'end') must not abort — the factory finished normally.
|
|
89
|
+
if (err || !this.readableEnded) {
|
|
90
|
+
this.#ac.abort(err)
|
|
91
|
+
}
|
|
71
92
|
this.#ac = null
|
|
72
93
|
}
|
|
73
94
|
|
|
@@ -80,7 +101,37 @@ class FactoryStream extends Readable {
|
|
|
80
101
|
}
|
|
81
102
|
}
|
|
82
103
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
class Handler extends DecoratorHandler {
|
|
105
|
+
#body
|
|
106
|
+
|
|
107
|
+
constructor(handler, body) {
|
|
108
|
+
super(handler)
|
|
109
|
+
this.#body = body
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onError(err) {
|
|
113
|
+
// Undici only destroys a request body once it has started writing it. If
|
|
114
|
+
// the dispatch fails before that (connect error/timeout, DNS failure,
|
|
115
|
+
// abort while queued, sync throw from an inner interceptor), nobody else
|
|
116
|
+
// owns the FactoryStream — but the factory has already run on nextTick
|
|
117
|
+
// (side effects, e.g. an open fd from fs.createReadStream), so destroy it
|
|
118
|
+
// here. destroy() is idempotent: if undici already consumed or destroyed
|
|
119
|
+
// the stream this is a no-op.
|
|
120
|
+
this.#body.destroy(err)
|
|
121
|
+
super.onError(err)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default () => (dispatch) => (opts, handler) => {
|
|
126
|
+
if (typeof opts.body !== 'function') {
|
|
127
|
+
return dispatch(opts, handler)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const body = new FactoryStream(opts.body).on('error', noop)
|
|
131
|
+
try {
|
|
132
|
+
return dispatch({ ...opts, body }, new Handler(handler, body))
|
|
133
|
+
} catch (err) {
|
|
134
|
+
body.destroy(err)
|
|
135
|
+
throw err
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -10,6 +10,7 @@ class Handler extends DecoratorHandler {
|
|
|
10
10
|
#body = ''
|
|
11
11
|
#bodySize = 0
|
|
12
12
|
#opts
|
|
13
|
+
#reason = null
|
|
13
14
|
|
|
14
15
|
constructor(opts, { handler }) {
|
|
15
16
|
super(handler)
|
|
@@ -23,8 +24,14 @@ class Handler extends DecoratorHandler {
|
|
|
23
24
|
this.#headers = null
|
|
24
25
|
this.#body = ''
|
|
25
26
|
this.#bodySize = 0
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
this.#reason = null
|
|
28
|
+
|
|
29
|
+
super.onConnect((reason) => {
|
|
30
|
+
// Remember the abort reason so onError can recognize it. The reason is
|
|
31
|
+
// owned by the caller (e.g. signal.reason) and must not be decorated.
|
|
32
|
+
this.#reason = reason ?? null
|
|
33
|
+
abort(reason)
|
|
34
|
+
})
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
onHeaders(statusCode, headers, resume) {
|
|
@@ -81,6 +88,16 @@ class Handler extends DecoratorHandler {
|
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
onError(err) {
|
|
91
|
+
// An abort reason is owned by the caller and may be shared by every
|
|
92
|
+
// request in flight on the same signal — decorating it in place would
|
|
93
|
+
// permanently mutate the caller's object and leak one request's req/res
|
|
94
|
+
// onto another request's rejection (last writer wins). Callers also rely
|
|
95
|
+
// on receiving the exact reason object, so pass it through untouched.
|
|
96
|
+
if (err != null && (err === this.#reason || err === this.#opts.signal?.reason)) {
|
|
97
|
+
super.onError(err)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
super.onError(
|
|
85
102
|
decorateError(err, this.#opts, {
|
|
86
103
|
statusCode: this.#statusCode || undefined,
|