@nxtedition/nxt-undici 7.4.1 → 7.4.3
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 +51 -8
- package/lib/index.js +1 -0
- package/lib/interceptor/cache.js +479 -108
- 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 +46 -15
- 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 +228 -25
- package/lib/utils.js +320 -17
- package/package.json +1 -2
package/lib/interceptor/dns.js
CHANGED
|
@@ -44,6 +44,27 @@ class Handler extends DecoratorHandler {
|
|
|
44
44
|
|
|
45
45
|
const SWEEP_INTERVAL = 30e3
|
|
46
46
|
|
|
47
|
+
// A cached (or in-flight-shared) lookup error must never be handed to more
|
|
48
|
+
// than one caller as-is: downstream decorateError call sites (response-retry,
|
|
49
|
+
// response-error) mutate the error they receive (err.req, err.res,
|
|
50
|
+
// err.statusCode), so a shared object would leak one request's decoration
|
|
51
|
+
// into another's. Give each caller a fresh Error carrying the identifying
|
|
52
|
+
// dns fields, with the original attached as `cause`.
|
|
53
|
+
function makeLookupError(err) {
|
|
54
|
+
const wrapped = new Error(err.message, { cause: err })
|
|
55
|
+
wrapped.code = err.code
|
|
56
|
+
if (err.errno !== undefined) {
|
|
57
|
+
wrapped.errno = err.errno
|
|
58
|
+
}
|
|
59
|
+
if (err.syscall !== undefined) {
|
|
60
|
+
wrapped.syscall = err.syscall
|
|
61
|
+
}
|
|
62
|
+
if (err.hostname !== undefined) {
|
|
63
|
+
wrapped.hostname = err.hostname
|
|
64
|
+
}
|
|
65
|
+
return wrapped
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
// Error codes that mean the IP itself is unreachable/bad, so the selected
|
|
48
69
|
// record should be invalidated immediately (expires = 0), forcing a re-resolve.
|
|
49
70
|
// Anything else surfaced on the response path — a headers/body timeout, a
|
|
@@ -67,12 +88,16 @@ const CONNECTION_ERROR_CODES = new Set([
|
|
|
67
88
|
|
|
68
89
|
export default () => (dispatch) => {
|
|
69
90
|
const cache = new Map()
|
|
91
|
+
const negatives = new Map()
|
|
70
92
|
const promises = new Map()
|
|
71
93
|
|
|
72
94
|
// The `cache` Map is otherwise only ever written, never trimmed, so a process
|
|
73
95
|
// touching many distinct hostnames over its lifetime would leak entries that
|
|
74
96
|
// can never be selected again. Sweep dead entries (all records expired and
|
|
75
97
|
// none in flight) at most once per SWEEP_INTERVAL to bound the O(n) cost.
|
|
98
|
+
// Negative entries are swept on the same cadence (they are also deleted
|
|
99
|
+
// eagerly on the next successful lookup / overwritten on the next failure,
|
|
100
|
+
// so the sweep only matters for hostnames that are never touched again).
|
|
76
101
|
let lastSweep = 0
|
|
77
102
|
function sweep(now) {
|
|
78
103
|
if (now - lastSweep < SWEEP_INTERVAL) {
|
|
@@ -84,18 +109,33 @@ export default () => (dispatch) => {
|
|
|
84
109
|
cache.delete(hostname)
|
|
85
110
|
}
|
|
86
111
|
}
|
|
112
|
+
for (const [hostname, negative] of negatives) {
|
|
113
|
+
if (negative.expires < now) {
|
|
114
|
+
negatives.delete(hostname)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
87
117
|
}
|
|
88
118
|
|
|
89
|
-
function resolve(hostname, { ttl }) {
|
|
119
|
+
function resolve(hostname, { ttl, negativeTTL, lookup }) {
|
|
90
120
|
let promise = promises.get(hostname)
|
|
91
121
|
if (!promise) {
|
|
92
122
|
promise = new Promise((resolve) => {
|
|
93
|
-
|
|
123
|
+
lookup(hostname, { all: true }, (err, records) => {
|
|
94
124
|
promises.delete(hostname)
|
|
95
125
|
|
|
96
126
|
if (err) {
|
|
127
|
+
// Negative cache: remember the failure for a short while so a hot
|
|
128
|
+
// caller of an unresolvable host fails fast instead of issuing a
|
|
129
|
+
// lookup storm (response-retry retries ENOTFOUND/EAI_AGAIN up to
|
|
130
|
+
// `retry` (default 8) times, so without this every logical
|
|
131
|
+
// request produced ~9 lookups). EAI_AGAIN is transient by
|
|
132
|
+
// definition, but the same small TTL applies — the window only
|
|
133
|
+
// needs to absorb a retry burst, and a short TTL keeps recovery
|
|
134
|
+
// fast either way.
|
|
135
|
+
negatives.set(hostname, { err, expires: getFastNow() + negativeTTL })
|
|
97
136
|
resolve([err, null])
|
|
98
137
|
} else {
|
|
138
|
+
negatives.delete(hostname)
|
|
99
139
|
const now = getFastNow()
|
|
100
140
|
const val = records.map(({ address }) => {
|
|
101
141
|
return {
|
|
@@ -125,6 +165,8 @@ export default () => (dispatch) => {
|
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
const ttl = opts.dns.ttl ?? 2e3
|
|
168
|
+
const negativeTTL = opts.dns.negativeTTL ?? 1e3
|
|
169
|
+
const lookup = opts.dns.lookup ?? dns.lookup
|
|
128
170
|
const url = new URL(opts.path ?? '', opts.origin)
|
|
129
171
|
const balance = opts.dns.balance
|
|
130
172
|
|
|
@@ -141,9 +183,19 @@ export default () => (dispatch) => {
|
|
|
141
183
|
let records = cache.get(hostname)
|
|
142
184
|
|
|
143
185
|
if (records == null || records.every((x) => x.expires < now)) {
|
|
144
|
-
|
|
186
|
+
// A fresh negative entry means a lookup for this hostname failed less
|
|
187
|
+
// than negativeTTL ago — fail fast instead of hitting the resolver
|
|
188
|
+
// again. Fresh positive records (checked above) take precedence, so a
|
|
189
|
+
// failed pre-emptive refresh never fails requests that can still be
|
|
190
|
+
// served from cache.
|
|
191
|
+
const negative = negatives.get(hostname)
|
|
192
|
+
if (negative != null && negative.expires >= now) {
|
|
193
|
+
throw makeLookupError(negative.err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const [err, val] = await resolve(hostname, { ttl, negativeTTL, lookup })
|
|
145
197
|
if (err) {
|
|
146
|
-
throw err
|
|
198
|
+
throw makeLookupError(err)
|
|
147
199
|
}
|
|
148
200
|
records = val
|
|
149
201
|
}
|
|
@@ -190,7 +242,7 @@ export default () => (dispatch) => {
|
|
|
190
242
|
// refreshed records land in cache for the next request, smoothing
|
|
191
243
|
// out DNS lookup latency. `resolve()` dedupes via `promises`.
|
|
192
244
|
if (records.some((x) => x.expires < now + ttl / 2)) {
|
|
193
|
-
resolve(hostname, { ttl })
|
|
245
|
+
resolve(hostname, { ttl, negativeTTL, lookup })
|
|
194
246
|
}
|
|
195
247
|
|
|
196
248
|
url.hostname = net.isIPv6(record.address) ? `[${record.address}]` : record.address
|
|
@@ -224,8 +276,23 @@ export default () => (dispatch) => {
|
|
|
224
276
|
}
|
|
225
277
|
|
|
226
278
|
try {
|
|
279
|
+
// origin is rewritten to the resolved IP, so pin the host header to
|
|
280
|
+
// the logical hostname — but never clobber an explicit user-supplied
|
|
281
|
+
// host (virtual hosting). Lowercase lookup matches the pipeline
|
|
282
|
+
// invariant (parseHeaders) and priority.js, which reads the same key.
|
|
283
|
+
// Host is a singular header, so only a single non-empty string value
|
|
284
|
+
// is preserved (same rule as priority.js) — an array (duplicate Host
|
|
285
|
+
// field-lines) or an empty string falls back to the origin-derived
|
|
286
|
+
// host.
|
|
227
287
|
return dispatch(
|
|
228
|
-
{
|
|
288
|
+
{
|
|
289
|
+
...opts,
|
|
290
|
+
origin: url.origin,
|
|
291
|
+
headers: {
|
|
292
|
+
...opts.headers,
|
|
293
|
+
host: (typeof opts.headers?.host === 'string' && opts.headers.host) || host,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
229
296
|
new Handler(handler, onSettle),
|
|
230
297
|
)
|
|
231
298
|
} catch (err) {
|
package/lib/interceptor/log.js
CHANGED
|
@@ -1,10 +1,144 @@
|
|
|
1
|
-
import { DecoratorHandler } from '../utils.js'
|
|
1
|
+
import { DecoratorHandler, parseHeaders } from '../utils.js'
|
|
2
2
|
|
|
3
3
|
const kGlobalIndex = Symbol.for('@nxtedition/nxt-undici#globalIndex')
|
|
4
4
|
const kGlobalArray = Symbol.for('@nxtedition/nxt-undici#globalArray')
|
|
5
5
|
|
|
6
|
+
const REDACTED = '[redacted]'
|
|
7
|
+
|
|
8
|
+
// Header names (lowercase) whose values must never reach the logs.
|
|
9
|
+
const SECRET_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'set-cookie'])
|
|
10
|
+
|
|
11
|
+
// Allocation-free pre-scan: true when `headers` is a plain object that the
|
|
12
|
+
// parse + redact path would reproduce verbatim — every key already lowercase,
|
|
13
|
+
// every value a string (or array of strings), no secret header present. In
|
|
14
|
+
// that case the original object can be logged as-is: log bindings only read
|
|
15
|
+
// it (pino serializes child bindings eagerly), nothing in the pipeline
|
|
16
|
+
// mutates a caller's headers object in place. Uses a `for..in` + `Object.hasOwn`
|
|
17
|
+
// guard rather than `Object.keys` so the scan itself allocates nothing while
|
|
18
|
+
// still ignoring inherited props exactly as `Object.keys` would.
|
|
19
|
+
function isCleanHeaderObject(headers) {
|
|
20
|
+
for (const key in headers) {
|
|
21
|
+
if (!Object.hasOwn(headers, key)) {
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if (SECRET_HEADERS.has(key) || key.toLowerCase() !== key) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
const val = headers[key]
|
|
28
|
+
if (Array.isArray(val)) {
|
|
29
|
+
for (const item of val) {
|
|
30
|
+
if (typeof item !== 'string') {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else if (typeof val !== 'string') {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return a loggable view of `headers` with credential values replaced by a
|
|
42
|
+
// redaction marker. Copy-on-write: the common case (already-lowercased plain
|
|
43
|
+
// object, string values, nothing to redact) returns the original object as-is,
|
|
44
|
+
// allocating no new headers object (the pre-scan itself is allocation-free
|
|
45
|
+
// too). Only when something actually needs work — flat-array
|
|
46
|
+
// form ([name, value, name, value, ...] with Buffer or string entries from
|
|
47
|
+
// onHeaders/onUpgrade), a secret header, a non-lowercase name, or a
|
|
48
|
+
// non-string value — do we build a sanitized copy via parseHeaders, which
|
|
49
|
+
// lowercases names, stringifies values (Buffers included, so no
|
|
50
|
+
// `{type:'Buffer',data:[...]}` blobs in bindings), skips null/undefined, and
|
|
51
|
+
// merges duplicate names into arrays instead of overwriting earlier values.
|
|
52
|
+
function sanitizeHeaders(headers) {
|
|
53
|
+
if (headers == null || typeof headers !== 'object') {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!Array.isArray(headers) && isCleanHeaderObject(headers)) {
|
|
58
|
+
return headers
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sanitized = parseHeaders(headers)
|
|
62
|
+
|
|
63
|
+
for (const name of SECRET_HEADERS) {
|
|
64
|
+
if (name in sanitized) {
|
|
65
|
+
sanitized[name] = REDACTED
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return sanitized
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize the request origin for logging without leaking userinfo
|
|
73
|
+
// credentials embedded as `http://user:pass@host`. Copy-on-write: userinfo
|
|
74
|
+
// requires an '@', so a string without one is returned as-is — no URL
|
|
75
|
+
// allocation. Real `URL` instances already expose a credential-free
|
|
76
|
+
// `origin`; arbitrary URL-like objects do NOT get that fast path, since a
|
|
77
|
+
// plain `{ origin: 'http://user:pass@host' }` would bypass the userinfo
|
|
78
|
+
// check. Everything else is stringified, and only strings that could carry
|
|
79
|
+
// userinfo are parsed and reduced to URL#origin (which never contains
|
|
80
|
+
// userinfo); if such a string is not a parseable URL, prefer losing the
|
|
81
|
+
// value over risking embedded credentials.
|
|
82
|
+
function sanitizeOrigin(origin) {
|
|
83
|
+
if (origin == null) {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
if (origin instanceof URL) {
|
|
87
|
+
return origin.origin
|
|
88
|
+
}
|
|
89
|
+
const str = typeof origin === 'string' ? origin : String(origin)
|
|
90
|
+
if (!str.includes('@')) {
|
|
91
|
+
return str
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return new URL(str).origin
|
|
95
|
+
} catch {
|
|
96
|
+
return REDACTED
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Summarize the request body (type + size) instead of embedding its content.
|
|
101
|
+
// Bodies may contain credentials or be arbitrarily large, and pino serializes
|
|
102
|
+
// child bindings eagerly — never put the payload itself into the log record.
|
|
103
|
+
function describeBody(body) {
|
|
104
|
+
if (body == null) {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
if (typeof body === 'string') {
|
|
108
|
+
return `string(${Buffer.byteLength(body)} bytes)`
|
|
109
|
+
}
|
|
110
|
+
if (Buffer.isBuffer(body)) {
|
|
111
|
+
return `Buffer(${body.byteLength} bytes)`
|
|
112
|
+
}
|
|
113
|
+
if (ArrayBuffer.isView(body)) {
|
|
114
|
+
return `${body.constructor?.name ?? 'TypedArray'}(${body.byteLength} bytes)`
|
|
115
|
+
}
|
|
116
|
+
if (typeof body === 'object' && typeof body.byteLength === 'number') {
|
|
117
|
+
return `${body.constructor?.name ?? 'ArrayBuffer'}(${body.byteLength} bytes)`
|
|
118
|
+
}
|
|
119
|
+
if (typeof body === 'function') {
|
|
120
|
+
return 'function'
|
|
121
|
+
}
|
|
122
|
+
return body.constructor?.name ?? typeof body
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Small, sanitized summary of the request opts used for all `ureq` log
|
|
126
|
+
// bindings. Built once per request instead of binding the live opts object,
|
|
127
|
+
// which both leaked credentials/bodies into logs and paid eager pino
|
|
128
|
+
// serialization of the full opts (including the entire body) per request.
|
|
129
|
+
function sanitizeRequest(opts) {
|
|
130
|
+
return {
|
|
131
|
+
id: opts.id,
|
|
132
|
+
origin: sanitizeOrigin(opts.origin),
|
|
133
|
+
path: opts.path,
|
|
134
|
+
method: opts.method,
|
|
135
|
+
headers: sanitizeHeaders(opts.headers),
|
|
136
|
+
body: describeBody(opts.body),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
6
140
|
class Handler extends DecoratorHandler {
|
|
7
|
-
#
|
|
141
|
+
#ureq
|
|
8
142
|
#logger
|
|
9
143
|
|
|
10
144
|
#abort
|
|
@@ -25,8 +159,8 @@ class Handler extends DecoratorHandler {
|
|
|
25
159
|
constructor(logOpts, opts, { handler }) {
|
|
26
160
|
super(handler)
|
|
27
161
|
|
|
28
|
-
this.#
|
|
29
|
-
this.#logger = opts.logger.child({ ureq:
|
|
162
|
+
this.#ureq = sanitizeRequest(opts)
|
|
163
|
+
this.#logger = opts.logger.child({ ureq: this.#ureq })
|
|
30
164
|
|
|
31
165
|
if (logOpts?.bindings) {
|
|
32
166
|
this.#logger = this.#logger.child(logOpts?.bindings)
|
|
@@ -59,7 +193,7 @@ class Handler extends DecoratorHandler {
|
|
|
59
193
|
|
|
60
194
|
this.#logger.debug(
|
|
61
195
|
{
|
|
62
|
-
ures: { statusCode, headers },
|
|
196
|
+
ures: { statusCode, headers: sanitizeHeaders(headers) },
|
|
63
197
|
elapsedTime: this.#timing.headers,
|
|
64
198
|
},
|
|
65
199
|
'upstream request upgrade',
|
|
@@ -76,7 +210,9 @@ class Handler extends DecoratorHandler {
|
|
|
76
210
|
onHeaders(statusCode, headers, resume) {
|
|
77
211
|
this.#timing.headers = performance.now() - this.#created
|
|
78
212
|
this.#statusCode = statusCode
|
|
79
|
-
|
|
213
|
+
// Only used for log records; store the sanitized copy so set-cookie etc.
|
|
214
|
+
// never end up in retained (error-level) logs.
|
|
215
|
+
this.#headers = sanitizeHeaders(headers)
|
|
80
216
|
|
|
81
217
|
return super.onHeaders(statusCode, headers, resume)
|
|
82
218
|
}
|
|
@@ -95,7 +231,7 @@ class Handler extends DecoratorHandler {
|
|
|
95
231
|
this.#timing.end = performance.now() - this.#created
|
|
96
232
|
|
|
97
233
|
const data = {
|
|
98
|
-
ureq: this.#
|
|
234
|
+
ureq: this.#ureq,
|
|
99
235
|
ures: {
|
|
100
236
|
statusCode: this.#statusCode,
|
|
101
237
|
headers: this.#headers,
|
|
@@ -161,7 +297,46 @@ class Handler extends DecoratorHandler {
|
|
|
161
297
|
this[kGlobalIndex] = -1
|
|
162
298
|
}
|
|
163
299
|
}
|
|
300
|
+
|
|
301
|
+
// Finalization for a request whose inner dispatch threw synchronously:
|
|
302
|
+
// undici never took ownership of the handler, so no terminal callback
|
|
303
|
+
// (onError/onComplete) will ever arrive. Log the failure and deregister
|
|
304
|
+
// from the in-flight registry. Deliberately does NOT forward onError —
|
|
305
|
+
// the dispatch entry below rethrows and an outer interceptor (lookup)
|
|
306
|
+
// delivers the error to the original handler chain, so forwarding here
|
|
307
|
+
// would double-deliver it.
|
|
308
|
+
onDispatchError(err) {
|
|
309
|
+
if (this[kGlobalIndex] === -1) {
|
|
310
|
+
// A terminal callback already ran before the error escaped dispatch
|
|
311
|
+
// (e.g. onError was delivered and the error was then rethrown):
|
|
312
|
+
// already logged and deregistered.
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.#timing.end = performance.now() - this.#created
|
|
317
|
+
|
|
318
|
+
this.#logger.error({ err, elapsedTime: this.#timing.end }, 'upstream request failed')
|
|
319
|
+
|
|
320
|
+
this.onDone()
|
|
321
|
+
}
|
|
164
322
|
}
|
|
165
323
|
|
|
166
|
-
export default (logOpts) => (dispatch) => (opts, handler) =>
|
|
167
|
-
opts.logger
|
|
324
|
+
export default (logOpts) => (dispatch) => (opts, handler) => {
|
|
325
|
+
if (!opts.logger) {
|
|
326
|
+
return dispatch(opts, handler)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const logHandler = new Handler(logOpts, opts, { handler })
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
return dispatch(opts, logHandler)
|
|
333
|
+
} catch (err) {
|
|
334
|
+
// An inner interceptor threw synchronously at dispatch time (e.g. proxy
|
|
335
|
+
// loop detection). The error escapes past the already-registered handler,
|
|
336
|
+
// which would otherwise stay in the global in-flight registry forever.
|
|
337
|
+
// Finalize it and rethrow so outer interceptors observe the same error
|
|
338
|
+
// as before.
|
|
339
|
+
logHandler.onDispatchError(err)
|
|
340
|
+
throw err
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -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) {
|
|
@@ -94,10 +108,13 @@ function eqiLower(a, b) {
|
|
|
94
108
|
// allocation-free and letting the common (non-hop) header bail out in a single
|
|
95
109
|
// comparison.
|
|
96
110
|
/**
|
|
111
|
+
* Exported for reuse by the cache interceptor: RFC 9111 §3.1 forbids storing
|
|
112
|
+
* hop-by-hop fields, which is the same set a proxy must not retransmit.
|
|
113
|
+
*
|
|
97
114
|
* @param {string} key
|
|
98
115
|
* @returns {boolean}
|
|
99
116
|
*/
|
|
100
|
-
function isHopByHop(key) {
|
|
117
|
+
export function isHopByHop(key) {
|
|
101
118
|
switch (key.length) {
|
|
102
119
|
case 2:
|
|
103
120
|
return eqiLower(key, 'te')
|
|
@@ -148,8 +165,10 @@ function isHopByHop(key) {
|
|
|
148
165
|
* @param {boolean} [options.isResponse] Response path marker. When set,
|
|
149
166
|
* Forwarded is never synthesised regardless of `socket` (it would leak the
|
|
150
167
|
* 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.
|
|
168
|
+
* @param {(acc: T, key: string, value: string | string[]) => T} fn Accumulator
|
|
169
|
+
* invoked once per retained header. The value is an array when the field
|
|
170
|
+
* appeared more than once (parseHeaders shape) — never pre-joined, because
|
|
171
|
+
* fields like set-cookie (RFC 6265) must keep distinct field lines.
|
|
153
172
|
* @param {T} acc Initial accumulator value.
|
|
154
173
|
* @returns {T}
|
|
155
174
|
*/
|
|
@@ -262,7 +281,16 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket, isResponse },
|
|
|
262
281
|
(remove === null || !remove.includes(key.toLowerCase())) &&
|
|
263
282
|
!isHopByHop(key)
|
|
264
283
|
) {
|
|
265
|
-
|
|
284
|
+
// A repeated field-line arrives as an array (parseHeaders collects
|
|
285
|
+
// them). Pass it through as-is — .toString() would comma-join with no
|
|
286
|
+
// space, corrupting fields whose values legally contain commas and must
|
|
287
|
+
// never be combined: set-cookie (RFC 9110 §5.3 / RFC 6265, e.g. an
|
|
288
|
+
// Expires date) and multi-challenge www-authenticate. Array values are
|
|
289
|
+
// exactly the shape parseHeaders delivers without this interceptor, and
|
|
290
|
+
// both the request path (undici accepts array header values) and the
|
|
291
|
+
// response handler chain already relay it.
|
|
292
|
+
const value = headers[key]
|
|
293
|
+
acc = fn(acc, key, Array.isArray(value) ? value : value.toString())
|
|
266
294
|
}
|
|
267
295
|
}
|
|
268
296
|
|
|
@@ -358,7 +386,10 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
358
386
|
proxyName: opts.proxy.name,
|
|
359
387
|
},
|
|
360
388
|
(obj, key, val) => {
|
|
361
|
-
|
|
389
|
+
// Case-insensitive (eqiLower) like reduceHeaders' capture above: the
|
|
390
|
+
// standalone interceptors.proxy() composition may pass mixed-case keys
|
|
391
|
+
// (the production path lowercases via parseHeaders first).
|
|
392
|
+
if (!expectsPayload && key.length === 14 && eqiLower(key, 'content-length')) {
|
|
362
393
|
// https://tools.ietf.org/html/rfc7230#section-3.3.2
|
|
363
394
|
// A user agent SHOULD NOT send a Content-Length header field when
|
|
364
395
|
// the request message does not contain a payload body and the method
|
|
@@ -366,7 +397,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
366
397
|
// undici will error if provided an unexpected content-length: 0 header.
|
|
367
398
|
} else if (key[0] === ':') {
|
|
368
399
|
// strip pseudo headers
|
|
369
|
-
} else if (key === 'expect') {
|
|
400
|
+
} else if (key.length === 6 && eqiLower(key, 'expect')) {
|
|
370
401
|
// undici doesn't support expect header.
|
|
371
402
|
} else {
|
|
372
403
|
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
|
+
}
|