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.
- package/README.md +413 -52
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3134 -787
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +140 -33
- package/dist/cjs/cache.js +58 -7
- package/dist/cjs/circuit-breaker.js +53 -4
- package/dist/cjs/client.js +726 -188
- package/dist/cjs/cookie-parser.js +131 -6
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +691 -70
- package/dist/cjs/dedup.js +142 -21
- package/dist/cjs/digest.js +195 -38
- package/dist/cjs/graphql.js +166 -35
- package/dist/cjs/headers.js +327 -46
- package/dist/cjs/interceptors.js +227 -24
- package/dist/cjs/lifecycle.js +92 -59
- package/dist/cjs/logging.js +160 -24
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +178 -30
- package/dist/cjs/proxy.js +429 -0
- package/dist/cjs/response.js +506 -110
- package/dist/cjs/socks5.js +136 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +335 -73
- package/dist/cjs/ws.js +62 -12
- package/dist/esm/aws-sigv4.js +140 -33
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +58 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +53 -4
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +726 -188
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +131 -6
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +125 -28
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +691 -70
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +142 -21
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +195 -38
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +166 -35
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +327 -46
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +227 -24
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +92 -59
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +160 -24
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +247 -22
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +178 -30
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +430 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +506 -110
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +136 -15
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +173 -43
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +335 -73
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +62 -12
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +19 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +15 -2
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +69 -11
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +216 -25
- 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 +17 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +68 -31
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +126 -38
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +22 -3
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +49 -2
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +219 -26
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -25
- 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
package/dist/esm/utils.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-runtime utilities for type safety, validation, and security.
|
|
3
3
|
*/
|
|
4
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
4
|
let _process;
|
|
6
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
5
|
let _Buffer;
|
|
8
6
|
// Check globalThis.process for Node.js runtime detection (no dynamic import needed)
|
|
9
7
|
function getProcess() {
|
|
@@ -47,7 +45,17 @@ const DEFAULT_LIMITS = {
|
|
|
47
45
|
* @returns Parse result with success/failure information
|
|
48
46
|
*/
|
|
49
47
|
export function safeJSONParse(text, options = {}) {
|
|
50
|
-
|
|
48
|
+
// `{ ...DEFAULT_LIMITS, ...options }` copies an *explicitly present*
|
|
49
|
+
// `undefined` straight over a real limit, and every comparison this file
|
|
50
|
+
// makes is `<=` / `>` against a number — so `{ maxDepth: undefined }` did not
|
|
51
|
+
// mean "use the default", it meant "there is no depth limit", silently. That
|
|
52
|
+
// is the shape config plumbing produces (`{ maxDepth: env.MAX_DEPTH }`), and
|
|
53
|
+
// the `Required<>` on `limits` says the opposite. Only defined values merge.
|
|
54
|
+
const limits = { ...DEFAULT_LIMITS };
|
|
55
|
+
for (const [key, value] of Object.entries(options)) {
|
|
56
|
+
if (value !== undefined)
|
|
57
|
+
limits[key] = value;
|
|
58
|
+
}
|
|
51
59
|
// Check string length first
|
|
52
60
|
if (text.length > limits.maxStringLength) {
|
|
53
61
|
return {
|
|
@@ -241,6 +249,44 @@ export function parseUntrustedJSON(text) {
|
|
|
241
249
|
// ============================================================================
|
|
242
250
|
// §2 TYPE GUARDS
|
|
243
251
|
// ============================================================================
|
|
252
|
+
/**
|
|
253
|
+
* Exact brand test for a platform type.
|
|
254
|
+
*
|
|
255
|
+
* `instanceof` is authoritative in the current realm and — unlike a
|
|
256
|
+
* constructor-name check — survives subclasses, so `new File()` is still a
|
|
257
|
+
* `Blob`. It fails across realms (an `iframe`'s `Headers` is a different
|
|
258
|
+
* constructor), so the platform's own `Symbol.toStringTag` is consulted as a
|
|
259
|
+
* fallback: that is the tag the runtime sets on the real object, and the one
|
|
260
|
+
* thing a `Map` or a `Set` does not carry.
|
|
261
|
+
*
|
|
262
|
+
* The previous guards duck-typed on a *single* method name, which made the
|
|
263
|
+
* most common built-ins in the language pass for types they are not:
|
|
264
|
+
* `Map` and `Set` both have `has`, `FormData` has `forEach`, so
|
|
265
|
+
* `isURLSearchParams(new Map())`, `isURLSearchParams(new Set())` and
|
|
266
|
+
* `isHeaders(new FormData())` were all `true`. Meanwhile `isBlob` compared
|
|
267
|
+
* `constructor.name === "Blob"` and rejected every `File`, which is a `Blob`.
|
|
268
|
+
*/
|
|
269
|
+
function hasBrand(value, ctor, tag, required) {
|
|
270
|
+
if (value === null || typeof value !== "object")
|
|
271
|
+
return false;
|
|
272
|
+
if (typeof ctor === "function") {
|
|
273
|
+
try {
|
|
274
|
+
if (value instanceof ctor)
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// A Proxy with a hostile getPrototypeOf — fall through to the brand.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Cross-realm: the runtime's own brand, plus the methods that make the
|
|
282
|
+
// value usable as the type. The brand alone is one getter away from
|
|
283
|
+
// anything, and a guard whose whole job is to route a value to the right
|
|
284
|
+
// code path is not one that should hand it out on a say-so.
|
|
285
|
+
if (Object.prototype.toString.call(value) !== `[object ${tag}]`)
|
|
286
|
+
return false;
|
|
287
|
+
const obj = value;
|
|
288
|
+
return required.every((m) => typeof obj[m] === "function");
|
|
289
|
+
}
|
|
244
290
|
/**
|
|
245
291
|
* Type guard for Uint8Array.
|
|
246
292
|
*
|
|
@@ -266,10 +312,7 @@ export function isArrayBuffer(value) {
|
|
|
266
312
|
* @returns True if the value is a ReadableStream.
|
|
267
313
|
*/
|
|
268
314
|
export function isReadableStream(value) {
|
|
269
|
-
return (value
|
|
270
|
-
typeof value === "object" &&
|
|
271
|
-
"getReader" in value &&
|
|
272
|
-
typeof value.getReader === "function");
|
|
315
|
+
return hasBrand(value, globalThis.ReadableStream, "ReadableStream", ["getReader", "cancel"]);
|
|
273
316
|
}
|
|
274
317
|
/**
|
|
275
318
|
* Type guard for Headers.
|
|
@@ -278,10 +321,7 @@ export function isReadableStream(value) {
|
|
|
278
321
|
* @returns True if the value is a Headers instance.
|
|
279
322
|
*/
|
|
280
323
|
export function isHeaders(value) {
|
|
281
|
-
return (value
|
|
282
|
-
typeof value === "object" &&
|
|
283
|
-
"forEach" in value &&
|
|
284
|
-
typeof value.forEach === "function");
|
|
324
|
+
return hasBrand(value, globalThis.Headers, "Headers", ["get", "set", "append", "forEach"]);
|
|
285
325
|
}
|
|
286
326
|
/**
|
|
287
327
|
* Type guard for AbortSignal.
|
|
@@ -290,10 +330,10 @@ export function isHeaders(value) {
|
|
|
290
330
|
* @returns True if the value is an AbortSignal.
|
|
291
331
|
*/
|
|
292
332
|
export function isAbortSignal(value) {
|
|
293
|
-
return (value
|
|
294
|
-
|
|
295
|
-
"
|
|
296
|
-
|
|
333
|
+
return hasBrand(value, globalThis.AbortSignal, "AbortSignal", [
|
|
334
|
+
"addEventListener",
|
|
335
|
+
"removeEventListener",
|
|
336
|
+
]);
|
|
297
337
|
}
|
|
298
338
|
/**
|
|
299
339
|
* Type guard for plain objects (\[object Object\]).
|
|
@@ -302,9 +342,17 @@ export function isAbortSignal(value) {
|
|
|
302
342
|
* @returns True if the value is a plain Object.
|
|
303
343
|
*/
|
|
304
344
|
export function isPlainObject(value) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
345
|
+
if (value === null || typeof value !== "object")
|
|
346
|
+
return false;
|
|
347
|
+
// `Object.prototype.toString.call(x) === "[object Object]"` is the classic
|
|
348
|
+
// wrong way to write this: it is true for *every* object whose class does
|
|
349
|
+
// not override Symbol.toStringTag, so `new (class { constructor() { this.a = 1 } })()`
|
|
350
|
+
// passed — which is the opposite of what "plain" means, and the reason the
|
|
351
|
+
// guard cannot be trusted for the copy/merge decisions its name invites.
|
|
352
|
+
// What makes an object plain is its prototype: Object.prototype, or none at
|
|
353
|
+
// all (a null-prototype object is a dictionary, and still plain).
|
|
354
|
+
const proto = Object.getPrototypeOf(value);
|
|
355
|
+
return proto === Object.prototype || proto === null;
|
|
308
356
|
}
|
|
309
357
|
/**
|
|
310
358
|
* Type guard for FormData.
|
|
@@ -313,9 +361,7 @@ export function isPlainObject(value) {
|
|
|
313
361
|
* @returns True if the value is a FormData instance.
|
|
314
362
|
*/
|
|
315
363
|
export function isFormData(value) {
|
|
316
|
-
return (value
|
|
317
|
-
typeof value === "object" &&
|
|
318
|
-
value.constructor?.name === "FormData");
|
|
364
|
+
return hasBrand(value, globalThis.FormData, "FormData", ["append", "get", "getAll", "set"]);
|
|
319
365
|
}
|
|
320
366
|
/**
|
|
321
367
|
* Type guard for Blob.
|
|
@@ -324,7 +370,10 @@ export function isFormData(value) {
|
|
|
324
370
|
* @returns True if the value is a Blob instance.
|
|
325
371
|
*/
|
|
326
372
|
export function isBlob(value) {
|
|
327
|
-
|
|
373
|
+
// File extends Blob, and its own brand is "[object File]", so this is
|
|
374
|
+
// `instanceof`-first on purpose: a constructor-name check rejected every
|
|
375
|
+
// File, which is the single most common Blob-shaped value in a browser.
|
|
376
|
+
return hasBrand(value, globalThis.Blob, "Blob", ["arrayBuffer", "slice", "text", "stream"]);
|
|
328
377
|
}
|
|
329
378
|
/**
|
|
330
379
|
* Type guard for URLSearchParams.
|
|
@@ -333,10 +382,12 @@ export function isBlob(value) {
|
|
|
333
382
|
* @returns True if the value is a URLSearchParams instance.
|
|
334
383
|
*/
|
|
335
384
|
export function isURLSearchParams(value) {
|
|
336
|
-
return (value
|
|
337
|
-
|
|
338
|
-
"
|
|
339
|
-
|
|
385
|
+
return hasBrand(value, globalThis.URLSearchParams, "URLSearchParams", [
|
|
386
|
+
"append",
|
|
387
|
+
"get",
|
|
388
|
+
"getAll",
|
|
389
|
+
"sort",
|
|
390
|
+
]);
|
|
340
391
|
}
|
|
341
392
|
// ============================================================================
|
|
342
393
|
// §3 VALIDATION UTILITIES
|
|
@@ -366,13 +417,21 @@ export function isValidHeaderValue(value) {
|
|
|
366
417
|
return false;
|
|
367
418
|
if (value.length > 8192)
|
|
368
419
|
return false; // Reasonable length limit
|
|
369
|
-
//
|
|
370
|
-
//
|
|
420
|
+
// RFC 9110 §5.5: field-value is VCHAR / SP / HTAB / obs-text, where obs-text
|
|
421
|
+
// is %x80-FF. So a value may hold the Latin-1 range and nothing above it —
|
|
422
|
+
// there is no upper bound in the loop, which accepted an emoji and then let
|
|
423
|
+
// the very next `new Headers({ "X-A": "\u{1F600}" })` throw
|
|
424
|
+
// "Cannot convert argument to a ByteString because the character at index 0
|
|
425
|
+
// has a value of 55357". This guard is what a caller checks first, so a
|
|
426
|
+
// "valid" verdict that the runtime then refuses is worse than no verdict.
|
|
371
427
|
for (let i = 0; i < value.length; i++) {
|
|
372
428
|
const code = value.charCodeAt(i);
|
|
373
|
-
// No control characters (0-31 except 9=HT)
|
|
374
|
-
//
|
|
375
|
-
if ((code < 32 && code !== 9) || code === 127
|
|
429
|
+
// No control characters (0-31 except 9=HT) and no DEL — CR and LF are
|
|
430
|
+
// inside that range, so this is also the header-injection guard.
|
|
431
|
+
if ((code < 32 && code !== 9) || code === 127)
|
|
432
|
+
return false;
|
|
433
|
+
// Above obs-text: a code point no ByteString header value can carry.
|
|
434
|
+
if (code > 0xff)
|
|
376
435
|
return false;
|
|
377
436
|
}
|
|
378
437
|
return true;
|
|
@@ -581,9 +640,7 @@ function isBlockedIPv6(b) {
|
|
|
581
640
|
if (b[0] === 0x20 && b[1] === 0x01 && b[2] === 0x0d && b[3] === 0xb8)
|
|
582
641
|
return true;
|
|
583
642
|
// ::ffff:0:0/96 — IPv4-mapped → apply the IPv4 checks to the tail
|
|
584
|
-
if (b.slice(0, 10).every((x) => x === 0) &&
|
|
585
|
-
b[10] === 0xff &&
|
|
586
|
-
b[11] === 0xff) {
|
|
643
|
+
if (b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff) {
|
|
587
644
|
return isBlockedIPv4(read32(12));
|
|
588
645
|
}
|
|
589
646
|
// ::/96 — deprecated IPv4-compatible → apply the IPv4 checks to the tail
|
|
@@ -611,7 +668,7 @@ function isBlockedIPv6(b) {
|
|
|
611
668
|
* @param allowedSchemes - Allowed URL schemes
|
|
612
669
|
* @returns True if URL is safe
|
|
613
670
|
*/
|
|
614
|
-
export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
671
|
+
export function isSafeURL(url, allowedSchemes = ["http", "https"], options = {}) {
|
|
615
672
|
try {
|
|
616
673
|
const parsed = typeof url === "string" ? new URL(url) : url;
|
|
617
674
|
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
@@ -623,25 +680,31 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
|
623
680
|
if (FORBIDDEN_SCHEMES.has(scheme)) {
|
|
624
681
|
return false;
|
|
625
682
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
// Unparseable IPv6 literal → reject defensively
|
|
638
|
-
if (v6 === null || isBlockedIPv6(v6))
|
|
639
|
-
return false;
|
|
640
|
-
}
|
|
641
|
-
else {
|
|
642
|
-
const v4 = parseIPv4Host(host);
|
|
643
|
-
if (v4 !== null && isBlockedIPv4(v4))
|
|
683
|
+
// `allowPrivateNetwork` relaxes *only* the address checks below. Scheme,
|
|
684
|
+
// path traversal, hostname length, URL length and port validation are not
|
|
685
|
+
// about where the socket lands, so they still apply — otherwise opting in to
|
|
686
|
+
// a loopback fixture would also silently opt in to `file:` and `../`.
|
|
687
|
+
if (!options.allowPrivateNetwork) {
|
|
688
|
+
let host = parsed.hostname.toLowerCase();
|
|
689
|
+
const isV6Literal = host.startsWith("[") && host.endsWith("]");
|
|
690
|
+
if (isV6Literal)
|
|
691
|
+
host = host.slice(1, -1);
|
|
692
|
+
// Block loopback hostnames (including *.localhost subdomains)
|
|
693
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
|
|
644
694
|
return false;
|
|
695
|
+
// Block private/reserved IPs (SSRF prevention) — numeric comparison over
|
|
696
|
+
// expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
|
|
697
|
+
if (isV6Literal || host.includes(":")) {
|
|
698
|
+
const v6 = parseIPv6Host(host);
|
|
699
|
+
// Unparseable IPv6 literal → reject defensively
|
|
700
|
+
if (v6 === null || isBlockedIPv6(v6))
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
const v4 = parseIPv4Host(host);
|
|
705
|
+
if (v4 !== null && isBlockedIPv4(v4))
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
645
708
|
}
|
|
646
709
|
// Check for suspicious patterns (path traversal)
|
|
647
710
|
if (parsed.hostname.includes("..") || parsed.pathname.includes("..")) {
|
|
@@ -666,6 +729,35 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
|
666
729
|
return false;
|
|
667
730
|
}
|
|
668
731
|
}
|
|
732
|
+
/**
|
|
733
|
+
* True when a literal IPv4/IPv6 address falls in a blocked range.
|
|
734
|
+
*
|
|
735
|
+
* The same decision `isSafeURL` makes about a hostname, applied to an address
|
|
736
|
+
* that has already been resolved. Kept separate from `isSafeURL` because the two
|
|
737
|
+
* answer different questions: `isSafeURL` reads what the caller wrote, this
|
|
738
|
+
* reads what the resolver returned. Both are needed — see
|
|
739
|
+
* `createPinnedLookup` in `core.ts`, which is the only thing that stops a
|
|
740
|
+
* hostname that resolves to a private address from being dialled.
|
|
741
|
+
*
|
|
742
|
+
* IPv4 literals are accepted in every form a resolver can return (dotted-quad
|
|
743
|
+
* and numeric); anything unparseable is treated as blocked, so an address this
|
|
744
|
+
* cannot understand fails closed rather than open.
|
|
745
|
+
*/
|
|
746
|
+
export function isBlockedAddress(address) {
|
|
747
|
+
const host = address.trim().toLowerCase();
|
|
748
|
+
if (host.startsWith("[") && host.endsWith("]"))
|
|
749
|
+
return true;
|
|
750
|
+
if (host === "" || host === "localhost" || host.endsWith(".localhost"))
|
|
751
|
+
return true;
|
|
752
|
+
if (host.includes(":")) {
|
|
753
|
+
const v6 = parseIPv6Host(host);
|
|
754
|
+
return v6 === null || isBlockedIPv6(v6);
|
|
755
|
+
}
|
|
756
|
+
// A resolver returns dotted-quad, but `parseIPv4Host` also accepts the
|
|
757
|
+
// numeric spellings, and a non-IP string must not be read as "safe".
|
|
758
|
+
const v4 = parseIPv4Host(host);
|
|
759
|
+
return v4 === null || isBlockedIPv4(v4);
|
|
760
|
+
}
|
|
669
761
|
/**
|
|
670
762
|
* Check if a URL is absolute (has a scheme).
|
|
671
763
|
*
|
|
@@ -712,7 +804,24 @@ export function deepClone(value) {
|
|
|
712
804
|
value.forEach((v) => cloned.add(deepClone(v)));
|
|
713
805
|
return cloned;
|
|
714
806
|
}
|
|
715
|
-
|
|
807
|
+
// A class instance is rebuilt on *its own* prototype. The walk below
|
|
808
|
+
// produced a bare `{}` for every object that was not a plain one, so
|
|
809
|
+
// `deepClone(new (class { m() { return 1 } })())` came back as an `Object`
|
|
810
|
+
// with `m` gone — the clone was no longer the thing it was cloned from, and
|
|
811
|
+
// the failure was silent until something called a method. `new (proto)()` is
|
|
812
|
+
// not generally callable, so the instance is created with its prototype
|
|
813
|
+
// attached and its own properties copied across.
|
|
814
|
+
//
|
|
815
|
+
// A *host* object — RegExp, URL, a typed array, an Error — is not: those
|
|
816
|
+
// carry internal slots that `Object.create` cannot produce, and
|
|
817
|
+
// `Object.create(RegExp.prototype)` satisfies `instanceof` while throwing
|
|
818
|
+
// "called on non-RegExp object" the moment a getter is touched. The brand is
|
|
819
|
+
// the discriminator, and it is exact: a class instance reports the plain
|
|
820
|
+
// `[object Object]`, because the brand comes from the runtime, not the
|
|
821
|
+
// constructor. Those keep the dictionary form they always had.
|
|
822
|
+
const proto = Object.getPrototypeOf(value);
|
|
823
|
+
const isClassInstance = Object.prototype.toString.call(value) === "[object Object]";
|
|
824
|
+
const cloned = proto === Object.prototype || proto === null || !isClassInstance ? {} : Object.create(proto);
|
|
716
825
|
for (const key in value) {
|
|
717
826
|
// Prototype-pollution guard: never copy __proto__ / constructor / prototype
|
|
718
827
|
if (key === "__proto__" || key === "constructor" || key === "prototype")
|
|
@@ -742,7 +851,27 @@ export function isPromise(value) {
|
|
|
742
851
|
*/
|
|
743
852
|
export function createStructuredError(message, context) {
|
|
744
853
|
const error = new Error(message);
|
|
745
|
-
Object.assign
|
|
854
|
+
// `Object.assign` writes through [[Set]], so a context carrying an *own*
|
|
855
|
+
// `__proto__` key — which is exactly what `JSON.parse('{"__proto\u003a…}')`
|
|
856
|
+
// produces, and what a caller forwarding a remote error payload hands over —
|
|
857
|
+
// did not add a field. It reached the inherited `__proto__` setter and
|
|
858
|
+
// replaced the error's prototype, so the attacker-supplied object sat in the
|
|
859
|
+
// prototype chain of every error the client then went on to format, log or
|
|
860
|
+
// inspect. The one key that has to be expressible is written as a data
|
|
861
|
+
// property, which is what was meant.
|
|
862
|
+
for (const key of Object.keys(context)) {
|
|
863
|
+
const value = context[key];
|
|
864
|
+
if (key === "__proto__") {
|
|
865
|
+
Object.defineProperty(error, key, {
|
|
866
|
+
value,
|
|
867
|
+
writable: true,
|
|
868
|
+
enumerable: true,
|
|
869
|
+
configurable: true,
|
|
870
|
+
});
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
error[key] = value;
|
|
874
|
+
}
|
|
746
875
|
return error;
|
|
747
876
|
}
|
|
748
877
|
/**
|
|
@@ -758,11 +887,40 @@ export function formatError(error) {
|
|
|
758
887
|
context[key] = value;
|
|
759
888
|
}
|
|
760
889
|
}
|
|
761
|
-
const contextStr = Object.keys(context).length > 0 ? ` | ${
|
|
890
|
+
const contextStr = Object.keys(context).length > 0 ? ` | ${safeStringify(context)}` : "";
|
|
762
891
|
return `${error.name}: ${error.message}${contextStr}`;
|
|
763
892
|
}
|
|
764
893
|
return String(error);
|
|
765
894
|
}
|
|
895
|
+
/**
|
|
896
|
+
* `JSON.stringify` for a log line, on a value that is very likely to be
|
|
897
|
+
* hostile.
|
|
898
|
+
*
|
|
899
|
+
* The context attached by {@link createStructuredError} is whatever the failing
|
|
900
|
+
* call had in hand — a `request`, a `response`, a `cause` — and all three
|
|
901
|
+
* routinely point back at each other. `JSON.stringify` answers that with a
|
|
902
|
+
* thrown `TypeError: Converting circular structure to JSON`, and a BigInt with
|
|
903
|
+
* another, so the function whose entire job is to turn an error into a string
|
|
904
|
+
* threw instead, taking the `catch` that was logging it down with it.
|
|
905
|
+
*/
|
|
906
|
+
export function safeStringify(value, circularMarker = "[Circular]") {
|
|
907
|
+
const seen = new Set();
|
|
908
|
+
try {
|
|
909
|
+
return (JSON.stringify(value, (_key, v) => {
|
|
910
|
+
if (typeof v === "bigint")
|
|
911
|
+
return v.toString();
|
|
912
|
+
if (typeof v === "object" && v !== null) {
|
|
913
|
+
if (seen.has(v))
|
|
914
|
+
return circularMarker;
|
|
915
|
+
seen.add(v);
|
|
916
|
+
}
|
|
917
|
+
return v;
|
|
918
|
+
}) ?? String(value));
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
return String(value);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
766
924
|
// ============================================================================
|
|
767
925
|
// §5 TIME UTILITIES
|
|
768
926
|
// ============================================================================
|
|
@@ -823,12 +981,12 @@ export function perfNow() {
|
|
|
823
981
|
*/
|
|
824
982
|
export function sleep(ms, signal) {
|
|
825
983
|
if (signal?.aborted) {
|
|
826
|
-
return Promise.reject(
|
|
984
|
+
return Promise.reject(abortError());
|
|
827
985
|
}
|
|
828
986
|
return new Promise((resolve, reject) => {
|
|
829
987
|
const onAbort = () => {
|
|
830
988
|
clearTimeout(timer);
|
|
831
|
-
reject(
|
|
989
|
+
reject(abortError());
|
|
832
990
|
};
|
|
833
991
|
const timer = setTimeout(() => {
|
|
834
992
|
signal?.removeEventListener("abort", onAbort); // cleanup on normal completion
|
|
@@ -865,10 +1023,16 @@ export function concatUint8Arrays(chunks) {
|
|
|
865
1023
|
* @returns Uint8Array or null if unsupported type
|
|
866
1024
|
*/
|
|
867
1025
|
export function toUint8Array(data) {
|
|
1026
|
+
// Both branches copy. The Uint8Array branch sliced but the ArrayBuffer
|
|
1027
|
+
// branch wrapped, so the same call handed back a copy for one input type and
|
|
1028
|
+
// a live view for the other: writing to the result of
|
|
1029
|
+
// `toUint8Array(buffer)` wrote through to the caller's buffer, and two calls
|
|
1030
|
+
// given the same buffer shared it. A function whose output is "the bytes" has
|
|
1031
|
+
// to be the same kind of thing whichever type it was handed.
|
|
868
1032
|
if (data instanceof Uint8Array)
|
|
869
1033
|
return data.slice();
|
|
870
1034
|
if (data instanceof ArrayBuffer)
|
|
871
|
-
return new Uint8Array(data);
|
|
1035
|
+
return new Uint8Array(data.slice(0));
|
|
872
1036
|
const b = getBuffer();
|
|
873
1037
|
if (b && b.isBuffer(data)) {
|
|
874
1038
|
return new Uint8Array(data);
|
|
@@ -888,6 +1052,50 @@ export function toUint8Array(data) {
|
|
|
888
1052
|
* @param bytes The buffer to encode.
|
|
889
1053
|
* @returns Base64-encoded string.
|
|
890
1054
|
*/
|
|
1055
|
+
/**
|
|
1056
|
+
* SHA-256 of a string or byte array, as lowercase hex.
|
|
1057
|
+
*
|
|
1058
|
+
* The `ArrayBuffer` slice is not optional. `crypto.subtle.digest` takes an
|
|
1059
|
+
* `ArrayBuffer`, and a `Uint8Array`'s `.buffer` is the whole backing store —
|
|
1060
|
+
* pass it directly and a view that starts partway into a larger buffer gets
|
|
1061
|
+
* hashed from offset zero, hashing the wrong bytes. Three copies of this
|
|
1062
|
+
* function existed and one of them made exactly that mistake.
|
|
1063
|
+
*/
|
|
1064
|
+
/**
|
|
1065
|
+
* Copy `headers`, replacing the value of any name in `sensitive` with `mask`.
|
|
1066
|
+
*
|
|
1067
|
+
* The comparison is case-insensitive because HTTP header names are, and the
|
|
1068
|
+
* caller's set is folded once here rather than at each call site — which is
|
|
1069
|
+
* what `noUncheckedIndexedAccess` makes expensive elsewhere, where every
|
|
1070
|
+
* consumer ends up writing `headers["x"] ?? headers["X"]` to find a header that
|
|
1071
|
+
* was only ever stored under one spelling.
|
|
1072
|
+
*
|
|
1073
|
+
* `mask` is a parameter rather than a constant because four call sites already
|
|
1074
|
+
* disagreed on it: the logger and the lifecycle hooks wrote `"***"`, the
|
|
1075
|
+
* logging interceptor wrote `"**REDACTED**"`, and the HAR recorder wrote
|
|
1076
|
+
* `"***REDACTED***"`. Those strings are observable in log output, so unifying
|
|
1077
|
+
* them is a behaviour change and is deliberately not made here — but they are
|
|
1078
|
+
* now in one function, so a future decision to converge is a one-line edit.
|
|
1079
|
+
*/
|
|
1080
|
+
/** True when `name` is one of `sensitive`, compared case-insensitively. */
|
|
1081
|
+
export function isSensitiveHeader(name, sensitive) {
|
|
1082
|
+
return sensitive.has(name.toLowerCase());
|
|
1083
|
+
}
|
|
1084
|
+
export function redactHeaders(headers, sensitive, mask) {
|
|
1085
|
+
const out = {};
|
|
1086
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1087
|
+
out[k] = isSensitiveHeader(k, sensitive) ? mask : v;
|
|
1088
|
+
}
|
|
1089
|
+
return out;
|
|
1090
|
+
}
|
|
1091
|
+
export async function sha256Hex(data) {
|
|
1092
|
+
const raw = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
1093
|
+
const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength);
|
|
1094
|
+
const hash = await crypto.subtle.digest("SHA-256", buf);
|
|
1095
|
+
return Array.from(new Uint8Array(hash))
|
|
1096
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1097
|
+
.join("");
|
|
1098
|
+
}
|
|
891
1099
|
export function uint8ArrayToBase64(bytes) {
|
|
892
1100
|
// Process in 3-byte (24-bit) aligned chunks to avoid padding issues mid-stream.
|
|
893
1101
|
// 3 × 2¹³ = 24 576 bytes per chunk — keeps individual strings small.
|
|
@@ -929,25 +1137,44 @@ export function mergeSignals(...signals) {
|
|
|
929
1137
|
}
|
|
930
1138
|
if (validSignals.length === 1)
|
|
931
1139
|
return validSignals[0];
|
|
932
|
-
// Check if any signal is already aborted
|
|
933
|
-
|
|
1140
|
+
// Check if any signal is already aborted.
|
|
1141
|
+
//
|
|
1142
|
+
// `controller.abort()` with no argument installs the platform's generic
|
|
1143
|
+
// `AbortError: This operation was aborted`, which threw away *why* the call
|
|
1144
|
+
// was aborted. The two-live-signal branch below preserves the reason, and so
|
|
1145
|
+
// does the `AbortSignal.any` path this function prefers on every current
|
|
1146
|
+
// runtime — so the one case a caller is most likely to hit (a signal that
|
|
1147
|
+
// already fired, e.g. `AbortSignal.timeout()`) was the only one that lost
|
|
1148
|
+
// it. `interceptors.ts` re-aborts with `existing.reason` when merging, and
|
|
1149
|
+
// anything reading the merged signal's reason saw a bare AbortError where it
|
|
1150
|
+
// should have seen the caller's TimeoutError or their own Error.
|
|
1151
|
+
const preAborted = validSignals.find((s) => s.aborted);
|
|
1152
|
+
if (preAborted) {
|
|
934
1153
|
const controller = new AbortController();
|
|
935
|
-
controller.abort();
|
|
1154
|
+
controller.abort(preAborted.reason);
|
|
936
1155
|
return controller.signal;
|
|
937
1156
|
}
|
|
1157
|
+
// The platform primitive, where it exists. The hand-rolled version below
|
|
1158
|
+
// attached an `abort` listener to every input and only ever removed it when
|
|
1159
|
+
// one of those inputs fired — so merging a long-lived caller signal (a
|
|
1160
|
+
// request-scoped controller is the usual one) accumulated one listener per
|
|
1161
|
+
// merge, forever, and tripped Node's MaxListenersExceededWarning at 11.
|
|
1162
|
+
// The "9.11" self-cleanup that was supposed to release them could not run:
|
|
1163
|
+
// the merged controller is never handed out, so nothing but the inputs can
|
|
1164
|
+
// ever abort it. `AbortSignal.any` holds its inputs weakly and adds nothing
|
|
1165
|
+
// observable to them. Node 20.3+, Deno, Bun and current browsers have it;
|
|
1166
|
+
// the manual path remains for Node 18 and older.
|
|
1167
|
+
const nativeAny = AbortSignal.any;
|
|
1168
|
+
if (typeof nativeAny === "function") {
|
|
1169
|
+
return nativeAny.call(AbortSignal, validSignals);
|
|
1170
|
+
}
|
|
938
1171
|
const controller = new AbortController();
|
|
939
1172
|
// abort: fire the controller and clean up ALL listeners immediately.
|
|
940
1173
|
const abort = () => {
|
|
941
1174
|
for (const s of validSignals)
|
|
942
1175
|
s.removeEventListener("abort", abort);
|
|
943
|
-
controller.abort(
|
|
1176
|
+
controller.abort(abortError());
|
|
944
1177
|
};
|
|
945
|
-
// 9.11: when the merged signal itself aborts (e.g. from another path), also clean up.
|
|
946
|
-
// This prevents listener accumulation when callers abort the controller externally.
|
|
947
|
-
controller.signal.addEventListener("abort", () => {
|
|
948
|
-
for (const s of validSignals)
|
|
949
|
-
s.removeEventListener("abort", abort);
|
|
950
|
-
}, { once: true });
|
|
951
1178
|
for (const s of validSignals)
|
|
952
1179
|
s.addEventListener("abort", abort, { once: true });
|
|
953
1180
|
return controller.signal;
|
|
@@ -1001,11 +1228,20 @@ export function randomBytes(byteCount) {
|
|
|
1001
1228
|
.join("");
|
|
1002
1229
|
}
|
|
1003
1230
|
// Cross-runtime error creation: DOMException may not exist in all runtimes.
|
|
1004
|
-
|
|
1231
|
+
/**
|
|
1232
|
+
* A platform-shaped abort rejection: a `DOMException` named `AbortError` where
|
|
1233
|
+
* one exists, and a plain `Error` carrying the same name where it does not.
|
|
1234
|
+
*
|
|
1235
|
+
* `message` is a parameter because two callers need different text for the same
|
|
1236
|
+
* event, and they had each written this function. It is deliberately not
|
|
1237
|
+
* `AbortError` from `types.ts`: that is a `KinetexError` with code `EABORT`, a
|
|
1238
|
+
* different and equally deliberate contract used by the request pipeline.
|
|
1239
|
+
*/
|
|
1240
|
+
export function abortError(message = "Aborted") {
|
|
1005
1241
|
if (typeof DOMException !== "undefined") {
|
|
1006
|
-
return new DOMException(
|
|
1242
|
+
return new DOMException(message, "AbortError");
|
|
1007
1243
|
}
|
|
1008
|
-
const err = new Error(
|
|
1244
|
+
const err = new Error(message);
|
|
1009
1245
|
err.name = "AbortError";
|
|
1010
1246
|
return err;
|
|
1011
1247
|
}
|
|
@@ -1088,7 +1324,33 @@ export function hasNativeFetch() {
|
|
|
1088
1324
|
export function normalizeHeaders(headers) {
|
|
1089
1325
|
const result = {};
|
|
1090
1326
|
headers.forEach((value, key) => {
|
|
1091
|
-
|
|
1327
|
+
const name = key.toLowerCase();
|
|
1328
|
+
// `__proto__` is a legal header name — it is made of token characters — and
|
|
1329
|
+
// `result[name] = value` is a [[Set]], so a response carrying it went
|
|
1330
|
+
// through the inherited setter, which ignores a primitive. The header did
|
|
1331
|
+
// not overwrite anything; it simply disappeared, and the caller reading the
|
|
1332
|
+
// normalized record never learned the response had sent it.
|
|
1333
|
+
if (name === "__proto__") {
|
|
1334
|
+
Object.defineProperty(result, name, {
|
|
1335
|
+
value,
|
|
1336
|
+
writable: true,
|
|
1337
|
+
enumerable: true,
|
|
1338
|
+
configurable: true,
|
|
1339
|
+
});
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
// `Set-Cookie` is the one header the Fetch spec does NOT combine: it
|
|
1343
|
+
// yields each cookie as a separate `forEach` entry, while every other
|
|
1344
|
+
// repeated header arrives already joined with ", ". Assigning therefore
|
|
1345
|
+
// overwrote: a response setting two cookies normalized to the last one
|
|
1346
|
+
// only, and the first vanished with no error anywhere. `Headers.get()`
|
|
1347
|
+
// reports them combined as "a=1, b=2", so accumulate to match it — the
|
|
1348
|
+
// cookie jar splits this form back apart with `splitSetCookieHeaders`.
|
|
1349
|
+
if (name === "set-cookie" && Object.hasOwn(result, name)) {
|
|
1350
|
+
result[name] = `${result[name]}, ${value}`;
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
result[name] = value;
|
|
1092
1354
|
});
|
|
1093
1355
|
return result;
|
|
1094
1356
|
}
|