kinetex 1.3.0 → 1.5.0

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.
Files changed (123) hide show
  1. package/README.md +413 -52
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3134 -787
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +140 -33
  6. package/dist/cjs/cache.js +58 -7
  7. package/dist/cjs/circuit-breaker.js +53 -4
  8. package/dist/cjs/client.js +726 -188
  9. package/dist/cjs/cookie-parser.js +131 -6
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +691 -70
  12. package/dist/cjs/dedup.js +142 -21
  13. package/dist/cjs/digest.js +195 -38
  14. package/dist/cjs/graphql.js +166 -35
  15. package/dist/cjs/headers.js +327 -46
  16. package/dist/cjs/interceptors.js +227 -24
  17. package/dist/cjs/lifecycle.js +92 -59
  18. package/dist/cjs/logging.js +160 -24
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +178 -30
  22. package/dist/cjs/proxy.js +429 -0
  23. package/dist/cjs/response.js +506 -110
  24. package/dist/cjs/socks5.js +136 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +335 -73
  28. package/dist/cjs/ws.js +62 -12
  29. package/dist/esm/aws-sigv4.js +140 -33
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +58 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +53 -4
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +726 -188
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +131 -6
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +691 -70
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +142 -21
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +195 -38
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +166 -35
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +327 -46
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +227 -24
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +92 -59
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +160 -24
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +178 -30
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +430 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +506 -110
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +136 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +335 -73
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +62 -12
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +15 -2
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +216 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts +38 -3
  91. package/dist/types/dedup.d.ts.map +1 -1
  92. package/dist/types/digest.d.ts +17 -37
  93. package/dist/types/digest.d.ts.map +1 -1
  94. package/dist/types/graphql.d.ts.map +1 -1
  95. package/dist/types/headers.d.ts +68 -31
  96. package/dist/types/headers.d.ts.map +1 -1
  97. package/dist/types/interceptors.d.ts +126 -38
  98. package/dist/types/interceptors.d.ts.map +1 -1
  99. package/dist/types/lifecycle.d.ts +19 -2
  100. package/dist/types/lifecycle.d.ts.map +1 -1
  101. package/dist/types/logging.d.ts +22 -3
  102. package/dist/types/logging.d.ts.map +1 -1
  103. package/dist/types/mod.d.ts +5 -3
  104. package/dist/types/mod.d.ts.map +1 -1
  105. package/dist/types/pagination.d.ts +0 -25
  106. package/dist/types/pagination.d.ts.map +1 -1
  107. package/dist/types/progress.d.ts +1 -1
  108. package/dist/types/progress.d.ts.map +1 -1
  109. package/dist/types/proxy.d.ts +50 -0
  110. package/dist/types/proxy.d.ts.map +1 -0
  111. package/dist/types/response.d.ts +49 -2
  112. package/dist/types/response.d.ts.map +1 -1
  113. package/dist/types/socks5.d.ts.map +1 -1
  114. package/dist/types/sse.d.ts.map +1 -1
  115. package/dist/types/types.d.ts +219 -26
  116. package/dist/types/types.d.ts.map +1 -1
  117. package/dist/types/url.d.ts +0 -25
  118. package/dist/types/url.d.ts.map +1 -1
  119. package/dist/types/utils.d.ts +69 -1
  120. package/dist/types/utils.d.ts.map +1 -1
  121. package/dist/types/ws.d.ts +0 -1
  122. package/dist/types/ws.d.ts.map +1 -1
  123. package/package.json +10 -4
package/dist/cjs/core.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Node.js globals accessed via globalThis for cross-runtime compatibility
2
2
  const g = globalThis;
3
3
  import { KinetexError, TimeoutError, SizeLimitError } from "./types.js";
4
- import { concatUint8Arrays, mergeSignals, isAbortError, safeJSONParse } from "./utils.js";
4
+ import { concatUint8Arrays, mergeSignals, isAbortError, safeJSONParse, isSafeURL, isBlockedAddress, randomBytes, } from "./utils.js";
5
5
  import { isValidHeaderName, isValidHeaderValue } from "./headers.js";
6
6
  // ============================================================================
7
7
  // §1 RUNTIME DETECTION
@@ -65,7 +65,26 @@ let _runtimeOverride = null;
65
65
  * setRuntime(null); // restore detection
66
66
  * ```
67
67
  */
68
+ /** Every value {@link Runtime} admits, for runtime validation. */
69
+ const KNOWN_RUNTIMES = [
70
+ "node",
71
+ "deno",
72
+ "bun",
73
+ "browser",
74
+ "cloudflare-workers",
75
+ "edge",
76
+ "unknown",
77
+ ];
68
78
  export function setRuntime(rt) {
79
+ // The parameter is typed, but nothing checks it at runtime, and the callers
80
+ // that matter pass a value read from configuration. A typo — "denno" — was
81
+ // stored verbatim and became the effective runtime, and every branch in the
82
+ // library gated on `RUNTIME === "..."` then missed: no fetch, no HTTP/2, no
83
+ // proxy, no Node-only path, with nothing thrown and nothing logged. An
84
+ // unrecognised value is a mistake worth reporting at the point it is made.
85
+ if (rt !== null && !KNOWN_RUNTIMES.includes(rt)) {
86
+ throw new TypeError(`setRuntime: unknown runtime ${JSON.stringify(rt)}. Expected one of: ${KNOWN_RUNTIMES.join(", ")}`);
87
+ }
69
88
  _runtimeOverride = rt;
70
89
  }
71
90
  /**
@@ -117,6 +136,14 @@ export class FetchTransport {
117
136
  fetchFn;
118
137
  strict;
119
138
  onDroppedHeader;
139
+ dispatcher;
140
+ /** Per-request timeout; omitted means no timeout. */
141
+ requestTimeoutMs;
142
+ /**
143
+ * Permit connections to hosts resolving into private/loopback space.
144
+ * Default: false.
145
+ */
146
+ _allowPrivateNetwork;
120
147
  /**
121
148
  * @param fetchFnOrOptions - Custom fetch function or options object
122
149
  */
@@ -125,12 +152,76 @@ export class FetchTransport {
125
152
  this.fetchFn = fetchFnOrOptions;
126
153
  this.strict = false;
127
154
  this.onDroppedHeader = undefined;
155
+ this.dispatcher = undefined;
156
+ this.requestTimeoutMs = undefined;
157
+ this._allowPrivateNetwork = false;
128
158
  }
129
159
  else {
130
160
  this.fetchFn = fetchFnOrOptions.fetchFn ?? globalThis.fetch;
131
161
  this.strict = fetchFnOrOptions.strict ?? false;
132
162
  this.onDroppedHeader = fetchFnOrOptions.onDroppedHeader;
163
+ this.dispatcher = fetchFnOrOptions.dispatcher;
164
+ this.requestTimeoutMs = fetchFnOrOptions.requestTimeoutMs;
165
+ this._allowPrivateNetwork = fetchFnOrOptions.allowPrivateNetwork ?? false;
166
+ }
167
+ }
168
+ /**
169
+ * Refuse a host that resolves into blocked address space.
170
+ *
171
+ * The Node transports close DNS rebinding properly: they resolve once and
172
+ * pin the checked address to the socket (`createPinnedLookup`). `fetch` has no
173
+ * resolver hook — undici owns the connection and exposes no way to install
174
+ * one — so the only place to intervene is before the call.
175
+ *
176
+ * That closes the case that matters in practice: a name resolving to loopback
177
+ * or the cloud metadata address is refused outright, which is the vector
178
+ * behind the classic SSRF-through-DNS attacks. What it cannot close is a
179
+ * resolver that answers public on this lookup and private on the connect a
180
+ * moment later. Closing *that* needs a resolver hook `fetch` does not offer,
181
+ * and claiming otherwise would be worse than saying so.
182
+ *
183
+ * The host has already passed the lexical screen by this point, so this is a
184
+ * second line of defence rather than the first.
185
+ */
186
+ async assertReachableHost(url) {
187
+ if (this._allowPrivateNetwork)
188
+ return;
189
+ // A caller-supplied `fetch` owns its own name resolution — its dispatcher,
190
+ // agent or resolver may answer something the system resolver never would,
191
+ // and vice versa. Screening the *system* answer for a name this transport
192
+ // will not resolve is theatre: it cannot observe the connection that
193
+ // actually happens, so it neither protects the caller nor describes the
194
+ // risk honestly. The lexical screen still applies to every URL, including
195
+ // redirects; the resolution half of the defence is the transport's job, and
196
+ // a custom one is the caller's.
197
+ if (this.fetchFn !== globalThis.fetch)
198
+ return;
199
+ let parsed;
200
+ try {
201
+ parsed = new URL(url);
202
+ }
203
+ catch {
204
+ return; // Malformed URLs fail later, with a better message.
205
+ }
206
+ if (!parsed.hostname || isIP(parsed.hostname))
207
+ return;
208
+ let addresses;
209
+ try {
210
+ // `all: true` so every candidate is inspected, matching what the socket
211
+ // would have been able to reach.
212
+ addresses = await new Promise((resolve, reject) => {
213
+ dnsLookup(parsed.hostname, { all: true }, (err, res) => err ? reject(err) : resolve(res));
214
+ });
215
+ }
216
+ catch {
217
+ // Resolution failed; let the request itself report it rather than
218
+ // inventing a different failure.
219
+ return;
133
220
  }
221
+ const safe = filterSafeAddresses(addresses);
222
+ if (safe.length > 0)
223
+ return;
224
+ throw blockedHostError(url, addresses);
134
225
  }
135
226
  /**
136
227
  * Send a request via the native fetch() API.
@@ -143,6 +234,7 @@ export class FetchTransport {
143
234
  * @returns Raw response from the server
144
235
  */
145
236
  async send(req) {
237
+ await this.assertReachableHost(req.url);
146
238
  // Validate and sanitize headers to prevent injection attacks
147
239
  const sanitizedHeaders = {};
148
240
  for (const [name, value] of Object.entries(req.headers ?? {})) {
@@ -164,6 +256,20 @@ export class FetchTransport {
164
256
  }
165
257
  continue;
166
258
  }
259
+ // `__proto__` is a legal header name — it is made of token characters,
260
+ // so it passes the check above — and `sanitizedHeaders[name] = value` is a
261
+ // [[Set]], so it went to the inherited setter, which ignores a primitive.
262
+ // The header did not overwrite anything: it vanished, and the caller was
263
+ // never told. Written as a data property, which is what was meant.
264
+ if (name === "__proto__") {
265
+ Object.defineProperty(sanitizedHeaders, name, {
266
+ value,
267
+ writable: true,
268
+ enumerable: true,
269
+ configurable: true,
270
+ });
271
+ continue;
272
+ }
167
273
  sanitizedHeaders[name] = value;
168
274
  }
169
275
  // Strip the accept-encoding value INJECTED by client.ts so fetch() can add
@@ -195,16 +301,53 @@ export class FetchTransport {
195
301
  init.duplex = "half";
196
302
  }
197
303
  }
304
+ // Forwarded only when set: an explicit `dispatcher: undefined` would
305
+ // otherwise override a globally-installed dispatcher on the runtime.
306
+ if (this.dispatcher !== undefined) {
307
+ init.dispatcher = this.dispatcher;
308
+ }
309
+ // fetch() has no timeout of its own, so one is enforced here with an
310
+ // AbortController. Composed with the caller's signal rather than replacing
311
+ // it, so cancelling the caller still cancels the request.
312
+ const timeoutMs = this.requestTimeoutMs;
313
+ const timeoutCtl = timeoutMs === undefined ? undefined : new AbortController();
314
+ let timedOut = false;
315
+ const onCallerAbort = () => timeoutCtl?.abort();
316
+ if (timeoutCtl && req.signal) {
317
+ if (req.signal.aborted)
318
+ timeoutCtl.abort();
319
+ else
320
+ req.signal.addEventListener("abort", onCallerAbort, { once: true });
321
+ }
322
+ const timer = timeoutCtl && timeoutMs !== undefined
323
+ ? setTimeout(() => {
324
+ timedOut = true;
325
+ timeoutCtl.abort();
326
+ }, timeoutMs)
327
+ : undefined;
328
+ const initWithSignal = timeoutCtl && !init.signal ? { ...init, signal: timeoutCtl.signal } : init;
198
329
  let response;
199
330
  try {
200
- response = await this.fetchFn(req.url, init);
331
+ response = await this.fetchFn(req.url, initWithSignal);
201
332
  }
202
333
  catch (err) {
334
+ // Clear the timer on every exit path, and drop the listener we added.
335
+ if (timer !== undefined)
336
+ clearTimeout(timer);
337
+ if (timeoutCtl && req.signal)
338
+ req.signal.removeEventListener("abort", onCallerAbort);
339
+ if (timedOut) {
340
+ throw new KinetexError(`HTTP/1.1 request to ${req.url} timed out after ${timeoutMs}ms`, "ETIMEOUT", { request: req, cause: err });
341
+ }
203
342
  if (isAbortError(err)) {
204
343
  throw new KinetexError("Request was aborted", "EABORT", { request: req, cause: err });
205
344
  }
206
345
  throw new KinetexError(err instanceof Error ? err.message : "Network request failed", "ENETWORK", { request: req, cause: err });
207
346
  }
347
+ if (timer !== undefined)
348
+ clearTimeout(timer);
349
+ if (timeoutCtl && req.signal)
350
+ req.signal.removeEventListener("abort", onCallerAbort);
208
351
  const headers = normalizeHeaders(response.headers);
209
352
  const httpVersion = detectHTTPVersion(response, headers);
210
353
  // Determine if the runtime already decompressed the body.
@@ -264,6 +407,27 @@ export class NodeHTTP2Transport {
264
407
  _requestTimeoutMs;
265
408
  /** Cached HTTP/1.1 fallback transport — reuse instead of creating fresh FetchTransport per call */
266
409
  _http1Fallback = null;
410
+ /**
411
+ * Dedicated keep-alive agent for the legacy `node:https` path.
412
+ *
413
+ * This path called `https.request(options)` with no `agent`, so behaviour was
414
+ * whatever the global agent happened to do — and that default is a moving
415
+ * target. Node 19 turned `keepAlive` on for `https.globalAgent`, so this path
416
+ * silently changed from a TCP + TLS handshake per request (Node 18 and
417
+ * earlier) to pooled connections, with no code change here to explain it.
418
+ *
419
+ * An explicit agent makes the behaviour identical on every supported Node
420
+ * version, bounds the idle-socket pool (the global agent keeps 256 free
421
+ * sockets, which is a lot to hold open for a legacy fallback), and gives
422
+ * callers a way to opt out.
423
+ */
424
+ /** Whether to permit connections into private/loopback address space. */
425
+ _allowPrivateNetwork;
426
+ _http1KeepAlive;
427
+ _http1MaxSockets;
428
+ _http1Agent = null;
429
+ /** HTTP(S) CONNECT proxy, or undefined for a direct connection. */
430
+ _proxy;
267
431
  /**
268
432
  * @param options - Session pool and transport configuration
269
433
  */
@@ -271,6 +435,10 @@ export class NodeHTTP2Transport {
271
435
  this.sessionTTLMs = options.sessionTTLMs ?? 5 * 60_000;
272
436
  this.pingIntervalMs = options.pingIntervalMs ?? 30_000;
273
437
  this.maxSessions = options.maxSessions ?? 100;
438
+ this._allowPrivateNetwork = options.allowPrivateNetwork ?? false;
439
+ this._http1KeepAlive = options.http1KeepAlive ?? true;
440
+ this._http1MaxSockets = options.http1MaxSockets ?? 16;
441
+ this._proxy = options.proxy;
274
442
  this._strict = options.strict ?? false;
275
443
  this._onDroppedHeader = options.onDroppedHeader;
276
444
  this._ca = options.ca;
@@ -333,10 +501,32 @@ export class NodeHTTP2Transport {
333
501
  this._evictSession(lruOrigin, lruSession.session);
334
502
  }
335
503
  }
504
+ // With a proxy, the tunnel (and, for an https origin, the TLS handshake
505
+ // to the target) is established *before* http2.connect is called.
506
+ //
507
+ // `createConnection` must return a socket synchronously — node:http2 does
508
+ // not await it, and handing it a Promise yields a session built on a
509
+ // thenable, which fails deep inside the stream layer with
510
+ // "stream.pause is not a function". So the async work happens here and
511
+ // the already-open socket is passed back synchronously.
512
+ let proxiedSocket = null;
513
+ if (this._proxy !== undefined) {
514
+ proxiedSocket = await this._createProxiedSocket(origin, undefined, req);
515
+ }
336
516
  const session = await new Promise((resolve, reject) => {
337
517
  const s = http2.connect(origin, {
338
518
  rejectUnauthorized: true,
519
+ // Resolve-and-check, so a hostname that points at a private address is
520
+ // refused before anything is dialled. See `createPinnedLookup`.
521
+ ...(this._allowPrivateNetwork ? {} : { lookup: PINNED_LOOKUP }),
339
522
  ...(this._ca !== undefined ? { ca: this._ca } : {}),
523
+ // Already connected (and already TLS-wrapped for an https origin),
524
+ // so node:http2 must use it as-is rather than negotiating again.
525
+ ...(proxiedSocket !== null
526
+ ? {
527
+ createConnection: (() => proxiedSocket),
528
+ }
529
+ : {}),
340
530
  });
341
531
  // FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
342
532
  // FIX 9: unref() the timer so it does not prevent process exit
@@ -425,6 +615,29 @@ export class NodeHTTP2Transport {
425
615
  throw err;
426
616
  }
427
617
  }
618
+ /**
619
+ * Refuse one header on the HTTP/2 path. Strict mode raises `EVALIDATION`
620
+ * before anything is dialled; non-strict notifies the callback (if any) and
621
+ * warns, never dropping silently.
622
+ *
623
+ * Both the pseudo-header filter and the validation loop go through here so
624
+ * the two cannot drift apart, which is what let a caller-supplied `:path`
625
+ * reach the wire while `FetchTransport` dropped the identical header.
626
+ */
627
+ _rejectHeader(name, value, reason, request) {
628
+ if (this._strict) {
629
+ throw new KinetexError(`Strict mode: header "${name}" ${reason}`, "EVALIDATION", {
630
+ request,
631
+ });
632
+ }
633
+ if (this._onDroppedHeader) {
634
+ this._onDroppedHeader(name, value);
635
+ }
636
+ else if (typeof console !== "undefined") {
637
+ console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${name}" — ${reason}. ` +
638
+ `Pass strictHeaders: true to throw instead.`);
639
+ }
640
+ }
428
641
  /**
429
642
  * Send a request over HTTP/2 with iterative redirect following.
430
643
  * Each hop reuses or creates a session for the target origin.
@@ -457,14 +670,69 @@ export class NodeHTTP2Transport {
457
670
  session = existing.session;
458
671
  this.sessionUsage.set(origin, Date.now());
459
672
  }
460
- // Build headers for this hop
673
+ // A `FormData` body has to be encoded BEFORE the header block is built:
674
+ // the multipart boundary is generated during encoding, and a boundary
675
+ // that cannot reach `content-type` leaves a body the peer cannot parse.
676
+ // This transport bypasses fetch, so nothing else would have encoded it.
677
+ // The client's own path never reaches this — it encodes the form and sets
678
+ // the header together before dispatch — but a caller handing a `FormData`
679
+ // straight to a transport would otherwise send a body whose boundary no
680
+ // header ever named.
681
+ let bodyForHop = currentReq.body;
682
+ let encodedForHop;
683
+ if (currentReq.body instanceof FormData) {
684
+ encodedForHop = await serializeRawBody(currentReq.body);
685
+ bodyForHop = encodedForHop.bytes;
686
+ // Request headers are lowercased before they reach the transport, so
687
+ // a direct key read is the right test here.
688
+ if (encodedForHop.contentType !== undefined && !currentReq.headers["content-type"]) {
689
+ currentReq = {
690
+ ...currentReq,
691
+ headers: { ...currentReq.headers, "content-type": encodedForHop.contentType },
692
+ };
693
+ }
694
+ }
695
+ // Build headers for this hop. The transport owns the request line, so a
696
+ // caller-supplied pseudo-header (":path", ":authority", ":method",
697
+ // ":scheme") is refused rather than merged.
698
+ //
699
+ // They used to be spread over the transport's own values, which meant a
700
+ // caller could send a request to a path and a `:authority` that the URL
701
+ // argument never contained — the URL that `isSafeURL` screened is not
702
+ // the URL that got dialled. The validation loop below could not catch it
703
+ // either, because it skipped every name starting with ":", so even
704
+ // `strict: true` returned 200 for a hijacked `:path`, and
705
+ // `FetchTransport` dropped the very same header because ":" is not a
706
+ // token character. Same request, two transports, two answers.
461
707
  const h2ReqHeaders = {
462
708
  ":method": currentReq.method,
463
709
  ":path": currentUrl.pathname + currentUrl.search,
464
710
  ":scheme": "https",
465
711
  ":authority": currentUrl.host,
466
- ...currentReq.headers,
467
712
  };
713
+ for (const [hName, hValue] of Object.entries(currentReq.headers)) {
714
+ if (hName.startsWith(":")) {
715
+ this._rejectHeader(hName, Array.isArray(hValue) ? hValue.join(", ") : String(hValue), "is not a valid header name", currentReq);
716
+ continue;
717
+ }
718
+ // `__proto__` is a legal header name — it is made of token characters,
719
+ // so it passes `isValidHeaderName` — and `h2ReqHeaders[name] = value`
720
+ // is a [[Set]], so it hit the inherited setter, which ignores a
721
+ // primitive. The header did not overwrite anything: it vanished, and
722
+ // neither the callback nor the warning said so. Written as a data
723
+ // property, which is what `FetchTransport` already does — the two
724
+ // transports disagreed about whether it is sent at all.
725
+ if (hName === "__proto__") {
726
+ Object.defineProperty(h2ReqHeaders, hName, {
727
+ value: hValue,
728
+ writable: true,
729
+ enumerable: true,
730
+ configurable: true,
731
+ });
732
+ continue;
733
+ }
734
+ h2ReqHeaders[hName] = hValue;
735
+ }
468
736
  // Header validation (HTTP/2 control-character check). Runs in BOTH modes:
469
737
  // strict throws, non-strict drops with callback/warn — matching the
470
738
  // FetchTransport contract. (Previously the whole loop was gated on
@@ -475,35 +743,29 @@ export class NodeHTTP2Transport {
475
743
  if (hName.startsWith(":"))
476
744
  continue;
477
745
  const hStr = Array.isArray(hValue) ? hValue.join(", ") : String(hValue);
478
- let hasForbidden = false;
479
- for (let ci = 0; ci < hStr.length; ci++) {
480
- const code = hStr.charCodeAt(ci);
481
- if ((code >= 0x00 && code <= 0x08) || (code >= 0x0a && code <= 0x1f) || code === 0x7f) {
482
- hasForbidden = true;
483
- break;
484
- }
485
- }
486
- if (hasForbidden) {
487
- if (this._strict) {
488
- throw new KinetexError(`Strict mode: header "${hName}" contains forbidden control characters`, "EVALIDATION", { request: currentReq });
489
- }
490
- // FIX (H3): non-strict mode must match FetchTransport behavior —
491
- // notify the callback (if any) and warn, never drop silently.
492
- if (this._onDroppedHeader) {
493
- this._onDroppedHeader(hName, hStr);
494
- }
495
- else if (typeof console !== "undefined") {
496
- console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
497
- `Pass strictHeaders: true to throw instead.`);
498
- }
746
+ // The same two checks FetchTransport runs, through the same helpers —
747
+ // not a hand-rolled control-character scan. The scan checked the value
748
+ // only, so a header *name* that was not a token ("X Bad", "X\u00e9")
749
+ // reached `session.request()` and came back as a raw
750
+ // ERR_INVALID_HTTP2_HEADER / ERR_INVALID_HEADER_VALUE instead of being
751
+ // dropped in non-strict mode or raising EVALIDATION in strict mode; and
752
+ // it had no upper bound, so a value above U+00FF — which FetchTransport
753
+ // refuses because no ByteString header value can carry it — was sent on
754
+ // this path and dropped on that one. Same request, two transports, two
755
+ // answers. The comment above this loop claimed they matched; they did
756
+ // not, and HTTP/2 is the default on Node.
757
+ const nameOk = isValidHeaderName(hName);
758
+ const valueOk = isValidHeaderValue(hStr);
759
+ if (!nameOk || !valueOk) {
760
+ this._rejectHeader(hName, hStr, nameOk ? "contains forbidden control characters" : "is not a valid header name", currentReq);
499
761
  delete h2ReqHeaders[hName];
500
762
  }
501
763
  }
502
- const endStream = !currentReq.body || currentReq.method === "GET" || currentReq.method === "HEAD";
764
+ const endStream = !bodyForHop || currentReq.method === "GET" || currentReq.method === "HEAD";
503
765
  const stream = session.request(h2ReqHeaders, { endStream });
504
766
  // FIX 6 (backpressure): attachBodyToH2Stream now awaits drain events
505
- if (currentReq.body && !endStream) {
506
- attachBodyToH2Stream(stream, currentReq.body).catch((err) => {
767
+ if (bodyForHop && !endStream) {
768
+ attachBodyToH2Stream(stream, bodyForHop).catch((err) => {
507
769
  stream.destroy(err instanceof Error ? err : new Error(String(err)));
508
770
  });
509
771
  }
@@ -589,8 +851,9 @@ export class NodeHTTP2Transport {
589
851
  try {
590
852
  if (raw.body) {
591
853
  const drain = raw.body.getReader();
592
- // eslint-disable-next-line no-constant-condition
593
- while (true) {
854
+ // `for(;;)` rather than `while (true)`: same loop, no condition for a
855
+ // linter to have an opinion about.
856
+ for (;;) {
594
857
  const { done } = await drain.read();
595
858
  if (done)
596
859
  break;
@@ -603,14 +866,38 @@ export class NodeHTTP2Transport {
603
866
  }
604
867
  const location = raw.headers["location"];
605
868
  let nextHref;
869
+ let nextProtocol;
606
870
  try {
607
- nextHref = new URL(location, currentReq.url).href;
871
+ const nextUrl = new URL(location, currentReq.url);
872
+ nextHref = nextUrl.href;
873
+ nextProtocol = nextUrl.protocol.toLowerCase();
608
874
  }
609
875
  catch {
610
876
  throw new KinetexError(`Invalid redirect Location: ${location}`, "ENETWORK", {
611
877
  request: req,
612
878
  });
613
879
  }
880
+ // The same two gates the client's manual redirect follower applies, for
881
+ // the same reasons. They were absent here, and this loop is the *only*
882
+ // follower on this path whenever the request arrives without
883
+ // `redirect: "manual"` — which is every direct use of this transport.
884
+ //
885
+ // - Protocol. This transport hardcodes `:scheme: "https"` and speaks
886
+ // HTTP/2, so a cleartext target cannot be dialled at all: the hop
887
+ // failed as `ERR_HTTP2_ERROR: Protocol error` with nothing to connect
888
+ // it to the target. A downgrade was therefore possible by accident
889
+ // rather than refused on purpose, and an `httpsOnly` client could not
890
+ // tell the difference.
891
+ // - SSRF. The hop origin is dialled directly by `http2.connect` below,
892
+ // with no `isSafeURL` screen — the client's follower screens every hop
893
+ // precisely because "a redirect target never went through that
894
+ // check". A 302 to `http://127.0.0.1:9/` opened the socket.
895
+ if (nextProtocol !== "https:") {
896
+ throw new KinetexError(`HTTP/2 redirect to a non-HTTPS target blocked: ${nextProtocol}//…`, "EVALIDATION", { request: req });
897
+ }
898
+ if (!isSafeURL(nextHref)) {
899
+ throw new KinetexError(`Unsafe redirect target blocked: ${nextHref.replace(/:\/\/[^/@]*@/, "://…@")}`, "EVALIDATION", { request: req });
900
+ }
614
901
  // RFC 7231 §6.4: 301/302/303 → downgrade to GET; 307/308 → preserve method
615
902
  const nextMethod = raw.status === 301 || raw.status === 302 || raw.status === 303 ? "GET" : currentReq.method;
616
903
  const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
@@ -634,6 +921,10 @@ export class NodeHTTP2Transport {
634
921
  const opts = {
635
922
  fetchFn: globalThis.fetch,
636
923
  strict: this._strict,
924
+ // The h2 path enforces `_requestTimeoutMs` itself; without passing it
925
+ // down, `new NodeHTTP2Transport({ requestTimeoutMs: 500 })` hung for
926
+ // the full response time on any non-https URL or HTTP/1.1 request.
927
+ requestTimeoutMs: this._requestTimeoutMs,
637
928
  };
638
929
  if (this._onDroppedHeader !== undefined)
639
930
  opts.onDroppedHeader = this._onDroppedHeader;
@@ -649,12 +940,35 @@ export class NodeHTTP2Transport {
649
940
  async _sendHTTP1Legacy(req) {
650
941
  const https = await import("node:https");
651
942
  const url = new URL(req.url);
943
+ // A `FormData` body is encoded before the request options are built, for
944
+ // the same reason as on the HTTP/2 path: the multipart boundary is
945
+ // generated during encoding, and this transport bypasses fetch, so
946
+ // nothing else would encode it or announce the boundary. A caller-set
947
+ // `content-type` wins — they may have encoded the form themselves.
948
+ let legacyReq = req;
949
+ if (req.body instanceof FormData) {
950
+ const encoded = await serializeRawBody(req.body);
951
+ legacyReq = { ...req, body: encoded.bytes };
952
+ if (encoded.contentType !== undefined && !legacyReq.headers["content-type"]) {
953
+ legacyReq = {
954
+ ...legacyReq,
955
+ headers: { ...legacyReq.headers, "content-type": encoded.contentType },
956
+ };
957
+ }
958
+ }
652
959
  const options = {
653
960
  hostname: url.hostname,
654
961
  port: url.port || "443",
655
962
  path: url.pathname + url.search,
656
- method: req.method,
657
- headers: req.headers,
963
+ method: legacyReq.method,
964
+ headers: legacyReq.headers,
965
+ ...(this._http1KeepAlive ? { agent: this._getHttp1Agent(https) } : {}),
966
+ // `ca` was accepted by the transport but never reached this path, so a
967
+ // private or self-signed peer could not be reached without disabling
968
+ // verification process-wide.
969
+ ...(this._ca !== undefined ? { ca: this._ca } : {}),
970
+ // Same reason as the HTTP/2 path above.
971
+ ...(this._allowPrivateNetwork ? {} : { lookup: PINNED_LOOKUP }),
658
972
  };
659
973
  return new Promise((resolve, reject) => {
660
974
  const httpReq = https.request(options, (httpRes) => {
@@ -676,7 +990,13 @@ export class NodeHTTP2Transport {
676
990
  });
677
991
  });
678
992
  httpReq.once("error", (err) => {
679
- reject(new KinetexError(err.message, "ENETWORK", { request: req, cause: err }));
993
+ // An error raised while building the connection (a refused proxy
994
+ // tunnel, a TLS failure) is already a KinetexError carrying a
995
+ // meaningful code. Re-wrapping it as ENETWORK threw that away, so a
996
+ // 403 from the proxy and a DNS failure became indistinguishable.
997
+ reject(err instanceof KinetexError
998
+ ? err
999
+ : new KinetexError(err.message, "ENETWORK", { request: req, cause: err }));
680
1000
  });
681
1001
  // Remove the abort listener once the request settles so the httpReq
682
1002
  // reference doesn't leak beyond the request lifetime.
@@ -689,13 +1009,76 @@ export class NodeHTTP2Transport {
689
1009
  httpReq.once("close", cleanup);
690
1010
  httpReq.once("error", cleanup);
691
1011
  if (req.body && req.method !== "GET" && req.method !== "HEAD") {
692
- pipeBodyToNodeReq(httpReq, req.body).catch(reject);
1012
+ pipeBodyToNodeReq(httpReq, legacyReq.body).catch(reject);
693
1013
  }
694
1014
  else {
695
1015
  httpReq.end();
696
1016
  }
697
1017
  });
698
1018
  }
1019
+ /**
1020
+ * Lazily create the keep-alive agent used by the legacy HTTP/1.1 path.
1021
+ *
1022
+ * @param https - The already-imported `node:https` module.
1023
+ * @returns The shared agent, reused across requests.
1024
+ */
1025
+ _getHttp1Agent(https) {
1026
+ if (!this._http1Agent) {
1027
+ this._http1Agent = new https.Agent({
1028
+ keepAlive: true,
1029
+ maxSockets: this._http1MaxSockets,
1030
+ maxFreeSockets: this._http1MaxSockets,
1031
+ // A private/self-signed peer must be trusted before the socket enters
1032
+ // the pool, otherwise the agent only fails later on reuse.
1033
+ ...(this._ca !== undefined ? { ca: this._ca } : {}),
1034
+ });
1035
+ if (this._proxy !== undefined) {
1036
+ // Assigned to the INSTANCE, not passed in the agent options:
1037
+ // `new Agent({ createConnection })` only copies it into
1038
+ // `agent.options`, while `Agent.prototype.createSocket` calls
1039
+ // `this.createConnection(...)` — the prototype method. Passing it in
1040
+ // the options is silently ignored and the agent dials the origin
1041
+ // directly, bypassing the proxy entirely.
1042
+ //
1043
+ // `createSocket` does:
1044
+ // const s = this.createConnection(options, oncreate);
1045
+ // if (s) oncreate(null, s);
1046
+ // so returning a Promise would be truthy and the agent would adopt
1047
+ // the thenable as a socket. The callback form is the supported way to
1048
+ // connect asynchronously: this returns undefined and reports the
1049
+ // tunneled socket through `oncreate`.
1050
+ //
1051
+ // The tunnel is already TLS-wrapped for an https origin, which is
1052
+ // exactly what https.Agent expects createConnection to return.
1053
+ this._http1Agent.createConnection = ((opts, oncreate) => {
1054
+ void this._createProxiedSocket(`https://${String(opts.host ?? opts.servername ?? "localhost")}:${String(opts.port ?? 443)}`, undefined, undefined).then((socket) => oncreate(null, socket), (err) => oncreate(err));
1055
+ return undefined;
1056
+ });
1057
+ }
1058
+ }
1059
+ return this._http1Agent;
1060
+ }
1061
+ /**
1062
+ * Open a socket to `origin` through the configured proxy.
1063
+ *
1064
+ * The returned socket is fully established — tunneled, and TLS-wrapped when
1065
+ * the origin is `https:` — so it can be handed to a transport that requires
1066
+ * its connection synchronously.
1067
+ *
1068
+ * @param origin - Target origin, e.g. `https://api.example.com:443`.
1069
+ * @param req - Originating request, attached to any thrown error.
1070
+ * @returns A socket connected to the target through the proxy.
1071
+ */
1072
+ async _createProxiedSocket(origin, _tlsOpts, req) {
1073
+ const { connectThroughProxy } = await import("./proxy.js");
1074
+ const target = new URL(origin);
1075
+ return await connectThroughProxy(this._proxy, target, {
1076
+ ...(this._ca !== undefined ? { ca: this._ca } : {}),
1077
+ connectTimeoutMs: this._connectTimeoutMs,
1078
+ ...(req?.signal != null ? { signal: req.signal } : {}),
1079
+ ...(req !== undefined ? { request: req } : {}),
1080
+ });
1081
+ }
699
1082
  /** @internal Evict one session and its associated ping timer. */
700
1083
  _evictSession(origin, session) {
701
1084
  const timer = this.pingTimers.get(origin);
@@ -721,6 +1104,10 @@ export class NodeHTTP2Transport {
721
1104
  this._evictSession(origin, session);
722
1105
  }
723
1106
  this.sessions.clear();
1107
+ // Drain the legacy HTTP/1.1 keep-alive pool too, so a destroyed transport
1108
+ // leaves no idle sockets behind holding the event loop open.
1109
+ this._http1Agent?.destroy();
1110
+ this._http1Agent = null;
724
1111
  }
725
1112
  }
726
1113
  // ============================================================================
@@ -743,14 +1130,23 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
743
1130
  // "Custom fetch implementation" behavior holds on every runtime.
744
1131
  // Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
745
1132
  // fetch is given. Falls back to FetchTransport otherwise.
746
- if (IS_NODE && preferHTTP2 && fetchFn) {
1133
+ // A dispatcher belongs to the fetch implementation, exactly like a custom
1134
+ // fetch does — NodeHTTP2Transport speaks `node:http2` and has no notion of
1135
+ // one. So it forces the same fallback rather than being silently dropped.
1136
+ const needsFetchTransport = fetchFn !== undefined || transportOptions?.dispatcher !== undefined;
1137
+ if (IS_NODE && preferHTTP2 && needsFetchTransport) {
747
1138
  if (!isProductionEnvironment()) {
748
- console.warn('[kinetex] httpVersion: "HTTP/2" is ignored when a custom `fetch` is configured — ' +
749
- "NodeHTTP2Transport cannot use a custom fetch, so the request goes through " +
750
- "FetchTransport (HTTP/1.1 semantics). Drop the `fetch` option to use HTTP/2.");
1139
+ console.warn(fetchFn !== undefined
1140
+ ? '[kinetex] httpVersion: "HTTP/2" is ignored when a custom `fetch` is configured — ' +
1141
+ "NodeHTTP2Transport cannot use a custom fetch, so the request goes through " +
1142
+ "FetchTransport (HTTP/1.1 semantics). Drop the `fetch` option to use HTTP/2."
1143
+ : '[kinetex] httpVersion: "HTTP/2" is ignored when a `dispatcher` is configured — ' +
1144
+ "NodeHTTP2Transport talks to node:http2 directly and cannot use a fetch " +
1145
+ "dispatcher, so the request goes through FetchTransport (HTTP/1.1 semantics). " +
1146
+ "Drop the `dispatcher` option to use HTTP/2.");
751
1147
  }
752
1148
  }
753
- if (IS_NODE && preferHTTP2 && !fetchFn) {
1149
+ if (IS_NODE && preferHTTP2 && !needsFetchTransport) {
754
1150
  return new NodeHTTP2Transport({
755
1151
  ...(sessionOptions?.sessionTTLMs !== undefined
756
1152
  ? { sessionTTLMs: sessionOptions.sessionTTLMs }
@@ -758,6 +1154,17 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
758
1154
  ...(sessionOptions?.pingIntervalMs !== undefined
759
1155
  ? { pingIntervalMs: sessionOptions.pingIntervalMs }
760
1156
  : {}),
1157
+ // maxSessions was a documented transport option but was never forwarded
1158
+ // here, so the LRU cap was unreachable and the pool grew unbounded.
1159
+ ...(sessionOptions?.maxSessions !== undefined
1160
+ ? { maxSessions: sessionOptions.maxSessions }
1161
+ : {}),
1162
+ ...(sessionOptions?.http1KeepAlive !== undefined
1163
+ ? { http1KeepAlive: sessionOptions.http1KeepAlive }
1164
+ : {}),
1165
+ ...(sessionOptions?.http1MaxSockets !== undefined
1166
+ ? { http1MaxSockets: sessionOptions.http1MaxSockets }
1167
+ : {}),
761
1168
  ...(sessionOptions?.connectTimeoutMs !== undefined
762
1169
  ? { connectTimeoutMs: sessionOptions.connectTimeoutMs }
763
1170
  : {}),
@@ -765,9 +1172,14 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
765
1172
  ? { requestTimeoutMs: sessionOptions.requestTimeoutMs }
766
1173
  : {}),
767
1174
  ...(transportOptions?.strict !== undefined ? { strict: transportOptions.strict } : {}),
1175
+ ...(transportOptions?.allowPrivateNetwork !== undefined
1176
+ ? { allowPrivateNetwork: transportOptions.allowPrivateNetwork }
1177
+ : {}),
768
1178
  ...(transportOptions?.onDroppedHeader !== undefined
769
1179
  ? { onDroppedHeader: transportOptions.onDroppedHeader }
770
1180
  : {}),
1181
+ ...(transportOptions?.ca !== undefined ? { ca: transportOptions.ca } : {}),
1182
+ ...(transportOptions?.proxy !== undefined ? { proxy: transportOptions.proxy } : {}),
771
1183
  });
772
1184
  }
773
1185
  return new FetchTransport({
@@ -776,6 +1188,15 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
776
1188
  ...(transportOptions?.onDroppedHeader !== undefined
777
1189
  ? { onDroppedHeader: transportOptions.onDroppedHeader }
778
1190
  : {}),
1191
+ ...(transportOptions?.dispatcher !== undefined
1192
+ ? { dispatcher: transportOptions.dispatcher }
1193
+ : {}),
1194
+ // Reaches the pre-flight DNS screen in FetchTransport. It was accepted by
1195
+ // createTransport and then dropped, so every fetch-based runtime silently
1196
+ // had the screen off.
1197
+ ...(transportOptions?.allowPrivateNetwork !== undefined
1198
+ ? { allowPrivateNetwork: transportOptions.allowPrivateNetwork }
1199
+ : {}),
779
1200
  });
780
1201
  }
781
1202
  // ============================================================================
@@ -877,11 +1298,19 @@ export async function readRawBody(stream, maxBytes, _url, signal) {
877
1298
  const { done, value } = readResult;
878
1299
  if (done)
879
1300
  break;
1301
+ // Same policy as the readers in response.ts, reached through the same
1302
+ // helper. The error class stays this entry point's own: `SizeLimitError`
1303
+ // is a `KinetexError` carrying code `ESIZELIMIT`, where the response
1304
+ // readers raise `ResponseSizeLimitError`, which is a bare `Error` with the
1305
+ // same code. Both are public and documented, and merging them would be a
1306
+ // breaking change, so the factory is passed in rather than shared.
880
1307
  if (maxBytes > 0) {
881
- const newTotal = total + value.byteLength;
882
- if (newTotal > maxBytes) {
1308
+ try {
1309
+ limitAction(value, total, { maxBytes }, "throw", (n, max) => new SizeLimitError(n, max));
1310
+ }
1311
+ catch (err) {
883
1312
  await reader.cancel("size limit exceeded");
884
- throw new SizeLimitError(newTotal, maxBytes);
1313
+ throw err;
885
1314
  }
886
1315
  }
887
1316
  chunks.push(value);
@@ -937,6 +1366,19 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
937
1366
  if (result.success && result.value !== undefined) {
938
1367
  parseResult = result.value;
939
1368
  }
1369
+ else if (!result.success) {
1370
+ // `safeJSONParse` refuses a payload for a reason it can name — a
1371
+ // depth, a length, a key count, a prototype-pollution key — and that
1372
+ // reason was dropped on the floor, so the caller was told "JSON parse
1373
+ // failed" for a body that is perfectly valid JSON and merely larger
1374
+ // than the limits this function chose. A response that quietly changes
1375
+ // from parsed to raw text is worth one specific sentence about which
1376
+ // limit it crossed, and the code is on the error so a handler can
1377
+ // branch on it.
1378
+ const failure = new Error(`JSON body rejected: ${result.message ?? "parse failed"} — falling back to raw text`);
1379
+ Object.assign(failure, { code: result.error ?? "EPARSE" });
1380
+ parseError = failure;
1381
+ }
940
1382
  }
941
1383
  catch (e) {
942
1384
  parseError = e instanceof Error ? e : new Error(String(e));
@@ -964,7 +1406,98 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
964
1406
  // §9 UTILITIES
965
1407
  // ============================================================================
966
1408
  // FIX 12: normalizeHeaders centralised in utils.ts.
1409
+ import { lookup as dnsLookup } from "node:dns";
1410
+ import { isIP } from "node:net";
1411
+ /** One instance, shared: it holds no per-request state. */
1412
+ const PINNED_LOOKUP = createPinnedLookup();
1413
+ /**
1414
+ * A `lookup` for node:http and node:http2 that closes the DNS-rebinding gap.
1415
+ *
1416
+ * `isSafeURL` reads the hostname the caller wrote. The socket, however, is
1417
+ * dialled at whatever that hostname *resolves to*, and nothing between the two
1418
+ * points re-checked the answer — so a name that resolves to a public address on
1419
+ * the first lookup and to `127.0.0.1` on the second reached a private service.
1420
+ * That is DNS rebinding, and it needs an attacker-controlled resolver, which is
1421
+ * an ordinary capability for whoever controls the domain being requested.
1422
+ *
1423
+ * Resolving here and pinning the result closes it for the connection that
1424
+ * follows: every address the resolver returns is checked against the same
1425
+ * blocklist `isSafeURL` applies to a literal, and only a checked address is
1426
+ * handed back for the dial. A name that resolves solely to private space fails
1427
+ * instead of connecting.
1428
+ *
1429
+ * The alternative — checking the hostname again after connecting — is too late:
1430
+ * the request is already on the wire.
1431
+ *
1432
+ * `dns.lookup` (not `resolve`) is used deliberately: it honours `/etc/hosts`,
1433
+ * the resolver search list and `dns.setDefaultResultOrder`, so a host pinned in
1434
+ * the system file resolves the way it would have without this, and the address
1435
+ * handed back is the one the socket would have reached anyway.
1436
+ */
1437
+ /**
1438
+ * Drop every resolved address that falls in a blocked range.
1439
+ *
1440
+ * Exported separately from `createPinnedLookup` so the decision can be tested
1441
+ * against addresses a resolver would have to be trusted to produce: a name
1442
+ * that answers with a public address and a private one is the rebinding case,
1443
+ * and it cannot be arranged offline through a real resolver.
1444
+ */
1445
+ export function filterSafeAddresses(addresses) {
1446
+ return addresses.filter((a) => a.family !== 0 && !isBlockedAddress(a.address));
1447
+ }
1448
+ /**
1449
+ * The refusal a host whose every resolved address is blocked earns.
1450
+ *
1451
+ * Split out from {@link FetchTransport} so the message and code can be
1452
+ * asserted without arranging a resolver that answers with a blocked address.
1453
+ */
1454
+ export function blockedHostError(url, addresses) {
1455
+ return new KinetexError(`URL "${url}" resolved only to blocked addresses (${addresses.map((a) => a.address).join(", ") || "none"})`, "EBLOCKEDHOST");
1456
+ }
1457
+ export function createPinnedLookup() {
1458
+ return (hostname, options, callback) => {
1459
+ // Node's declared callback takes an address even on failure, and it ignores
1460
+ // it — callers branch on `err` first. Node's own resolvers call back with
1461
+ // the error alone, so this matches them rather than inventing a bogus
1462
+ // address.
1463
+ const fail = (err) => {
1464
+ callback(err);
1465
+ };
1466
+ // `all: true` so every candidate is inspected. Node sets it when
1467
+ // `autoSelectFamily` is on (the default since Node 20), in which case the
1468
+ // callback expects an array; when it is not, a single address is expected.
1469
+ const wantsAll = options.all === true;
1470
+ // Typed as `LookupAllOptions` so TypeScript selects the overload that
1471
+ // yields `LookupAddress[]`. Left as a plain object literal it picks the
1472
+ // single-address overload, and `.filter` below does not exist on a string.
1473
+ const resolved = { ...options, all: true };
1474
+ dnsLookup(hostname, resolved, (err, addresses) => {
1475
+ if (err) {
1476
+ fail(err);
1477
+ return;
1478
+ }
1479
+ const safe = filterSafeAddresses(addresses ?? []);
1480
+ if (safe.length === 0) {
1481
+ // Report it as a name-resolution failure rather than a connection
1482
+ // error: nothing was dialled, and the distinction matters to a caller
1483
+ // deciding whether to retry or to fail over.
1484
+ const blocked = (addresses ?? []).map((a) => a.address).join(", ") || "no addresses";
1485
+ const e = new Error(`Refusing to connect to ${hostname}: it resolves only to a blocked address (${blocked})`);
1486
+ e.code = "EBLOCKEDHOST";
1487
+ fail(e);
1488
+ return;
1489
+ }
1490
+ if (wantsAll) {
1491
+ callback(null, safe);
1492
+ return;
1493
+ }
1494
+ const first = safe[0];
1495
+ callback(null, first.address, first.family);
1496
+ });
1497
+ };
1498
+ }
967
1499
  import { normalizeHeaders as _normalizeHeaders } from "./utils.js";
1500
+ import { limitAction } from "./response.js";
968
1501
  /**
969
1502
  * Normalize a Headers object to a plain record.
970
1503
  *
@@ -984,29 +1517,52 @@ export function normalizeHeaders(headers) {
984
1517
  * @param _headers - Parsed response headers (reserved)
985
1518
  * @returns Detected HTTP version
986
1519
  */
1520
+ /**
1521
+ * Translate a runtime-reported protocol string into the {@link HTTPVersion}
1522
+ * union, or return null when it says nothing this library can act on.
1523
+ *
1524
+ * Accepts the spellings Deno and Bun actually use ("2", "2.0", "1", "1.0",
1525
+ * "1.1") plus the union's own members, case- and whitespace-insensitively, so a
1526
+ * peer cannot widen the field to a string by adding a prefix.
1527
+ */
1528
+ function normalizeHTTPVersion(raw) {
1529
+ const v = raw.trim().toLowerCase();
1530
+ if (v === "2" || v === "2.0" || v === "h2" || v === "http/2" || v === "http/2.0")
1531
+ return "HTTP/2";
1532
+ if (v === "1" || v === "1.0" || v === "http/1" || v === "http/1.0")
1533
+ return "HTTP/1.0";
1534
+ if (v === "1.1" || v === "http/1.1")
1535
+ return "HTTP/1.1";
1536
+ return null;
1537
+ }
987
1538
  function detectHTTPVersion(response, _headers) {
988
- // Deno exposes response.type or we can infer from headers
989
- // Runtime-specific property access requires type assertion
1539
+ // Deno and Bun both expose `httpVersion` on the Response, and both spell it
1540
+ // their own way: Deno answers "2.0", Bun answers "1.1" and "2". The Deno arm
1541
+ // translated its values; the Bun arm returned whatever it was given, so a
1542
+ // plain HTTP/1.1 response on Bun reported the string "1.1" — a value outside
1543
+ // the `HTTPVersion` union, reaching every caller of `res.httpVersion` through
1544
+ // a `[[typed]]` lie. A consumer switching on "HTTP/1.1" silently fell through,
1545
+ // and on a runtime the library does not run in CI the type checker is the only
1546
+ // thing that would have said so. Both arms now go through one translation, and
1547
+ // anything unrecognised falls through to the evidence below rather than being
1548
+ // reported as a protocol.
990
1549
  const denoResponse = response;
991
- if (denoResponse.httpVersion === "2.0" || denoResponse.httpVersion === "2") {
992
- return "HTTP/2";
993
- }
994
- // Bun exposes httpVersion as a property
995
- const bunResponse = response;
996
- if (bunResponse.httpVersion) {
997
- return bunResponse.httpVersion;
1550
+ const runtimeVersion = denoResponse.httpVersion;
1551
+ if (typeof runtimeVersion === "string") {
1552
+ const normalized = normalizeHTTPVersion(runtimeVersion);
1553
+ if (normalized)
1554
+ return normalized;
998
1555
  }
999
- // HTTP/3 (QUIC) detection via Alt-Svc header.
1556
+ // Server capability advertisement.
1000
1557
  // Servers that support HTTP/3 advertise: Alt-Svc: h3="...", h3-29="..."
1001
- // We detect the advertisement here and update accordingly.
1558
+ //
1559
+ // kinetex does not speak HTTP/3, and no runtime it targets has a stable
1560
+ // HTTP/3 client, so an h3 advertisement is deliberately NOT reported as
1561
+ // HTTP/3: the response in hand was served over HTTP/2, and saying otherwise
1562
+ // would misreport the protocol actually used. A runtime that ever does
1563
+ // negotiate h3 itself reaches the same answer below rather than claiming a
1564
+ // version the transport cannot produce.
1002
1565
  const altSvc = response.headers.get("alt-svc");
1003
- // Check for active HTTP/3 negotiation (runtime-specific property)
1004
- const h3Response = response;
1005
- if (h3Response.httpVersion === "3" ||
1006
- h3Response.httpVersion === "3.0" ||
1007
- h3Response.protocol === "h3") {
1008
- return "HTTP/3";
1009
- }
1010
1566
  // Alt-Svc advertisement: infer HTTP version from the advertised protocols.
1011
1567
  // - h3 (QUIC) means the server supports HTTP/3
1012
1568
  // - h2 means the server supports HTTP/2
@@ -1154,7 +1710,7 @@ async function attachBodyToH2Stream(stream, body) {
1154
1710
  // The raw Node transports bypass fetch, so bodies fetch would normally
1155
1711
  // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1156
1712
  // silently sent an empty body to the server.
1157
- const bytes = await serializeRawBody(body);
1713
+ const { bytes } = await serializeRawBody(body);
1158
1714
  stream.end(bytes);
1159
1715
  }
1160
1716
  }
@@ -1188,7 +1744,7 @@ async function pipeBodyToNodeReq(req, body) {
1188
1744
  // The raw Node transports bypass fetch, so bodies fetch would normally
1189
1745
  // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1190
1746
  // silently sent an empty body to the server.
1191
- const bytes = await serializeRawBody(body);
1747
+ const { bytes } = await serializeRawBody(body);
1192
1748
  req.end(bytes);
1193
1749
  }
1194
1750
  }
@@ -1197,16 +1753,80 @@ async function pipeBodyToNodeReq(req, body) {
1197
1753
  * Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
1198
1754
  *
1199
1755
  * @param body - Request body that is not a stream, byte array, or string
1200
- * @returns The encoded bytes to write (empty for unsupported types)
1756
+ * @returns The encoded bytes to write, plus a content type to announce when the
1757
+ * encoding generated one (empty bytes for unsupported types)
1201
1758
  */
1202
1759
  async function serializeRawBody(body) {
1203
1760
  if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
1204
- return new TextEncoder().encode(body.toString());
1761
+ return { bytes: new TextEncoder().encode(body.toString()) };
1205
1762
  }
1206
1763
  if (typeof Blob !== "undefined" && body instanceof Blob) {
1207
- return new Uint8Array(await body.arrayBuffer());
1764
+ return { bytes: new Uint8Array(await body.arrayBuffer()) };
1765
+ }
1766
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
1767
+ const { bytes, boundary } = await encodeMultipart(body);
1768
+ return { bytes, contentType: `multipart/form-data; boundary=${boundary}` };
1769
+ }
1770
+ return { bytes: new Uint8Array(0) };
1771
+ }
1772
+ /**
1773
+ * Escape a multipart field name or filename for a `Content-Disposition`
1774
+ * parameter. RFC 7578 §5.1 percent-encodes CR, LF and a double quote; a bare
1775
+ * backslash is escaped too so a name cannot terminate the quoted string early.
1776
+ */
1777
+ function escapeFieldName(name) {
1778
+ return name.replace(/[\r\n"\\]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
1779
+ }
1780
+ /**
1781
+ * Encode a `FormData` as `multipart/form-data` (RFC 7578).
1782
+ *
1783
+ * The function this lives in was written to stop the raw Node transports from
1784
+ * sending an *empty* body for the types fetch would have serialized, and it
1785
+ * covered `URLSearchParams` and `Blob`. `FormData` was missed, so on the
1786
+ * default transport on Node — `NodeHTTP2Transport`, which bypasses fetch
1787
+ * entirely — a form upload went out as a request with no body at all and no
1788
+ * `Content-Type`, and the server recorded an empty form. The response was an
1789
+ * ordinary 200, so nothing looked wrong.
1790
+ *
1791
+ * The boundary is generated per call and written into the body. The transports
1792
+ * that call this only ever see bytes, so the header is set by the client from
1793
+ * `options.headers`; a body encoded here without a matching header is
1794
+ * unparseable, which is why the boundary is returned alongside the bytes for a
1795
+ * caller that has to announce it. Passing one in makes the output
1796
+ * deterministic, which is what the test suite pins.
1797
+ */
1798
+ export async function encodeMultipart(form, boundary) {
1799
+ // 24 random bytes as hex: 192 bits is far past any collision concern, and hex
1800
+ // is all legal in a boundary.
1801
+ const bnd = boundary ?? `----kinetexFormBoundary${randomBytes(24)}`;
1802
+ const encoder = new TextEncoder();
1803
+ const chunks = [];
1804
+ const push = (text) => {
1805
+ chunks.push(encoder.encode(text));
1806
+ };
1807
+ for (const [name, value] of form.entries()) {
1808
+ // A field name is application- and sometimes user-controlled, and CR/LF in
1809
+ // one would forge a part header. Such a name is refused rather than sent
1810
+ // or silently dropped.
1811
+ if (/[\r\n"]/.test(name)) {
1812
+ throw new KinetexError(`Cannot send a multipart field whose name contains CR, LF or a quote: ${JSON.stringify(name)}`, "EVALIDATION");
1813
+ }
1814
+ push(`--${bnd}\r\n`);
1815
+ if (typeof value === "string") {
1816
+ push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"\r\n\r\n`);
1817
+ push(value);
1818
+ push("\r\n");
1819
+ continue;
1820
+ }
1821
+ // A File/Blob part: RFC 7578 §4.2 wants its own type and filename.
1822
+ push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"; ` +
1823
+ `filename="${escapeFieldName(value.name || "blob")}"\r\n` +
1824
+ `Content-Type: ${value.type || "application/octet-stream"}\r\n\r\n`);
1825
+ chunks.push(new Uint8Array(await value.arrayBuffer()));
1826
+ push("\r\n");
1208
1827
  }
1209
- return new Uint8Array(0);
1828
+ push(`--${bnd}--\r\n`);
1829
+ return { bytes: concatUint8Arrays(chunks), boundary: bnd };
1210
1830
  }
1211
1831
  // ============================================================================
1212
1832
  // §10 DECOMPRESSION
@@ -1216,8 +1836,9 @@ async function serializeRawBody(body) {
1216
1836
  * Dynamically imports response.ts so that environments that don't use
1217
1837
  * decompression don't pay the code cost. The import is cached by the runtime.
1218
1838
  *
1219
- * Supported encodings: gzip, deflate, br (brotli)
1220
- * Unsupported encodings (zstd, etc.) are passed through compressed; caller must handle or error.
1839
+ * Supported encodings: gzip, deflate, br (brotli), zstd.
1840
+ * Unsupported encodings are passed through compressed; the caller must handle
1841
+ * them.
1221
1842
  *
1222
1843
  * @param body - Raw body stream (or null)
1223
1844
  * @param headers - Response headers (content-encoding is read and stripped on success)
@@ -1236,7 +1857,7 @@ export async function decompressBodyStream(body, headers) {
1236
1857
  .map((e) => e.trim())
1237
1858
  .filter(Boolean);
1238
1859
  // Check for unsupported encodings
1239
- const supportedEncodings = ["gzip", "deflate", "br", "identity"];
1860
+ const supportedEncodings = ["gzip", "deflate", "br", "zstd", "identity"];
1240
1861
  for (const enc of encodings) {
1241
1862
  if (!supportedEncodings.includes(enc)) {
1242
1863
  console.warn(`[Kinetex] Unsupported Content-Encoding: ${enc}. ` +