@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.
@@ -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
- dns.lookup(hostname, { all: true }, (err, records) => {
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
- const [err, val] = await resolve(hostname, { ttl })
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
- { ...opts, origin: url.origin, headers: { ...opts.headers, host } },
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) {
@@ -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
- #opts
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.#opts = opts
29
- this.#logger = opts.logger.child({ ureq: opts })
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
- this.#headers = headers
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.#opts,
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 ? dispatch(opts, new Handler(logOpts, opts, { handler })) : dispatch(opts, handler)
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)
@@ -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
- super.onUpgrade(
29
- statusCode,
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
- socket,
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 invoked
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
- acc = fn(acc, key, headers[key].toString())
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
- if (key === 'content-length' && !expectsPayload) {
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
- const { origin, pathname, search } = parseURL(
109
- new URL(this.#location, this.#opts.origin && new URL(this.#opts.path, this.#opts.origin)),
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 !== 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
- if (typeof body === 'string' || body instanceof Buffer) {
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
- this.#ac.abort(err)
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
- export default () => (dispatch) => (opts, handler) =>
84
- typeof opts.body !== 'function'
85
- ? dispatch(opts, handler)
86
- : dispatch({ ...opts, body: new FactoryStream(opts.body).on('error', noop) }, handler)
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
+ }