kinetex 1.4.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 (95) hide show
  1. package/README.md +170 -46
  2. package/dist/browser/kinetex.esm.js +29 -29
  3. package/dist/browser/kinetex.js +1143 -791
  4. package/dist/browser/kinetex.min.js +29 -29
  5. package/dist/cjs/aws-sigv4.js +7 -14
  6. package/dist/cjs/cache.js +9 -0
  7. package/dist/cjs/circuit-breaker.js +8 -1
  8. package/dist/cjs/client.js +350 -95
  9. package/dist/cjs/cookie-parser.js +28 -1
  10. package/dist/cjs/core.js +227 -5
  11. package/dist/cjs/dedup.js +93 -10
  12. package/dist/cjs/digest.js +36 -15
  13. package/dist/cjs/graphql.js +2 -11
  14. package/dist/cjs/headers.js +24 -1
  15. package/dist/cjs/interceptors.js +6 -17
  16. package/dist/cjs/lifecycle.js +3 -19
  17. package/dist/cjs/logging.js +12 -29
  18. package/dist/cjs/progress.js +1 -3
  19. package/dist/cjs/proxy.js +19 -2
  20. package/dist/cjs/response.js +202 -75
  21. package/dist/cjs/socks5.js +5 -0
  22. package/dist/cjs/utils.js +115 -27
  23. package/dist/cjs/ws.js +43 -2
  24. package/dist/esm/aws-sigv4.js +7 -14
  25. package/dist/esm/aws-sigv4.js.map +1 -1
  26. package/dist/esm/cache.js +9 -0
  27. package/dist/esm/cache.js.map +1 -1
  28. package/dist/esm/circuit-breaker.js +8 -1
  29. package/dist/esm/circuit-breaker.js.map +1 -1
  30. package/dist/esm/client.js +350 -95
  31. package/dist/esm/client.js.map +1 -1
  32. package/dist/esm/cookie-parser.js +28 -1
  33. package/dist/esm/cookie-parser.js.map +1 -1
  34. package/dist/esm/core.js +227 -5
  35. package/dist/esm/core.js.map +1 -1
  36. package/dist/esm/dedup.js +93 -10
  37. package/dist/esm/dedup.js.map +1 -1
  38. package/dist/esm/digest.js +36 -15
  39. package/dist/esm/digest.js.map +1 -1
  40. package/dist/esm/graphql.js +2 -11
  41. package/dist/esm/graphql.js.map +1 -1
  42. package/dist/esm/headers.js +24 -1
  43. package/dist/esm/headers.js.map +1 -1
  44. package/dist/esm/interceptors.js +6 -17
  45. package/dist/esm/interceptors.js.map +1 -1
  46. package/dist/esm/lifecycle.js +3 -19
  47. package/dist/esm/lifecycle.js.map +1 -1
  48. package/dist/esm/logging.js +12 -29
  49. package/dist/esm/logging.js.map +1 -1
  50. package/dist/esm/progress.js +1 -3
  51. package/dist/esm/progress.js.map +1 -1
  52. package/dist/esm/proxy.js +19 -2
  53. package/dist/esm/proxy.js.map +1 -1
  54. package/dist/esm/response.js +202 -75
  55. package/dist/esm/response.js.map +1 -1
  56. package/dist/esm/socks5.js +5 -0
  57. package/dist/esm/socks5.js.map +1 -1
  58. package/dist/esm/types.js.map +1 -1
  59. package/dist/esm/url.js.map +1 -1
  60. package/dist/esm/utils.js +115 -27
  61. package/dist/esm/utils.js.map +1 -1
  62. package/dist/esm/ws.js +43 -2
  63. package/dist/esm/ws.js.map +1 -1
  64. package/dist/types/aws-sigv4.d.ts.map +1 -1
  65. package/dist/types/cache.d.ts.map +1 -1
  66. package/dist/types/circuit-breaker.d.ts +1 -1
  67. package/dist/types/circuit-breaker.d.ts.map +1 -1
  68. package/dist/types/client.d.ts.map +1 -1
  69. package/dist/types/cookie-parser.d.ts.map +1 -1
  70. package/dist/types/core.d.ts +113 -0
  71. package/dist/types/core.d.ts.map +1 -1
  72. package/dist/types/dedup.d.ts +38 -3
  73. package/dist/types/dedup.d.ts.map +1 -1
  74. package/dist/types/digest.d.ts.map +1 -1
  75. package/dist/types/graphql.d.ts.map +1 -1
  76. package/dist/types/headers.d.ts +23 -4
  77. package/dist/types/headers.d.ts.map +1 -1
  78. package/dist/types/interceptors.d.ts +24 -38
  79. package/dist/types/interceptors.d.ts.map +1 -1
  80. package/dist/types/lifecycle.d.ts.map +1 -1
  81. package/dist/types/logging.d.ts.map +1 -1
  82. package/dist/types/progress.d.ts.map +1 -1
  83. package/dist/types/proxy.d.ts.map +1 -1
  84. package/dist/types/response.d.ts +42 -1
  85. package/dist/types/response.d.ts.map +1 -1
  86. package/dist/types/socks5.d.ts.map +1 -1
  87. package/dist/types/types.d.ts +105 -23
  88. package/dist/types/types.d.ts.map +1 -1
  89. package/dist/types/url.d.ts +0 -11
  90. package/dist/types/url.d.ts.map +1 -1
  91. package/dist/types/utils.d.ts +69 -1
  92. package/dist/types/utils.d.ts.map +1 -1
  93. package/dist/types/ws.d.ts +0 -1
  94. package/dist/types/ws.d.ts.map +1 -1
  95. package/package.json +10 -4
@@ -1567,10 +1567,21 @@ function decodePunycode(input) {
1567
1567
  const initialN = 0x80;
1568
1568
  const delim = input.lastIndexOf("-");
1569
1569
  const output = [];
1570
+ // RFC 3492 §6.2: the basic part is everything before the *last* delimiter,
1571
+ // so it may contain any basic code point — "-" included. Testing it for
1572
+ // alphanumerics only rejected legal input: "xn--abc-def-" and "xn--a--"
1573
+ // both carry a hyphen inside their basic part, and were returned
1574
+ // undecoded. That left the A-label and its U-label looking like two
1575
+ // different cookie domains, so one host could be registered twice.
1576
+ //
1577
+ // The decoder only ever inserts code points >= initialN (0x80), so the
1578
+ // result can never contain a "." and cannot re-split the domain. Control
1579
+ // characters and non-ASCII are still refused, since neither is a basic
1580
+ // code point and both would be unsafe in a canonicalized cookie domain.
1570
1581
  const basic = delim >= 0 ? input.slice(0, delim) : "";
1571
1582
  for (let j = 0; j < basic.length; j++) {
1572
1583
  const cp = basic.charCodeAt(j);
1573
- if (cp > 0x7a || (cp > 0x5a && cp < 0x61) || (cp > 0x39 && cp < 0x41) || cp < 0x30)
1584
+ if (cp >= 0x80 || cp < 0x20 || cp === 0x7f)
1574
1585
  return input;
1575
1586
  output.push(cp);
1576
1587
  }
@@ -1647,6 +1658,22 @@ let _domainToUnicode;
1647
1658
  * @returns Decoded label (e.g., "中国") or original if not punycode
1648
1659
  */
1649
1660
  export function decodeIDNLabel(label) {
1661
+ // A dotted input is a domain, not a single label. Split it and decode each
1662
+ // label, so a full domain answers the same however many labels it has and
1663
+ // whichever decoder ends up handling it.
1664
+ //
1665
+ // This has to happen before the `xn--` test below. Previously a dotted input
1666
+ // that did not *begin* with `xn--` — "sub.xn--bcdf-zna1d.co.uk" — returned
1667
+ // untouched, and one that did was handed whole to the single-label decoder,
1668
+ // which failed on the dot. Whether the lazily-imported `node:url` helper had
1669
+ // resolved decided which of those happened, so the same input could decode or
1670
+ // not depending on module load timing.
1671
+ if (label.includes(".")) {
1672
+ return label
1673
+ .split(".")
1674
+ .map((part) => decodeIDNLabel(part))
1675
+ .join(".");
1676
+ }
1650
1677
  if (!label.toLowerCase().startsWith("xn--")) {
1651
1678
  return label;
1652
1679
  }
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, isSafeURL, randomBytes, } 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
@@ -137,6 +137,13 @@ export class FetchTransport {
137
137
  strict;
138
138
  onDroppedHeader;
139
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;
140
147
  /**
141
148
  * @param fetchFnOrOptions - Custom fetch function or options object
142
149
  */
@@ -146,13 +153,75 @@ export class FetchTransport {
146
153
  this.strict = false;
147
154
  this.onDroppedHeader = undefined;
148
155
  this.dispatcher = undefined;
156
+ this.requestTimeoutMs = undefined;
157
+ this._allowPrivateNetwork = false;
149
158
  }
150
159
  else {
151
160
  this.fetchFn = fetchFnOrOptions.fetchFn ?? globalThis.fetch;
152
161
  this.strict = fetchFnOrOptions.strict ?? false;
153
162
  this.onDroppedHeader = fetchFnOrOptions.onDroppedHeader;
154
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.
155
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;
220
+ }
221
+ const safe = filterSafeAddresses(addresses);
222
+ if (safe.length > 0)
223
+ return;
224
+ throw blockedHostError(url, addresses);
156
225
  }
157
226
  /**
158
227
  * Send a request via the native fetch() API.
@@ -165,6 +234,7 @@ export class FetchTransport {
165
234
  * @returns Raw response from the server
166
235
  */
167
236
  async send(req) {
237
+ await this.assertReachableHost(req.url);
168
238
  // Validate and sanitize headers to prevent injection attacks
169
239
  const sanitizedHeaders = {};
170
240
  for (const [name, value] of Object.entries(req.headers ?? {})) {
@@ -236,16 +306,48 @@ export class FetchTransport {
236
306
  if (this.dispatcher !== undefined) {
237
307
  init.dispatcher = this.dispatcher;
238
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;
239
329
  let response;
240
330
  try {
241
- response = await this.fetchFn(req.url, init);
331
+ response = await this.fetchFn(req.url, initWithSignal);
242
332
  }
243
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
+ }
244
342
  if (isAbortError(err)) {
245
343
  throw new KinetexError("Request was aborted", "EABORT", { request: req, cause: err });
246
344
  }
247
345
  throw new KinetexError(err instanceof Error ? err.message : "Network request failed", "ENETWORK", { request: req, cause: err });
248
346
  }
347
+ if (timer !== undefined)
348
+ clearTimeout(timer);
349
+ if (timeoutCtl && req.signal)
350
+ req.signal.removeEventListener("abort", onCallerAbort);
249
351
  const headers = normalizeHeaders(response.headers);
250
352
  const httpVersion = detectHTTPVersion(response, headers);
251
353
  // Determine if the runtime already decompressed the body.
@@ -319,6 +421,8 @@ export class NodeHTTP2Transport {
319
421
  * sockets, which is a lot to hold open for a legacy fallback), and gives
320
422
  * callers a way to opt out.
321
423
  */
424
+ /** Whether to permit connections into private/loopback address space. */
425
+ _allowPrivateNetwork;
322
426
  _http1KeepAlive;
323
427
  _http1MaxSockets;
324
428
  _http1Agent = null;
@@ -331,6 +435,7 @@ export class NodeHTTP2Transport {
331
435
  this.sessionTTLMs = options.sessionTTLMs ?? 5 * 60_000;
332
436
  this.pingIntervalMs = options.pingIntervalMs ?? 30_000;
333
437
  this.maxSessions = options.maxSessions ?? 100;
438
+ this._allowPrivateNetwork = options.allowPrivateNetwork ?? false;
334
439
  this._http1KeepAlive = options.http1KeepAlive ?? true;
335
440
  this._http1MaxSockets = options.http1MaxSockets ?? 16;
336
441
  this._proxy = options.proxy;
@@ -411,6 +516,9 @@ export class NodeHTTP2Transport {
411
516
  const session = await new Promise((resolve, reject) => {
412
517
  const s = http2.connect(origin, {
413
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 }),
414
522
  ...(this._ca !== undefined ? { ca: this._ca } : {}),
415
523
  // Already connected (and already TLS-wrapped for an https origin),
416
524
  // so node:http2 must use it as-is rather than negotiating again.
@@ -813,6 +921,10 @@ export class NodeHTTP2Transport {
813
921
  const opts = {
814
922
  fetchFn: globalThis.fetch,
815
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,
816
928
  };
817
929
  if (this._onDroppedHeader !== undefined)
818
930
  opts.onDroppedHeader = this._onDroppedHeader;
@@ -855,6 +967,8 @@ export class NodeHTTP2Transport {
855
967
  // private or self-signed peer could not be reached without disabling
856
968
  // verification process-wide.
857
969
  ...(this._ca !== undefined ? { ca: this._ca } : {}),
970
+ // Same reason as the HTTP/2 path above.
971
+ ...(this._allowPrivateNetwork ? {} : { lookup: PINNED_LOOKUP }),
858
972
  };
859
973
  return new Promise((resolve, reject) => {
860
974
  const httpReq = https.request(options, (httpRes) => {
@@ -1058,6 +1172,9 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
1058
1172
  ? { requestTimeoutMs: sessionOptions.requestTimeoutMs }
1059
1173
  : {}),
1060
1174
  ...(transportOptions?.strict !== undefined ? { strict: transportOptions.strict } : {}),
1175
+ ...(transportOptions?.allowPrivateNetwork !== undefined
1176
+ ? { allowPrivateNetwork: transportOptions.allowPrivateNetwork }
1177
+ : {}),
1061
1178
  ...(transportOptions?.onDroppedHeader !== undefined
1062
1179
  ? { onDroppedHeader: transportOptions.onDroppedHeader }
1063
1180
  : {}),
@@ -1074,6 +1191,12 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
1074
1191
  ...(transportOptions?.dispatcher !== undefined
1075
1192
  ? { dispatcher: transportOptions.dispatcher }
1076
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
+ : {}),
1077
1200
  });
1078
1201
  }
1079
1202
  // ============================================================================
@@ -1175,11 +1298,19 @@ export async function readRawBody(stream, maxBytes, _url, signal) {
1175
1298
  const { done, value } = readResult;
1176
1299
  if (done)
1177
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.
1178
1307
  if (maxBytes > 0) {
1179
- const newTotal = total + value.byteLength;
1180
- if (newTotal > maxBytes) {
1308
+ try {
1309
+ limitAction(value, total, { maxBytes }, "throw", (n, max) => new SizeLimitError(n, max));
1310
+ }
1311
+ catch (err) {
1181
1312
  await reader.cancel("size limit exceeded");
1182
- throw new SizeLimitError(newTotal, maxBytes);
1313
+ throw err;
1183
1314
  }
1184
1315
  }
1185
1316
  chunks.push(value);
@@ -1275,7 +1406,98 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
1275
1406
  // §9 UTILITIES
1276
1407
  // ============================================================================
1277
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
+ }
1278
1499
  import { normalizeHeaders as _normalizeHeaders } from "./utils.js";
1500
+ import { limitAction } from "./response.js";
1279
1501
  /**
1280
1502
  * Normalize a Headers object to a plain record.
1281
1503
  *
package/dist/cjs/dedup.js CHANGED
@@ -19,6 +19,81 @@
19
19
  // ============================================================================
20
20
  // §2 DEDUP MAP
21
21
  // ============================================================================
22
+ /**
23
+ * Headers that are per-request noise rather than part of a request's identity.
24
+ *
25
+ * Two callers sending different trace ids are the same request as far as the
26
+ * response is concerned, so including them would only cost the coalescing that
27
+ * dedup exists to provide. Everything else — `Authorization`, `Cookie`,
28
+ * `Accept`, any custom header a gateway keys on — participates, so a request is
29
+ * only ever coalesced with one that would have received the same response.
30
+ */
31
+ /**
32
+ * The default dedup key: method, URL, and the identity-bearing headers.
33
+ *
34
+ * Sorting makes the key order-independent, since two requests that differ only
35
+ * in header order are the same request. Values are included verbatim: a caller
36
+ * that wants coarser or finer granularity supplies a `keyFn`.
37
+ */
38
+ /**
39
+ * Headers excluded from a request's identity, as with {@link defaultDedupKey}.
40
+ * Duplicated rather than imported so `dedup.ts` stays dependency-free; the two
41
+ * lists must be kept in step.
42
+ */
43
+ const NON_IDENTITY_HEADERS = [
44
+ "traceparent",
45
+ "tracestate",
46
+ "baggage",
47
+ "x-request-id",
48
+ "x-correlation-id",
49
+ "x-trace-id",
50
+ ];
51
+ /**
52
+ * A stable, opaque digest of everything that makes a request distinct.
53
+ *
54
+ * Hashing rather than concatenating keeps credentials out of the key: a dedup
55
+ * map is a long-lived map of strings, and its keys should not be a place where
56
+ * an `Authorization` header sits in plain text. Returns `""` when no header
57
+ * distinguishes the request, so the common unauthenticated case keys on method
58
+ * and URL alone.
59
+ */
60
+ export async function identityFingerprint(headers) {
61
+ if (headers === undefined)
62
+ return "";
63
+ const lowered = new Map();
64
+ for (const [name, value] of Object.entries(headers)) {
65
+ lowered.set(name.toLowerCase(), value);
66
+ }
67
+ const parts = [];
68
+ for (const name of NON_IDENTITY_HEADERS)
69
+ lowered.delete(name);
70
+ for (const name of [...lowered.keys()].sort()) {
71
+ parts.push(`${name}=${lowered.get(name)}`);
72
+ }
73
+ if (parts.length === 0)
74
+ return "";
75
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("|")));
76
+ return Array.from(new Uint8Array(digest))
77
+ .map((b) => b.toString(16).padStart(2, "0"))
78
+ .join("");
79
+ }
80
+ /**
81
+ * Build a dedup key from a method, URL and identity digest.
82
+ */
83
+ export function dedupKeyFrom(method, url, fingerprint) {
84
+ return fingerprint ? `${method}:${url}:${fingerprint}` : `${method}:${url}`;
85
+ }
86
+ export function defaultDedupKey(method, url, headers) {
87
+ if (headers === undefined)
88
+ return `${method}::${url}`;
89
+ const identity = Object.entries(headers)
90
+ .filter(([name]) => !NON_IDENTITY_HEADERS.includes(name.toLowerCase()))
91
+ .map(([name, value]) => `${name.toLowerCase()}=${value}`)
92
+ .sort();
93
+ // No distinguishing header means the key stays method + URL, so an empty
94
+ // header set does not silently become a different key shape.
95
+ return identity.length === 0 ? `${method}::${url}` : `${method}::${url}::${identity.join("&")}`;
96
+ }
22
97
  /**
23
98
  * `DedupMap` manages in-flight promises keyed by a request fingerprint.
24
99
  * It is transport-agnostic — pass any async factory function.
@@ -26,6 +101,9 @@
26
101
  export class DedupMap {
27
102
  inflight = new Map();
28
103
  timeouts = new Map();
104
+ /** Window timers, overridable for tests. See {@link DedupOptions.timers}. */
105
+ _setTimeout;
106
+ _clearTimeout;
29
107
  windowMs;
30
108
  methods;
31
109
  keyFn;
@@ -39,8 +117,10 @@ export class DedupMap {
39
117
  constructor(options = {}) {
40
118
  this.windowMs = options.windowMs ?? 0;
41
119
  this.methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()));
42
- this.keyFn = options.keyFn ?? ((m, url) => `${m}::${url}`);
120
+ this.keyFn = options.keyFn ?? defaultDedupKey;
43
121
  this._globalSignal = options.signal ?? null;
122
+ this._setTimeout = options.timers?.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
123
+ this._clearTimeout = options.timers?.clearTimeout ?? ((h) => clearTimeout(h));
44
124
  }
45
125
  /**
46
126
  * Execute `factory` for the given method/url, or return the in-flight
@@ -64,9 +144,12 @@ export class DedupMap {
64
144
  * entry and creating a new one. The first caller becomes the "leader" and all
65
145
  * subsequent callers within the same tick share the same promise.
66
146
  *
67
- * Note: The default `keyFn` does NOT include headers in the deduplication key.
68
- * Two requests with different auth tokens to the same URL will NOT be deduplicated
69
- * unless you provide a custom `keyFn` that includes headers.
147
+ * Note: the default key includes the request headers, so two requests to the
148
+ * same URL carrying different `Authorization` or `Cookie` are separate
149
+ * requests and are never coalesced. A key of method + URL alone was the
150
+ * default once, and it handed one caller's response to another: 50 callers
151
+ * aside, even two callers for different tenants shared a single response.
152
+ * See {@link defaultDedupKey}.
70
153
  */
71
154
  execute(method, url, factory, headers, windowMs, signal) {
72
155
  // Use per-key TTL if provided, otherwise fall back to global windowMs (F-3)
@@ -138,7 +221,7 @@ export class DedupMap {
138
221
  this.inflight.delete(key);
139
222
  const existingTimeout = this.timeouts.get(key);
140
223
  if (existingTimeout !== undefined) {
141
- clearTimeout(existingTimeout);
224
+ this._clearTimeout(existingTimeout);
142
225
  this.timeouts.delete(key);
143
226
  }
144
227
  }
@@ -177,10 +260,10 @@ export class DedupMap {
177
260
  // out from under callers that were sharing it.
178
261
  const stale = this.timeouts.get(key);
179
262
  if (stale !== undefined) {
180
- clearTimeout(stale);
263
+ this._clearTimeout(stale);
181
264
  this.timeouts.delete(key);
182
265
  }
183
- const timeoutId = setTimeout(() => {
266
+ const timeoutId = this._setTimeout(() => {
184
267
  this.inflight.delete(key);
185
268
  this.timeouts.delete(key);
186
269
  }, effectiveWindowMs);
@@ -196,7 +279,7 @@ export class DedupMap {
196
279
  this.inflight.delete(key);
197
280
  const existingTimeout = this.timeouts.get(key);
198
281
  if (existingTimeout !== undefined) {
199
- clearTimeout(existingTimeout);
282
+ this._clearTimeout(existingTimeout);
200
283
  this.timeouts.delete(key);
201
284
  }
202
285
  throw err;
@@ -254,7 +337,7 @@ export class DedupMap {
254
337
  this.inflight.clear();
255
338
  // Clear pending timeouts (B-1)
256
339
  for (const timeoutId of this.timeouts.values()) {
257
- clearTimeout(timeoutId);
340
+ this._clearTimeout(timeoutId);
258
341
  }
259
342
  this.timeouts.clear();
260
343
  }
@@ -268,7 +351,7 @@ export class DedupMap {
268
351
  invalidate(key) {
269
352
  const timeoutId = this.timeouts.get(key);
270
353
  if (timeoutId) {
271
- clearTimeout(timeoutId);
354
+ this._clearTimeout(timeoutId);
272
355
  this.timeouts.delete(key);
273
356
  }
274
357
  return this.inflight.delete(key);
@@ -9,6 +9,7 @@
9
9
  * Authorization header in one call.
10
10
  */
11
11
  import { randomBytes, uint8ArrayToBase64 } from "./utils.js";
12
+ import { sha256Hex } from "./utils.js";
12
13
  // ============================================================================
13
14
  // §1 MD5 — Pure JS (no Web Crypto dependency)
14
15
  // ============================================================================
@@ -69,11 +70,14 @@ function md5(input) {
69
70
  for (let offset = 0; offset < bytes.length; offset += 64) {
70
71
  const M = [];
71
72
  for (let i = 0; i < 16; i++) {
72
- const b0 = bytes[offset + i * 4];
73
- const b1 = bytes[offset + i * 4 + 1];
74
- const b2 = bytes[offset + i * 4 + 2];
75
- const b3 = bytes[offset + i * 4 + 3];
76
- M[i] = (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
73
+ // Named `w0..w3`, not `b0..b3`: `b0` shadowed the outer MD5 state variable
74
+ // of the same name, so the running state and the little-endian word read
75
+ // out of the block were indistinguishable at a glance.
76
+ const w0 = bytes[offset + i * 4];
77
+ const w1 = bytes[offset + i * 4 + 1];
78
+ const w2 = bytes[offset + i * 4 + 2];
79
+ const w3 = bytes[offset + i * 4 + 3];
80
+ M[i] = (w0 | (w1 << 8) | (w2 << 16) | (w3 << 24)) >>> 0;
77
81
  }
78
82
  let A = a0, B = b0, C = c0, D = d0;
79
83
  for (let i = 0; i < 64; i++) {
@@ -137,7 +141,7 @@ function md5(input) {
137
141
  function hexToBytes(hex) {
138
142
  const out = new Uint8Array(hex.length / 2);
139
143
  for (let i = 0; i < out.length; i++) {
140
- out[i] = parseInt(hex.substr(i * 2, 2), 16);
144
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
141
145
  }
142
146
  return out;
143
147
  }
@@ -209,14 +213,6 @@ export function parseDigestChallenge(header) {
209
213
  * @param data - String to hash
210
214
  * @returns 64-char hex-encoded SHA-256 digest
211
215
  */
212
- async function sha256Hex(data) {
213
- const enc = new TextEncoder();
214
- const buf = await crypto.subtle.digest("SHA-256", enc.encode(data));
215
- const bytes = new Uint8Array(buf);
216
- return Array.from(bytes)
217
- .map((b) => b.toString(16).padStart(2, "0"))
218
- .join("");
219
- }
220
216
  // ============================================================================
221
217
  // §3c SHA-512/256 — Pure-JS (FIPS 180-4)
222
218
  // ============================================================================
@@ -700,12 +696,37 @@ export async function createDigestAuthorization(wwwAuth, username, password, met
700
696
  */
701
697
  export function createDigestAuthorizer() {
702
698
  const counters = new Map();
699
+ // The server chooses the nonce, and nonce expiry is a routine server-side
700
+ // control, so a long-lived client sees a fresh `realm\u0000nonce` key on every
701
+ // challenge. Entries were never evicted, so the map grew by one key per
702
+ // challenge for the life of the process — keyed on strings an
703
+ // attacker-influenced server supplies. `Map` preserves insertion order, so
704
+ // dropping the first key evicts the oldest challenge.
705
+ //
706
+ // ponytail: bounded LRU-by-insertion-order rather than a TTL timer. Raise
707
+ // MAX_TRACKED_NONCES if a deployment is ever seen cycling nonces faster
708
+ // than this; a timer per key would be the alternative.
709
+ const MAX_TRACKED_NONCES = 64;
710
+ const rememberNonce = (scope, value) => {
711
+ if (!counters.has(scope)) {
712
+ counters.set(scope, value);
713
+ if (counters.size > MAX_TRACKED_NONCES) {
714
+ const oldest = counters.keys().next();
715
+ if (!oldest.done)
716
+ counters.delete(oldest.value);
717
+ }
718
+ return;
719
+ }
720
+ // Re-insert so a hot challenge counts as recently used.
721
+ counters.delete(scope);
722
+ counters.set(scope, value);
723
+ };
703
724
  return async (wwwAuth, username, password, method, uri) => {
704
725
  const challenge = parseDigestChallenge(wwwAuth);
705
726
  const cnonce = randomBytes(5);
706
727
  const scope = `${challenge.realm}\u0000${challenge.nonce}`;
707
728
  const next = (counters.get(scope) ?? 0) + 1;
708
- counters.set(scope, next);
729
+ rememberNonce(scope, next);
709
730
  const nc = next.toString(16).padStart(8, "0");
710
731
  const response = await computeDigestResponse(challenge, username, password, method, uri, cnonce, nc);
711
732
  const usernameStar = await computeUsernameStar(challenge, username);