kinetex 1.0.0 → 1.1.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 +104 -16
- package/dist/browser/kinetex.esm.js +18 -18
- package/dist/browser/kinetex.js +509 -240
- package/dist/browser/kinetex.min.js +18 -18
- package/dist/cjs/client.js +180 -11
- package/dist/cjs/cookie-store.js +10 -0
- package/dist/cjs/core.js +25 -8
- package/dist/cjs/graphql.js +18 -4
- package/dist/cjs/logging.js +10 -2
- package/dist/cjs/mod.js +1 -1
- package/dist/cjs/pagination.js +6 -1
- package/dist/cjs/response.js +8 -6
- package/dist/cjs/sse.js +5 -2
- package/dist/cjs/utils.js +238 -57
- package/dist/cjs/ws.js +6 -1
- package/dist/esm/client.js +180 -11
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-store.js +10 -0
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +25 -8
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/graphql.js +18 -4
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/logging.js +10 -2
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +1 -1
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +6 -1
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/response.js +8 -6
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/sse.js +5 -2
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/utils.js +238 -57
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +6 -1
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +1 -1
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/utils.d.ts +18 -2
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +5 -5
package/dist/cjs/utils.js
CHANGED
|
@@ -188,6 +188,41 @@ export function tryParseJSON(text) {
|
|
|
188
188
|
const result = safeJSONParse(text);
|
|
189
189
|
return result.success ? result.value : text;
|
|
190
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* FIX (H6): Strip prototype-pollution keys from a freshly parsed JSON value.
|
|
193
|
+
*
|
|
194
|
+
* Recursively removes own properties named `__proto__`, `constructor`, and
|
|
195
|
+
* `prototype` from plain objects. Use immediately after any raw `JSON.parse`
|
|
196
|
+
* that does not go through {@link safeJSONParse} (streaming parsers, legacy
|
|
197
|
+
* helpers) so that untrusted payloads can never smuggle pollution keys into
|
|
198
|
+
* downstream spread/merge operations.
|
|
199
|
+
*
|
|
200
|
+
* @param value - Parsed JSON value (mutated copy is returned for objects/arrays).
|
|
201
|
+
* @returns Sanitized value — same reference for primitives.
|
|
202
|
+
*/
|
|
203
|
+
export function sanitizeParsedJSON(value) {
|
|
204
|
+
if (value === null || typeof value !== "object")
|
|
205
|
+
return value;
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
for (let i = 0; i < value.length; i++) {
|
|
208
|
+
value[i] = sanitizeParsedJSON(value[i]);
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
const obj = value;
|
|
213
|
+
for (const key of Object.keys(obj)) {
|
|
214
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
215
|
+
delete obj[key];
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// Recurse without writing back: sanitizeParsedJSON mutates its argument
|
|
219
|
+
// in place and returns the same reference, so a `obj[key] = ...` write
|
|
220
|
+
// would be a redundant dynamic assignment with a remote-controlled key
|
|
221
|
+
// (CodeQL: remote property injection).
|
|
222
|
+
sanitizeParsedJSON(obj[key]);
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
191
226
|
/**
|
|
192
227
|
* Parse JSON with reduced limits for untrusted input.
|
|
193
228
|
*
|
|
@@ -382,40 +417,182 @@ const FORBIDDEN_SCHEMES = new Set([
|
|
|
382
417
|
"webcal",
|
|
383
418
|
"urn",
|
|
384
419
|
]);
|
|
420
|
+
// FIX (H1): The regex-based PRIVATE_IP_RANGES list was bypassable via
|
|
421
|
+
// IPv6 forms like `http://[::0:1]/`, `http://[::ffff:7f00:1]/` (hex), and
|
|
422
|
+
// `http://[::ffff:a9fe:a9fe]/` (IMDS 169.254.169.254), plus numeric IPv4
|
|
423
|
+
// hostnames like `http://2130706433/` (127.0.0.1) and `http://0x7f000001/`.
|
|
424
|
+
// isSafeURL() now expands addresses to bytes and compares ranges numerically.
|
|
385
425
|
/**
|
|
386
|
-
*
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
/^::1$/i, // IPv6 loopback
|
|
404
|
-
/^fc00:/i, // IPv6 ULA (fc00::/7 - includes fc00: and fd00:)
|
|
405
|
-
/^fd00:/i, // IPv6 ULA (fd00::/8 - unique local)
|
|
406
|
-
/^fe80:/i, // IPv6 link-local (fe80::/10)
|
|
407
|
-
/^2001:db8:/i, // IPv6 documentation range
|
|
408
|
-
/^::0?ffff:/i, // IPv4-mapped IPv6 (::ffff:...)
|
|
409
|
-
/^0:0:0:0:0:ffff:/i, // IPv4-mapped compressed form
|
|
410
|
-
// FIX 4: IPv4-mapped IPv6 SSRF bypass — e.g. http://[::ffff:127.0.0.1]
|
|
411
|
-
/^::ffff:127\./i, // IPv4-mapped loopback
|
|
412
|
-
/^::ffff:10\./i, // IPv4-mapped RFC 1918
|
|
413
|
-
/^::ffff:192\.168\./i, // IPv4-mapped RFC 1918
|
|
414
|
-
/^::ffff:172\.(1[6-9]|2\d|3[01])\./i, // IPv4-mapped RFC 1918
|
|
415
|
-
/^::ffff:169\.254\./i, // IPv4-mapped link-local
|
|
416
|
-
/^0:0:0:0:0:ffff:7f/i, // compressed form of ::ffff:127.x
|
|
417
|
-
/^::$/i, // IPv4-compatible :: (any ::/96)
|
|
426
|
+
* Reserved IPv4 ranges blocked for SSRF prevention, as [lo, hi] 32-bit values.
|
|
427
|
+
*/
|
|
428
|
+
const IPV4_BLOCKED_RANGES = [
|
|
429
|
+
[0x00000000, 0x00ffffff], // 0.0.0.0/8 — this-network
|
|
430
|
+
[0x0a000000, 0x0affffff], // 10.0.0.0/8 — RFC 1918 private
|
|
431
|
+
[0x64400000, 0x647fffff], // 100.64.0.0/10 — CGNAT (RFC 6598)
|
|
432
|
+
[0x7f000000, 0x7fffffff], // 127.0.0.0/8 — loopback
|
|
433
|
+
[0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 — link-local (AWS IMDS etc.)
|
|
434
|
+
[0xac100000, 0xac1fffff], // 172.16.0.0/12 — RFC 1918 private
|
|
435
|
+
[0xc0000000, 0xc00000ff], // 192.0.0.0/24 — IETF protocol assignments
|
|
436
|
+
[0xc0000200, 0xc00002ff], // 192.0.2.0/24 — TEST-NET-1
|
|
437
|
+
[0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16 — RFC 1918 private
|
|
438
|
+
[0xc6120000, 0xc613ffff], // 198.18.0.0/15 — benchmarking
|
|
439
|
+
[0xc6336400, 0xc63364ff], // 198.51.100.0/24 — TEST-NET-2
|
|
440
|
+
[0xcb007100, 0xcb0071ff], // 203.0.113.0/24 — TEST-NET-3
|
|
441
|
+
[0xe0000000, 0xefffffff], // 224.0.0.0/4 — multicast
|
|
442
|
+
[0xf0000000, 0xffffffff], // 240.0.0.0/4 — reserved (incl. 255.255.255.255)
|
|
418
443
|
];
|
|
444
|
+
/** True when the 32-bit IPv4 value falls inside a reserved/blocked range. */
|
|
445
|
+
function isBlockedIPv4(n) {
|
|
446
|
+
return IPV4_BLOCKED_RANGES.some(([lo, hi]) => n >= lo && n <= hi);
|
|
447
|
+
}
|
|
448
|
+
/** Parse one dotted/numeric component: decimal, hex (0x…), or octal (0…). */
|
|
449
|
+
function parseIPv4Component(p) {
|
|
450
|
+
if (p === "")
|
|
451
|
+
return null;
|
|
452
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(p))
|
|
453
|
+
return parseInt(p, 16);
|
|
454
|
+
if (/^0[0-7]+$/.test(p))
|
|
455
|
+
return parseInt(p, 8);
|
|
456
|
+
if (/^\d+$/.test(p))
|
|
457
|
+
return parseInt(p, 10);
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Best-effort parse of a dotted or numeric IPv4 literal, accepting decimal,
|
|
462
|
+
* hex (0x…), and octal (0…) component forms — the same shapes URL parsers
|
|
463
|
+
* and resolvers accept (e.g. "2130706433", "0x7f.1", "0177.0.0.1").
|
|
464
|
+
* Returns the 32-bit value, or null when the host cannot be an IPv4 literal.
|
|
465
|
+
*/
|
|
466
|
+
function parseIPv4Host(host) {
|
|
467
|
+
if (host.includes(":") || !/^[0-9a-fA-FxX.]+$/.test(host))
|
|
468
|
+
return null;
|
|
469
|
+
const parts = host.split(".");
|
|
470
|
+
if (parts.length > 4)
|
|
471
|
+
return null;
|
|
472
|
+
if (parts.length === 1) {
|
|
473
|
+
const v = parseIPv4Component(parts[0]);
|
|
474
|
+
if (v === null || v > 0xffffffff)
|
|
475
|
+
return null;
|
|
476
|
+
return v >>> 0;
|
|
477
|
+
}
|
|
478
|
+
const nums = [];
|
|
479
|
+
for (const p of parts) {
|
|
480
|
+
const v = parseIPv4Component(p);
|
|
481
|
+
// Last part may absorb the remainder per WHATWG; cap it at 24 bits.
|
|
482
|
+
const isLast = p === parts[parts.length - 1];
|
|
483
|
+
if (v === null || v > (isLast && nums.length === 3 ? 0xff : 0xff))
|
|
484
|
+
return null;
|
|
485
|
+
nums.push(v);
|
|
486
|
+
}
|
|
487
|
+
while (nums.length < 4)
|
|
488
|
+
nums.push(0);
|
|
489
|
+
return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Expand an IPv6 address (bracket-stripped) into its 16 bytes.
|
|
493
|
+
* Handles :: compression, IPv4-mapped dotted tails, and zone indexes.
|
|
494
|
+
* Returns null when the address is not a valid IPv6 literal.
|
|
495
|
+
*/
|
|
496
|
+
function parseIPv6Host(host) {
|
|
497
|
+
if (!host.includes(":"))
|
|
498
|
+
return null;
|
|
499
|
+
const bare = host.split("%")[0]; // strip zone index (fe80::1%eth0)
|
|
500
|
+
const halves = bare.split("::");
|
|
501
|
+
if (halves.length > 2)
|
|
502
|
+
return null;
|
|
503
|
+
const expandGroups = (part) => {
|
|
504
|
+
if (part === "")
|
|
505
|
+
return [];
|
|
506
|
+
const groups = part.split(":");
|
|
507
|
+
const out = [];
|
|
508
|
+
for (let i = 0; i < groups.length; i++) {
|
|
509
|
+
const g = groups[i];
|
|
510
|
+
if (g === "")
|
|
511
|
+
return null;
|
|
512
|
+
if (g.includes(".")) {
|
|
513
|
+
// IPv4 dotted tail (e.g. ::ffff:127.0.0.1) must be the last group
|
|
514
|
+
if (i !== groups.length - 1)
|
|
515
|
+
return null;
|
|
516
|
+
const n = parseIPv4Host(g);
|
|
517
|
+
if (n === null)
|
|
518
|
+
return null;
|
|
519
|
+
out.push((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(g))
|
|
523
|
+
return null;
|
|
524
|
+
const v = parseInt(g, 16);
|
|
525
|
+
out.push((v >>> 8) & 0xff, v & 0xff);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
};
|
|
530
|
+
let bytes;
|
|
531
|
+
if (halves.length === 2) {
|
|
532
|
+
const left = expandGroups(halves[0]);
|
|
533
|
+
const right = expandGroups(halves[1]);
|
|
534
|
+
if (!left || !right)
|
|
535
|
+
return null;
|
|
536
|
+
const fill = 16 - left.length - right.length;
|
|
537
|
+
if (fill < 0)
|
|
538
|
+
return null;
|
|
539
|
+
bytes = [...left, ...new Array(fill).fill(0), ...right];
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
const all = expandGroups(bare);
|
|
543
|
+
if (!all || all.length !== 16)
|
|
544
|
+
return null;
|
|
545
|
+
bytes = all;
|
|
546
|
+
}
|
|
547
|
+
return bytes.length === 16 ? new Uint8Array(bytes) : null;
|
|
548
|
+
}
|
|
549
|
+
/** True when the 16-byte IPv6 address is in a reserved/blocked range. */
|
|
550
|
+
function isBlockedIPv6(b) {
|
|
551
|
+
const read32 = (i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
552
|
+
// :: (unspecified) — always blocked
|
|
553
|
+
if (b.every((x) => x === 0))
|
|
554
|
+
return true;
|
|
555
|
+
// ::1/128 — loopback
|
|
556
|
+
if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1)
|
|
557
|
+
return true;
|
|
558
|
+
const first = b[0];
|
|
559
|
+
// fc00::/7 — unique local addresses (fc00::/8 + fd00::/8)
|
|
560
|
+
if ((first & 0xfe) === 0xfc)
|
|
561
|
+
return true;
|
|
562
|
+
// fe80::/10 — link-local
|
|
563
|
+
if (first === 0xfe && (b[1] & 0xc0) === 0x80)
|
|
564
|
+
return true;
|
|
565
|
+
// ff00::/8 — multicast
|
|
566
|
+
if (first === 0xff)
|
|
567
|
+
return true;
|
|
568
|
+
// 2001:db8::/32 — documentation
|
|
569
|
+
if (b[0] === 0x20 && b[1] === 0x01 && b[2] === 0x0d && b[3] === 0xb8)
|
|
570
|
+
return true;
|
|
571
|
+
// ::ffff:0:0/96 — IPv4-mapped → apply the IPv4 checks to the tail
|
|
572
|
+
if (b.slice(0, 10).every((x) => x === 0) &&
|
|
573
|
+
b[10] === 0xff &&
|
|
574
|
+
b[11] === 0xff) {
|
|
575
|
+
return isBlockedIPv4(read32(12));
|
|
576
|
+
}
|
|
577
|
+
// ::/96 — deprecated IPv4-compatible → apply the IPv4 checks to the tail
|
|
578
|
+
// (covers e.g. [::127.0.0.1] and [::169.254.169.254])
|
|
579
|
+
if (b.slice(0, 12).every((x) => x === 0)) {
|
|
580
|
+
return isBlockedIPv4(read32(12));
|
|
581
|
+
}
|
|
582
|
+
// 64:ff9b::/96 — NAT64 (RFC 6052): traffic is translated to the embedded IPv4
|
|
583
|
+
if (b[0] === 0x00 &&
|
|
584
|
+
b[1] === 0x64 &&
|
|
585
|
+
b[2] === 0xff &&
|
|
586
|
+
b[3] === 0x9b &&
|
|
587
|
+
b.slice(4, 12).every((x) => x === 0)) {
|
|
588
|
+
return isBlockedIPv4(read32(12));
|
|
589
|
+
}
|
|
590
|
+
// 2002::/16 — 6to4: the embedded IPv4 sits in bits 16–47
|
|
591
|
+
if (b[0] === 0x20 && b[1] === 0x02) {
|
|
592
|
+
return isBlockedIPv4(read32(2));
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
419
596
|
/**
|
|
420
597
|
* Validate a URL for safety.
|
|
421
598
|
* @param url - URL to validate
|
|
@@ -435,18 +612,25 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
|
435
612
|
return false;
|
|
436
613
|
}
|
|
437
614
|
let host = parsed.hostname.toLowerCase();
|
|
438
|
-
|
|
439
|
-
if (
|
|
615
|
+
const isV6Literal = host.startsWith("[") && host.endsWith("]");
|
|
616
|
+
if (isV6Literal)
|
|
440
617
|
host = host.slice(1, -1);
|
|
441
|
-
//
|
|
442
|
-
if (host === "0
|
|
443
|
-
host = "::1";
|
|
444
|
-
// Block loopback and localhost
|
|
445
|
-
if (host === "localhost" || host === "0.0.0.0")
|
|
446
|
-
return false;
|
|
447
|
-
// Block private IP ranges (SSRF prevention)
|
|
448
|
-
if (PRIVATE_IP_RANGES.some((r) => r.test(host)))
|
|
618
|
+
// Block loopback hostnames (including *.localhost subdomains)
|
|
619
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
|
|
449
620
|
return false;
|
|
621
|
+
// Block private/reserved IPs (SSRF prevention) — numeric comparison over
|
|
622
|
+
// expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
|
|
623
|
+
if (isV6Literal || host.includes(":")) {
|
|
624
|
+
const v6 = parseIPv6Host(host);
|
|
625
|
+
// Unparseable IPv6 literal → reject defensively
|
|
626
|
+
if (v6 === null || isBlockedIPv6(v6))
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
const v4 = parseIPv4Host(host);
|
|
631
|
+
if (v4 !== null && isBlockedIPv4(v4))
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
450
634
|
// Check for suspicious patterns (path traversal)
|
|
451
635
|
if (parsed.hostname.includes("..") || parsed.pathname.includes("..")) {
|
|
452
636
|
return false;
|
|
@@ -518,6 +702,9 @@ export function deepClone(value) {
|
|
|
518
702
|
}
|
|
519
703
|
const cloned = {};
|
|
520
704
|
for (const key in value) {
|
|
705
|
+
// Prototype-pollution guard: never copy __proto__ / constructor / prototype
|
|
706
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype")
|
|
707
|
+
continue;
|
|
521
708
|
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
522
709
|
cloned[key] = deepClone(value[key]);
|
|
523
710
|
}
|
|
@@ -777,31 +964,25 @@ export function isAbortError(error) {
|
|
|
777
964
|
* Uses `crypto.getRandomValues()` which is available in all target runtimes
|
|
778
965
|
* (Node 18+, Deno, Bun, Browser, Cloudflare Workers, Vercel Edge).
|
|
779
966
|
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
967
|
+
* FIX (M8): removed the `Math.random()` fallback — non-CSPRNG output must
|
|
968
|
+
* never be used for nonces/cnonces (digest auth) or trace IDs. Environments
|
|
969
|
+
* without a CSPRNG now fail fast instead of silently producing predictable
|
|
970
|
+
* values.
|
|
782
971
|
*
|
|
783
972
|
* @param byteCount - Number of random bytes (output hex length = byteCount * 2)
|
|
784
973
|
* @returns Hex-encoded random string
|
|
974
|
+
* @throws {Error} When no CSPRNG is available in the current runtime
|
|
785
975
|
*/
|
|
786
976
|
export function randomBytes(byteCount) {
|
|
977
|
+
if (!Number.isInteger(byteCount) || byteCount < 0 || byteCount > 65536) {
|
|
978
|
+
throw new Error(`randomBytes: invalid byteCount ${byteCount}`);
|
|
979
|
+
}
|
|
787
980
|
const arr = new Uint8Array(byteCount);
|
|
788
981
|
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
789
982
|
crypto.getRandomValues(arr);
|
|
790
983
|
}
|
|
791
984
|
else {
|
|
792
|
-
|
|
793
|
-
// in all WinterCG-compliant runtimes. Repeat to fill requested length.
|
|
794
|
-
const uuid = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
795
|
-
? crypto.randomUUID().replace(/-/g, "")
|
|
796
|
-
: "";
|
|
797
|
-
if (uuid.length >= byteCount * 2) {
|
|
798
|
-
return uuid.slice(0, byteCount * 2);
|
|
799
|
-
}
|
|
800
|
-
// Last-resort fallback for environments with no crypto at all.
|
|
801
|
-
// This should never be reached in any runtime kinetex targets.
|
|
802
|
-
for (let i = 0; i < arr.length; i++) {
|
|
803
|
-
arr[i] = Math.floor(Math.random() * 256);
|
|
804
|
-
}
|
|
985
|
+
throw new Error("randomBytes: no CSPRNG available in this runtime — crypto.getRandomValues is required");
|
|
805
986
|
}
|
|
806
987
|
return Array.from(arr)
|
|
807
988
|
.map((b) => b.toString(16).padStart(2, "0"))
|
package/dist/cjs/ws.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* - Headers: real HTTP Upgrade on Deno/Bun/Node; URL params on browsers
|
|
18
18
|
* - Cross-runtime: Node.js 22, Deno, Bun, Browser, Cloudflare Workers
|
|
19
19
|
*/
|
|
20
|
+
// FIX (H6): remote WS payloads are untrusted — parsed JSON dispatched to
|
|
21
|
+
// listeners is stripped of prototype-pollution keys (see sanitizeParsedJSON).
|
|
22
|
+
import { sanitizeParsedJSON } from "./utils.js";
|
|
20
23
|
// ============================================================================
|
|
21
24
|
// §2 ERRORS
|
|
22
25
|
// ============================================================================
|
|
@@ -760,7 +763,9 @@ export class WSClient {
|
|
|
760
763
|
data = evt.data;
|
|
761
764
|
bytes = new TextEncoder().encode(data).byteLength;
|
|
762
765
|
try {
|
|
763
|
-
|
|
766
|
+
// FIX (H6): remote WS payloads are untrusted — strip
|
|
767
|
+
// prototype-pollution keys before dispatching to listeners.
|
|
768
|
+
json = sanitizeParsedJSON(JSON.parse(data));
|
|
764
769
|
}
|
|
765
770
|
catch {
|
|
766
771
|
/* not JSON */
|
package/dist/esm/client.js
CHANGED
|
@@ -130,6 +130,40 @@ function randomHex(len) {
|
|
|
130
130
|
// ============================================================================
|
|
131
131
|
// §3 HAR RECORDER (inline)
|
|
132
132
|
// ============================================================================
|
|
133
|
+
/**
|
|
134
|
+
* Headers redacted before being written to HAR entries (FIX M2).
|
|
135
|
+
* Mirrors logging.ts DEFAULT_REDACT_HEADERS — HAR logs are routinely exported
|
|
136
|
+
* and shared, so credentials must never appear verbatim.
|
|
137
|
+
*/
|
|
138
|
+
const HAR_REDACT_HEADERS = new Set([
|
|
139
|
+
"authorization",
|
|
140
|
+
"proxy-authorization",
|
|
141
|
+
"cookie",
|
|
142
|
+
"set-cookie",
|
|
143
|
+
"x-api-key",
|
|
144
|
+
"x-auth-token",
|
|
145
|
+
"x-access-token",
|
|
146
|
+
"x-refresh-token",
|
|
147
|
+
"x-csrf-token",
|
|
148
|
+
"x-session-id",
|
|
149
|
+
"x-session-token",
|
|
150
|
+
"x-secret",
|
|
151
|
+
"x-secret-key",
|
|
152
|
+
"x-private-key",
|
|
153
|
+
"api-key",
|
|
154
|
+
"apikey",
|
|
155
|
+
"bearer",
|
|
156
|
+
"token",
|
|
157
|
+
"authentication",
|
|
158
|
+
"credentials",
|
|
159
|
+
"password",
|
|
160
|
+
"passwd",
|
|
161
|
+
"secret",
|
|
162
|
+
]);
|
|
163
|
+
/** Redact a single header value for HAR output. */
|
|
164
|
+
function redactHARHeader(name, value) {
|
|
165
|
+
return HAR_REDACT_HEADERS.has(name.toLowerCase()) ? { name, value: "***REDACTED***" } : { name, value };
|
|
166
|
+
}
|
|
133
167
|
/**
|
|
134
168
|
* O(1) ring-buffer HAR entry recorder.
|
|
135
169
|
* Stores up to `maxEntries` entries, evicting oldest first.
|
|
@@ -192,7 +226,7 @@ class HARRecorder {
|
|
|
192
226
|
method: req.method,
|
|
193
227
|
url: req.url,
|
|
194
228
|
httpVersion: res.httpVersion,
|
|
195
|
-
headers: Object.entries(req.headers).map(([name, value]) => (
|
|
229
|
+
headers: Object.entries(req.headers).map(([name, value]) => redactHARHeader(name, value)),
|
|
196
230
|
queryString: (() => {
|
|
197
231
|
try {
|
|
198
232
|
return Array.from(new URL(req.url).searchParams.entries()).map(([name, value]) => ({
|
|
@@ -220,7 +254,7 @@ class HARRecorder {
|
|
|
220
254
|
status: res.status,
|
|
221
255
|
statusText: res.statusText,
|
|
222
256
|
httpVersion: res.httpVersion,
|
|
223
|
-
headers: Object.entries(res.headers).map(([name, value]) => (
|
|
257
|
+
headers: Object.entries(res.headers).map(([name, value]) => redactHARHeader(name, value)),
|
|
224
258
|
content: {
|
|
225
259
|
size: res.rawBody?.byteLength ?? 0,
|
|
226
260
|
mimeType: res.headers["content-type"] ?? "application/octet-stream",
|
|
@@ -279,7 +313,14 @@ async function applyAuth(req, auth) {
|
|
|
279
313
|
switch (auth.type) {
|
|
280
314
|
case "bearer": {
|
|
281
315
|
const token = typeof auth.token === "function" ? await auth.token() : auth.token;
|
|
282
|
-
|
|
316
|
+
// FIX (H3): token values — especially from async providers — must be
|
|
317
|
+
// validated before injection. A token containing CRLF would split or
|
|
318
|
+
// forge headers on the wire (header injection).
|
|
319
|
+
const headerValue = `Bearer ${token}`;
|
|
320
|
+
if (!isValidHeaderValue(headerValue)) {
|
|
321
|
+
throw new KinetexError("Invalid bearer token — contains forbidden characters (CRLF/CTL)", "EVALIDATION");
|
|
322
|
+
}
|
|
323
|
+
headers["authorization"] = headerValue;
|
|
283
324
|
break;
|
|
284
325
|
}
|
|
285
326
|
case "basic": {
|
|
@@ -303,7 +344,17 @@ async function applyAuth(req, auth) {
|
|
|
303
344
|
}
|
|
304
345
|
case "apikey": {
|
|
305
346
|
const key = typeof auth.key === "function" ? await auth.key() : auth.key;
|
|
306
|
-
|
|
347
|
+
// FIX (LOW): validate the custom header name — an apikey header containing
|
|
348
|
+
// CRLF or spaces would be injected verbatim into the request.
|
|
349
|
+
if (!isValidHeaderName(auth.header)) {
|
|
350
|
+
throw new KinetexError(`Invalid apikey auth header name: "${auth.header}"`, "EVALIDATION");
|
|
351
|
+
}
|
|
352
|
+
// FIX (H3): the key value is equally attacker-influenced when provided
|
|
353
|
+
// via an async provider — validate before injection.
|
|
354
|
+
if (!isValidHeaderValue(String(key))) {
|
|
355
|
+
throw new KinetexError(`Invalid apikey value for "${auth.header}" — contains forbidden characters`, "EVALIDATION");
|
|
356
|
+
}
|
|
357
|
+
headers[auth.header.toLowerCase()] = String(key);
|
|
307
358
|
break;
|
|
308
359
|
}
|
|
309
360
|
case "digest": {
|
|
@@ -328,6 +379,46 @@ async function applyAuth(req, auth) {
|
|
|
328
379
|
// ============================================================================
|
|
329
380
|
// §5 URL BUILDING
|
|
330
381
|
// ============================================================================
|
|
382
|
+
/**
|
|
383
|
+
* Headers stripped when a redirect crosses origins (FIX H2).
|
|
384
|
+
* These carry credentials and must never be forwarded to a different origin.
|
|
385
|
+
*/
|
|
386
|
+
const CROSS_ORIGIN_STRIP_HEADERS = new Set([
|
|
387
|
+
"authorization",
|
|
388
|
+
"cookie",
|
|
389
|
+
"proxy-authorization",
|
|
390
|
+
"x-api-key",
|
|
391
|
+
"x-auth-token",
|
|
392
|
+
"x-access-token",
|
|
393
|
+
"x-refresh-token",
|
|
394
|
+
"x-csrf-token",
|
|
395
|
+
"x-session-id",
|
|
396
|
+
"x-session-token",
|
|
397
|
+
"x-secret",
|
|
398
|
+
"x-secret-key",
|
|
399
|
+
"x-private-key",
|
|
400
|
+
"api-key",
|
|
401
|
+
"apikey",
|
|
402
|
+
"www-authenticate",
|
|
403
|
+
]);
|
|
404
|
+
/**
|
|
405
|
+
* Strip userinfo (user:pass@) from a URL string for safe error messages (FIX M5).
|
|
406
|
+
* Falls back to a regex strip when the URL cannot be parsed.
|
|
407
|
+
*/
|
|
408
|
+
function redactUserInfo(url) {
|
|
409
|
+
try {
|
|
410
|
+
const u = new URL(url);
|
|
411
|
+
if (u.username || u.password) {
|
|
412
|
+
u.username = "";
|
|
413
|
+
u.password = "";
|
|
414
|
+
return u.toString();
|
|
415
|
+
}
|
|
416
|
+
return url;
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return url.replace(/\/\/[^/@]*@/, "//");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
331
422
|
/**
|
|
332
423
|
* Resolve a URL against an optional base and append query parameters.
|
|
333
424
|
* Rejects unsafe URLs (private/loopback addresses). Enforces query param
|
|
@@ -368,7 +459,7 @@ function buildURL(base, url, params) {
|
|
|
368
459
|
}
|
|
369
460
|
if (!params || Object.keys(params).length === 0) {
|
|
370
461
|
if (!isSafeURL(full)) {
|
|
371
|
-
throw new KinetexError(`URL "${full}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
462
|
+
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
372
463
|
}
|
|
373
464
|
return full;
|
|
374
465
|
}
|
|
@@ -405,7 +496,7 @@ function buildURL(base, url, params) {
|
|
|
405
496
|
}
|
|
406
497
|
// Validate the final URL with params
|
|
407
498
|
if (!isSafeURL(result)) {
|
|
408
|
-
throw new KinetexError(`URL "${result}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
499
|
+
throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
409
500
|
}
|
|
410
501
|
return result;
|
|
411
502
|
}
|
|
@@ -448,6 +539,11 @@ function buildURL(base, url, params) {
|
|
|
448
539
|
if (result.length > MAX_URL_LENGTH) {
|
|
449
540
|
throw new KinetexError(`URL length ${result.length} bytes exceeds limit of ${MAX_URL_LENGTH} bytes`, "EVALIDATION");
|
|
450
541
|
}
|
|
542
|
+
// FIX M4: the manual param-concat fallback previously returned WITHOUT a
|
|
543
|
+
// safety check — validate the assembled URL like every other path.
|
|
544
|
+
if (!isSafeURL(result)) {
|
|
545
|
+
throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
546
|
+
}
|
|
451
547
|
return result;
|
|
452
548
|
}
|
|
453
549
|
}
|
|
@@ -1293,6 +1389,16 @@ export class Kinetex {
|
|
|
1293
1389
|
}
|
|
1294
1390
|
// ── Build initial request ─────────────────────────────────────────────
|
|
1295
1391
|
const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
|
|
1392
|
+
// FIX (M7): proxy configuration was stored but never consumed — a silent
|
|
1393
|
+
// no-op that sent traffic directly to the target, bypassing the user's
|
|
1394
|
+
// proxy entirely. Fail fast with actionable guidance instead.
|
|
1395
|
+
const proxy = options.proxy ?? this.cfg.proxy;
|
|
1396
|
+
if (proxy) {
|
|
1397
|
+
throw new KinetexError("proxy is configured but kinetex's built-in transports cannot route through it: " +
|
|
1398
|
+
"HTTP(S) proxies require a custom fetch with a proxy agent (e.g. undici ProxyAgent " +
|
|
1399
|
+
"passed via the `fetch` option), and SOCKS5 requires createSocks5Tunnel() from " +
|
|
1400
|
+
"kinetex/socks5. Set up one of those instead of relying on `proxy` silently doing nothing.", "EVALIDATION");
|
|
1401
|
+
}
|
|
1296
1402
|
// Enforce HTTPS-only if configured
|
|
1297
1403
|
if (this.cfg.httpsOnly) {
|
|
1298
1404
|
try {
|
|
@@ -1323,12 +1429,39 @@ export class Kinetex {
|
|
|
1323
1429
|
else if (options.body instanceof Blob) {
|
|
1324
1430
|
bodySize = options.body.size;
|
|
1325
1431
|
}
|
|
1432
|
+
else if (ArrayBuffer.isView(options.body)) {
|
|
1433
|
+
// FIX (H4): other typed-array/DataView views were uncounted.
|
|
1434
|
+
// ArrayBuffer.isView() is used instead of `instanceof ArrayBufferView`
|
|
1435
|
+
// because there is no runtime global to instanceof against.
|
|
1436
|
+
bodySize = options.body.byteLength;
|
|
1437
|
+
}
|
|
1438
|
+
else if (options.body instanceof URLSearchParams) {
|
|
1439
|
+
// FIX (H4): previously silently skipped — count the serialized form.
|
|
1440
|
+
bodySize = new TextEncoder().encode(options.body.toString()).byteLength;
|
|
1441
|
+
}
|
|
1326
1442
|
else if (options.body instanceof FormData) {
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1443
|
+
// FIX (H4): estimate multipart size instead of skipping entirely —
|
|
1444
|
+
// the old skip allowed unbounded uploads past the configured limit.
|
|
1445
|
+
const boundaryOverhead = 76; // per part: --boundary, headers, CRLF (conservative)
|
|
1446
|
+
for (const [name, value] of options.body) {
|
|
1447
|
+
bodySize += new TextEncoder().encode(name).byteLength + boundaryOverhead;
|
|
1448
|
+
if (typeof value === "string") {
|
|
1449
|
+
bodySize += new TextEncoder().encode(value).byteLength;
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
bodySize += value.size;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
bodySize += boundaryOverhead; // final boundary
|
|
1456
|
+
}
|
|
1457
|
+
else if (options.body instanceof ReadableStream) {
|
|
1458
|
+
// FIX (H4): a stream's size cannot be known without consuming it —
|
|
1459
|
+
// reject rather than silently bypassing the limit. Callers who need
|
|
1460
|
+
// streaming uploads must pass maxRequestSize: 0 explicitly.
|
|
1461
|
+
throw new KinetexError(`maxRequestSize cannot be enforced for ReadableStream bodies — pass maxRequestSize: 0 to opt out, or buffer the body first`, "EVALIDATION");
|
|
1329
1462
|
}
|
|
1330
1463
|
else if (options.body && typeof options.body === "object") {
|
|
1331
|
-
bodySize = JSON.stringify(options.body).
|
|
1464
|
+
bodySize = new TextEncoder().encode(JSON.stringify(options.body)).byteLength;
|
|
1332
1465
|
}
|
|
1333
1466
|
if (bodySize > maxRequestSize) {
|
|
1334
1467
|
throw new KinetexError(`Request body size ${bodySize} bytes exceeds limit of ${maxRequestSize} bytes`, "EVALIDATION");
|
|
@@ -1559,12 +1692,33 @@ export class Kinetex {
|
|
|
1559
1692
|
async _sendFollowingRedirects(req, timeout, jar, appliedAuth) {
|
|
1560
1693
|
const MAX_REDIRECTS = 20;
|
|
1561
1694
|
let currentReq = { ...req, redirect: "manual" };
|
|
1695
|
+
const origin0 = (() => {
|
|
1696
|
+
try {
|
|
1697
|
+
return new URL(req.url).origin;
|
|
1698
|
+
}
|
|
1699
|
+
catch {
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
})();
|
|
1562
1703
|
// Track visited URLs to detect redirect loops
|
|
1563
1704
|
const visited = new Set();
|
|
1564
1705
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
1565
|
-
//
|
|
1706
|
+
// FIX H2 (part 2): re-apply auth on every hop ONLY while we remain on the
|
|
1707
|
+
// original origin. Once a redirect has crossed origins, credential-bearing
|
|
1708
|
+
// headers must not be re-injected — otherwise the cross-origin strip in
|
|
1709
|
+
// the redirect branch below would be immediately undone.
|
|
1566
1710
|
if (hop > 0 && appliedAuth) {
|
|
1567
|
-
|
|
1711
|
+
const sameOrigin = (() => {
|
|
1712
|
+
try {
|
|
1713
|
+
return new URL(currentReq.url).origin === origin0;
|
|
1714
|
+
}
|
|
1715
|
+
catch {
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
})();
|
|
1719
|
+
if (sameOrigin) {
|
|
1720
|
+
currentReq = await applyAuth(currentReq, appliedAuth);
|
|
1721
|
+
}
|
|
1568
1722
|
}
|
|
1569
1723
|
// Fire request interceptors and onBeforeRequest hooks on every hop
|
|
1570
1724
|
// so auth headers, logging, and tracing apply to redirect legs too.
|
|
@@ -1656,6 +1810,21 @@ export class Kinetex {
|
|
|
1656
1810
|
else {
|
|
1657
1811
|
delete nextHeaders["cookie"];
|
|
1658
1812
|
}
|
|
1813
|
+
// FIX H2: When the redirect crosses origins, strip credential-bearing
|
|
1814
|
+
// headers (Authorization, Cookie, proxy auth, API keys) so secrets are
|
|
1815
|
+
// never forwarded to a different origin (RFC 9110 7.1 semantics).
|
|
1816
|
+
// Cookies for the new origin are re-established by the jar lookup above;
|
|
1817
|
+
// jar scoping guarantees only same-site cookies apply.
|
|
1818
|
+
try {
|
|
1819
|
+
if (new URL(nextUrl).origin !== new URL(currentReq.url).origin) {
|
|
1820
|
+
for (const h of CROSS_ORIGIN_STRIP_HEADERS) {
|
|
1821
|
+
delete nextHeaders[h];
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
catch {
|
|
1826
|
+
/* nextUrl was already validated above */
|
|
1827
|
+
}
|
|
1659
1828
|
currentReq = {
|
|
1660
1829
|
...currentReq,
|
|
1661
1830
|
url: nextUrl,
|