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.
- package/README.md +170 -46
- package/dist/browser/kinetex.esm.js +29 -29
- package/dist/browser/kinetex.js +1143 -791
- package/dist/browser/kinetex.min.js +29 -29
- package/dist/cjs/aws-sigv4.js +7 -14
- package/dist/cjs/cache.js +9 -0
- package/dist/cjs/circuit-breaker.js +8 -1
- package/dist/cjs/client.js +350 -95
- package/dist/cjs/cookie-parser.js +28 -1
- package/dist/cjs/core.js +227 -5
- package/dist/cjs/dedup.js +93 -10
- package/dist/cjs/digest.js +36 -15
- package/dist/cjs/graphql.js +2 -11
- package/dist/cjs/headers.js +24 -1
- package/dist/cjs/interceptors.js +6 -17
- package/dist/cjs/lifecycle.js +3 -19
- package/dist/cjs/logging.js +12 -29
- package/dist/cjs/progress.js +1 -3
- package/dist/cjs/proxy.js +19 -2
- package/dist/cjs/response.js +202 -75
- package/dist/cjs/socks5.js +5 -0
- package/dist/cjs/utils.js +115 -27
- package/dist/cjs/ws.js +43 -2
- package/dist/esm/aws-sigv4.js +7 -14
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +9 -0
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +8 -1
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +350 -95
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +28 -1
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/core.js +227 -5
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +93 -10
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +36 -15
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +2 -11
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +24 -1
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +6 -17
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +3 -19
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +12 -29
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/progress.js +1 -3
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +19 -2
- package/dist/esm/proxy.js.map +1 -1
- package/dist/esm/response.js +202 -75
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +5 -0
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +115 -27
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +43 -2
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +1 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/core.d.ts +113 -0
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +38 -3
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +23 -4
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +24 -38
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts.map +1 -1
- package/dist/types/response.d.ts +42 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/types.d.ts +105 -23
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -11
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts +69 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts +0 -1
- package/dist/types/ws.d.ts.map +1 -1
- 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
|
|
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,
|
|
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
|
-
|
|
1180
|
-
|
|
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
|
|
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 ??
|
|
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:
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
this._clearTimeout(stale);
|
|
181
264
|
this.timeouts.delete(key);
|
|
182
265
|
}
|
|
183
|
-
const timeoutId =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
+
this._clearTimeout(timeoutId);
|
|
272
355
|
this.timeouts.delete(key);
|
|
273
356
|
}
|
|
274
357
|
return this.inflight.delete(key);
|
package/dist/cjs/digest.js
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|