kinetex 1.0.0 → 1.2.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 +581 -275
- 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 +53 -27
- package/dist/cjs/graphql.js +37 -8
- package/dist/cjs/interceptors.js +22 -11
- package/dist/cjs/logging.js +10 -2
- package/dist/cjs/mod.js +1 -1
- package/dist/cjs/pagination.js +6 -1
- package/dist/cjs/progress.js +13 -6
- package/dist/cjs/response.js +8 -6
- package/dist/cjs/sse.js +5 -2
- package/dist/cjs/utils.js +250 -57
- package/dist/cjs/ws.js +16 -2
- 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 +53 -27
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/graphql.js +37 -8
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/interceptors.js +22 -11
- package/dist/esm/interceptors.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/progress.js +13 -6
- package/dist/esm/progress.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 +250 -57
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +16 -2
- 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 +4 -0
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/interceptors.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/progress.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 +4 -0
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +5 -5
package/dist/cjs/mod.js
CHANGED
|
@@ -95,4 +95,4 @@ export { HookRegistry, HookEmitter } from "./lifecycle.js";
|
|
|
95
95
|
export { CircuitBreaker, CircuitBreakerRegistry, CircuitOpenError, createCircuitBreaker, createCircuitBreakerRegistry, } from "./circuit-breaker.js";
|
|
96
96
|
export { DedupMap, createDedupMap } from "./dedup.js";
|
|
97
97
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
98
|
-
export { safeJSONParse, tryParseJSON, parseUntrustedJSON, isUint8Array, isArrayBuffer, isReadableStream, isHeaders, isAbortSignal, isPlainObject, isFormData, isBlob, isURLSearchParams, isValidHeaderName, isValidHeaderValue, isSafeURL, sanitizeURL, createStructuredError, formatError, perfNow, sleep, concatUint8Arrays, toUint8Array, uint8ArrayToBase64, deepClone, normalizeHeaders, mergeSignals, isAbortError, getRuntime, isNodeEnvironment, isBrowserEnvironment, hasNativeFetch, } from "./utils.js";
|
|
98
|
+
export { safeJSONParse, tryParseJSON, parseUntrustedJSON, sanitizeParsedJSON, isUint8Array, isArrayBuffer, isReadableStream, isHeaders, isAbortSignal, isPlainObject, isFormData, isBlob, isURLSearchParams, isValidHeaderName, isValidHeaderValue, isSafeURL, sanitizeURL, createStructuredError, formatError, perfNow, sleep, concatUint8Arrays, toUint8Array, uint8ArrayToBase64, deepClone, normalizeHeaders, mergeSignals, isAbortError, getRuntime, isNodeEnvironment, isBrowserEnvironment, hasNativeFetch, } from "./utils.js";
|
package/dist/cjs/pagination.js
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
* - Per-page transform / filter
|
|
23
23
|
* - Pagination state serialization (resume support)
|
|
24
24
|
*/
|
|
25
|
+
// FIX (H6): serialized pagination state may come from untrusted URLs —
|
|
26
|
+
// parsed JSON is stripped of prototype-pollution keys (see sanitizeParsedJSON).
|
|
27
|
+
import { sanitizeParsedJSON } from "./utils.js";
|
|
25
28
|
// ============================================================================
|
|
26
29
|
// §2 INITIAL STATE BUILDER
|
|
27
30
|
// ============================================================================
|
|
@@ -457,7 +460,9 @@ export function serializePaginationState(state) {
|
|
|
457
460
|
*/
|
|
458
461
|
export function deserializePaginationState(serialized) {
|
|
459
462
|
try {
|
|
460
|
-
|
|
463
|
+
// FIX (H6): serialized state can originate from untrusted URLs/clients —
|
|
464
|
+
// strip prototype-pollution keys before the state is spread into requests.
|
|
465
|
+
return sanitizeParsedJSON(JSON.parse(atob(serialized)));
|
|
461
466
|
}
|
|
462
467
|
catch {
|
|
463
468
|
throw new Error("Invalid pagination state string");
|
package/dist/cjs/progress.js
CHANGED
|
@@ -187,10 +187,13 @@ export function withUploadProgress(body, total, options = {}) {
|
|
|
187
187
|
controller.error(new DOMException("Upload aborted", "AbortError"));
|
|
188
188
|
return;
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
const onUploadAbort = () => {
|
|
191
191
|
reader.cancel("aborted").catch(() => { });
|
|
192
192
|
controller.error(new DOMException("Upload aborted", "AbortError"));
|
|
193
|
-
}
|
|
193
|
+
};
|
|
194
|
+
// Removed in the stream's finally block so repeated uploads sharing one
|
|
195
|
+
// signal do not accumulate listeners (leak fix).
|
|
196
|
+
options.signal?.addEventListener("abort", onUploadAbort, { once: true });
|
|
194
197
|
try {
|
|
195
198
|
while (true) {
|
|
196
199
|
const { done, value } = await reader.read();
|
|
@@ -207,6 +210,9 @@ export function withUploadProgress(body, total, options = {}) {
|
|
|
207
210
|
tracker.complete();
|
|
208
211
|
controller.error(err);
|
|
209
212
|
}
|
|
213
|
+
finally {
|
|
214
|
+
options.signal?.removeEventListener("abort", onUploadAbort);
|
|
215
|
+
}
|
|
210
216
|
},
|
|
211
217
|
});
|
|
212
218
|
return { stream, tracker };
|
|
@@ -342,11 +348,11 @@ export async function collectStream(stream, total, options = {}) {
|
|
|
342
348
|
reader.cancel("aborted").catch(() => { });
|
|
343
349
|
throw new DOMException("Stream aborted", "AbortError");
|
|
344
350
|
}
|
|
345
|
-
// Register abort handler
|
|
351
|
+
// Register abort handler (removed in the finally below — leak fix)
|
|
352
|
+
const abortHandler = () => {
|
|
353
|
+
reader.cancel("aborted").catch(() => { });
|
|
354
|
+
};
|
|
346
355
|
if (options.signal) {
|
|
347
|
-
const abortHandler = () => {
|
|
348
|
-
reader.cancel("aborted").catch(() => { });
|
|
349
|
-
};
|
|
350
356
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
351
357
|
}
|
|
352
358
|
try {
|
|
@@ -360,6 +366,7 @@ export async function collectStream(stream, total, options = {}) {
|
|
|
360
366
|
}
|
|
361
367
|
finally {
|
|
362
368
|
reader.releaseLock();
|
|
369
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
363
370
|
}
|
|
364
371
|
tracker.complete();
|
|
365
372
|
// Concatenate chunks
|
package/dist/cjs/response.js
CHANGED
|
@@ -150,7 +150,9 @@ export class ResponseDecodeError extends Error {
|
|
|
150
150
|
* Normalize a fetch Response headers to a plain object.
|
|
151
151
|
*/
|
|
152
152
|
// FIX 12: normalizeHeaders centralised in utils.ts.
|
|
153
|
-
|
|
153
|
+
// FIX (H6): sanitizeParsedJSON strips __proto__/constructor/prototype keys
|
|
154
|
+
// from freshly parsed JSON so untrusted payloads cannot pollute prototypes.
|
|
155
|
+
import { normalizeHeaders as _normalizeHeadersUtil, sanitizeParsedJSON as _sanitizeParsedJSON, } from "./utils.js";
|
|
154
156
|
/** @deprecated use normalizeHeaders from utils.ts directly */
|
|
155
157
|
export function normalizeHeaders(headers) {
|
|
156
158
|
return _normalizeHeadersUtil(headers);
|
|
@@ -526,7 +528,7 @@ export async function readJSON(response, options = {}) {
|
|
|
526
528
|
const charset = options.charset ?? parseContentType(ct ?? "")?.charset ?? null;
|
|
527
529
|
const text = decodeBody(bytes, charset, url);
|
|
528
530
|
try {
|
|
529
|
-
return JSON.parse(text);
|
|
531
|
+
return _sanitizeParsedJSON(JSON.parse(text));
|
|
530
532
|
}
|
|
531
533
|
catch (err) {
|
|
532
534
|
throw new ResponseDecodeError(`JSON parse failed: ${err}`, "json", url);
|
|
@@ -624,7 +626,7 @@ export async function* readNDJSON(response, options = {}) {
|
|
|
624
626
|
const trimmed = buffer.trim();
|
|
625
627
|
if (trimmed) {
|
|
626
628
|
try {
|
|
627
|
-
yield JSON.parse(trimmed);
|
|
629
|
+
yield _sanitizeParsedJSON(JSON.parse(trimmed));
|
|
628
630
|
}
|
|
629
631
|
catch (err) {
|
|
630
632
|
options.onParseError?.(err, trimmed);
|
|
@@ -640,7 +642,7 @@ export async function* readNDJSON(response, options = {}) {
|
|
|
640
642
|
if (!trimmed || trimmed.startsWith("#"))
|
|
641
643
|
continue;
|
|
642
644
|
try {
|
|
643
|
-
yield JSON.parse(trimmed);
|
|
645
|
+
yield _sanitizeParsedJSON(JSON.parse(trimmed));
|
|
644
646
|
}
|
|
645
647
|
catch (err) {
|
|
646
648
|
options.onParseError?.(err, trimmed);
|
|
@@ -699,7 +701,7 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
699
701
|
const remaining = flushBuffer();
|
|
700
702
|
if (remaining) {
|
|
701
703
|
try {
|
|
702
|
-
const obj = JSON.parse(remaining);
|
|
704
|
+
const obj = _sanitizeParsedJSON(JSON.parse(remaining));
|
|
703
705
|
options.onObject?.(obj);
|
|
704
706
|
yield obj;
|
|
705
707
|
}
|
|
@@ -722,7 +724,7 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
722
724
|
if (depth === 0 && startIdx !== -1) {
|
|
723
725
|
const objStr = buffer.slice(startIdx, i + 1);
|
|
724
726
|
try {
|
|
725
|
-
const obj = JSON.parse(objStr);
|
|
727
|
+
const obj = _sanitizeParsedJSON(JSON.parse(objStr));
|
|
726
728
|
options.onObject?.(obj);
|
|
727
729
|
yield obj;
|
|
728
730
|
}
|
package/dist/cjs/sse.js
CHANGED
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
* limit and 15min wall-clock time limit. Long-lived SSE connections may be terminated
|
|
27
27
|
* by the platform. Consider using shorter-lived streams with client-side reconnection.
|
|
28
28
|
*/
|
|
29
|
+
// FIX (H6): JSON event payloads from the server are untrusted — strip
|
|
30
|
+
// prototype-pollution keys before handing parsed data to user handlers.
|
|
31
|
+
import { sanitizeParsedJSON } from "./utils.js";
|
|
29
32
|
// ============================================================================
|
|
30
33
|
// §2 SSE PARSER (line-level state machine)
|
|
31
34
|
// ============================================================================
|
|
@@ -522,7 +525,7 @@ export async function* jsonSSE(source, options = {}) {
|
|
|
522
525
|
if (!evt.data && evt.data !== "")
|
|
523
526
|
continue;
|
|
524
527
|
try {
|
|
525
|
-
const data = JSON.parse(evt.data, options.reviver);
|
|
528
|
+
const data = sanitizeParsedJSON(JSON.parse(evt.data, options.reviver));
|
|
526
529
|
yield { event: evt.event, data, id: evt.id };
|
|
527
530
|
}
|
|
528
531
|
catch (err) {
|
|
@@ -563,7 +566,7 @@ export class SSERouter {
|
|
|
563
566
|
onJSON(eventType, handler) {
|
|
564
567
|
return this.on(eventType, async (data, evt) => {
|
|
565
568
|
try {
|
|
566
|
-
const parsed = JSON.parse(data);
|
|
569
|
+
const parsed = sanitizeParsedJSON(JSON.parse(data));
|
|
567
570
|
await handler(parsed, evt);
|
|
568
571
|
}
|
|
569
572
|
catch {
|
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,194 @@ 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
|
+
// WHATWG host parser: a single trailing dot (FQDN form) is stripped before
|
|
470
|
+
// parsing, so "127.0.0.1." must be treated exactly like "127.0.0.1".
|
|
471
|
+
const bare = host.endsWith(".") ? host.slice(0, -1) : host;
|
|
472
|
+
if (bare === "")
|
|
473
|
+
return null;
|
|
474
|
+
const parts = bare.split(".");
|
|
475
|
+
if (parts.length > 4 || parts.length < 1)
|
|
476
|
+
return null;
|
|
477
|
+
if (parts.length === 1) {
|
|
478
|
+
const v = parseIPv4Component(parts[0]);
|
|
479
|
+
if (v === null || v > 0xffffffff)
|
|
480
|
+
return null;
|
|
481
|
+
return v >>> 0;
|
|
482
|
+
}
|
|
483
|
+
// WHATWG: all but the last part are single octets; the last part may absorb
|
|
484
|
+
// the remainder of the address (value < 256^(5 - numberOfParts)) and is
|
|
485
|
+
// expanded big-endian across the remaining octets. E.g. "127.1" → 127.0.0.1
|
|
486
|
+
// and "169.254.43253" → 169.254.168.245 (still link-local → blocked).
|
|
487
|
+
const octets = [];
|
|
488
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
489
|
+
const v = parseIPv4Component(parts[i]);
|
|
490
|
+
if (v === null || v > 0xff)
|
|
491
|
+
return null;
|
|
492
|
+
octets.push(v);
|
|
493
|
+
}
|
|
494
|
+
const last = parseIPv4Component(parts[parts.length - 1]);
|
|
495
|
+
const lastCap = 2 ** (8 * (5 - parts.length)); // 256^(5-n)
|
|
496
|
+
if (last === null || last >= lastCap)
|
|
497
|
+
return null;
|
|
498
|
+
for (let shift = 8 * (4 - parts.length); shift >= 0; shift -= 8) {
|
|
499
|
+
octets.push((last >>> shift) & 0xff);
|
|
500
|
+
}
|
|
501
|
+
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Expand an IPv6 address (bracket-stripped) into its 16 bytes.
|
|
505
|
+
* Handles :: compression, IPv4-mapped dotted tails, and zone indexes.
|
|
506
|
+
* Returns null when the address is not a valid IPv6 literal.
|
|
507
|
+
*/
|
|
508
|
+
function parseIPv6Host(host) {
|
|
509
|
+
if (!host.includes(":"))
|
|
510
|
+
return null;
|
|
511
|
+
const bare = host.split("%")[0]; // strip zone index (fe80::1%eth0)
|
|
512
|
+
const halves = bare.split("::");
|
|
513
|
+
if (halves.length > 2)
|
|
514
|
+
return null;
|
|
515
|
+
const expandGroups = (part) => {
|
|
516
|
+
if (part === "")
|
|
517
|
+
return [];
|
|
518
|
+
const groups = part.split(":");
|
|
519
|
+
const out = [];
|
|
520
|
+
for (let i = 0; i < groups.length; i++) {
|
|
521
|
+
const g = groups[i];
|
|
522
|
+
if (g === "")
|
|
523
|
+
return null;
|
|
524
|
+
if (g.includes(".")) {
|
|
525
|
+
// IPv4 dotted tail (e.g. ::ffff:127.0.0.1) must be the last group
|
|
526
|
+
if (i !== groups.length - 1)
|
|
527
|
+
return null;
|
|
528
|
+
const n = parseIPv4Host(g);
|
|
529
|
+
if (n === null)
|
|
530
|
+
return null;
|
|
531
|
+
out.push((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(g))
|
|
535
|
+
return null;
|
|
536
|
+
const v = parseInt(g, 16);
|
|
537
|
+
out.push((v >>> 8) & 0xff, v & 0xff);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
};
|
|
542
|
+
let bytes;
|
|
543
|
+
if (halves.length === 2) {
|
|
544
|
+
const left = expandGroups(halves[0]);
|
|
545
|
+
const right = expandGroups(halves[1]);
|
|
546
|
+
if (!left || !right)
|
|
547
|
+
return null;
|
|
548
|
+
const fill = 16 - left.length - right.length;
|
|
549
|
+
if (fill < 0)
|
|
550
|
+
return null;
|
|
551
|
+
bytes = [...left, ...new Array(fill).fill(0), ...right];
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
const all = expandGroups(bare);
|
|
555
|
+
if (!all || all.length !== 16)
|
|
556
|
+
return null;
|
|
557
|
+
bytes = all;
|
|
558
|
+
}
|
|
559
|
+
return bytes.length === 16 ? new Uint8Array(bytes) : null;
|
|
560
|
+
}
|
|
561
|
+
/** True when the 16-byte IPv6 address is in a reserved/blocked range. */
|
|
562
|
+
function isBlockedIPv6(b) {
|
|
563
|
+
const read32 = (i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
564
|
+
// :: (unspecified) — always blocked
|
|
565
|
+
if (b.every((x) => x === 0))
|
|
566
|
+
return true;
|
|
567
|
+
// ::1/128 — loopback
|
|
568
|
+
if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1)
|
|
569
|
+
return true;
|
|
570
|
+
const first = b[0];
|
|
571
|
+
// fc00::/7 — unique local addresses (fc00::/8 + fd00::/8)
|
|
572
|
+
if ((first & 0xfe) === 0xfc)
|
|
573
|
+
return true;
|
|
574
|
+
// fe80::/10 — link-local
|
|
575
|
+
if (first === 0xfe && (b[1] & 0xc0) === 0x80)
|
|
576
|
+
return true;
|
|
577
|
+
// ff00::/8 — multicast
|
|
578
|
+
if (first === 0xff)
|
|
579
|
+
return true;
|
|
580
|
+
// 2001:db8::/32 — documentation
|
|
581
|
+
if (b[0] === 0x20 && b[1] === 0x01 && b[2] === 0x0d && b[3] === 0xb8)
|
|
582
|
+
return true;
|
|
583
|
+
// ::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) {
|
|
587
|
+
return isBlockedIPv4(read32(12));
|
|
588
|
+
}
|
|
589
|
+
// ::/96 — deprecated IPv4-compatible → apply the IPv4 checks to the tail
|
|
590
|
+
// (covers e.g. [::127.0.0.1] and [::169.254.169.254])
|
|
591
|
+
if (b.slice(0, 12).every((x) => x === 0)) {
|
|
592
|
+
return isBlockedIPv4(read32(12));
|
|
593
|
+
}
|
|
594
|
+
// 64:ff9b::/96 — NAT64 (RFC 6052): traffic is translated to the embedded IPv4
|
|
595
|
+
if (b[0] === 0x00 &&
|
|
596
|
+
b[1] === 0x64 &&
|
|
597
|
+
b[2] === 0xff &&
|
|
598
|
+
b[3] === 0x9b &&
|
|
599
|
+
b.slice(4, 12).every((x) => x === 0)) {
|
|
600
|
+
return isBlockedIPv4(read32(12));
|
|
601
|
+
}
|
|
602
|
+
// 2002::/16 — 6to4: the embedded IPv4 sits in bits 16–47
|
|
603
|
+
if (b[0] === 0x20 && b[1] === 0x02) {
|
|
604
|
+
return isBlockedIPv4(read32(2));
|
|
605
|
+
}
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
419
608
|
/**
|
|
420
609
|
* Validate a URL for safety.
|
|
421
610
|
* @param url - URL to validate
|
|
@@ -435,18 +624,25 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
|
435
624
|
return false;
|
|
436
625
|
}
|
|
437
626
|
let host = parsed.hostname.toLowerCase();
|
|
438
|
-
|
|
439
|
-
if (
|
|
627
|
+
const isV6Literal = host.startsWith("[") && host.endsWith("]");
|
|
628
|
+
if (isV6Literal)
|
|
440
629
|
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)))
|
|
630
|
+
// Block loopback hostnames (including *.localhost subdomains)
|
|
631
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
|
|
449
632
|
return false;
|
|
633
|
+
// Block private/reserved IPs (SSRF prevention) — numeric comparison over
|
|
634
|
+
// expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
|
|
635
|
+
if (isV6Literal || host.includes(":")) {
|
|
636
|
+
const v6 = parseIPv6Host(host);
|
|
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))
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
450
646
|
// Check for suspicious patterns (path traversal)
|
|
451
647
|
if (parsed.hostname.includes("..") || parsed.pathname.includes("..")) {
|
|
452
648
|
return false;
|
|
@@ -518,6 +714,9 @@ export function deepClone(value) {
|
|
|
518
714
|
}
|
|
519
715
|
const cloned = {};
|
|
520
716
|
for (const key in value) {
|
|
717
|
+
// Prototype-pollution guard: never copy __proto__ / constructor / prototype
|
|
718
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype")
|
|
719
|
+
continue;
|
|
521
720
|
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
522
721
|
cloned[key] = deepClone(value[key]);
|
|
523
722
|
}
|
|
@@ -777,31 +976,25 @@ export function isAbortError(error) {
|
|
|
777
976
|
* Uses `crypto.getRandomValues()` which is available in all target runtimes
|
|
778
977
|
* (Node 18+, Deno, Bun, Browser, Cloudflare Workers, Vercel Edge).
|
|
779
978
|
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
979
|
+
* FIX (M8): removed the `Math.random()` fallback — non-CSPRNG output must
|
|
980
|
+
* never be used for nonces/cnonces (digest auth) or trace IDs. Environments
|
|
981
|
+
* without a CSPRNG now fail fast instead of silently producing predictable
|
|
982
|
+
* values.
|
|
782
983
|
*
|
|
783
984
|
* @param byteCount - Number of random bytes (output hex length = byteCount * 2)
|
|
784
985
|
* @returns Hex-encoded random string
|
|
986
|
+
* @throws {Error} When no CSPRNG is available in the current runtime
|
|
785
987
|
*/
|
|
786
988
|
export function randomBytes(byteCount) {
|
|
989
|
+
if (!Number.isInteger(byteCount) || byteCount < 0 || byteCount > 65536) {
|
|
990
|
+
throw new Error(`randomBytes: invalid byteCount ${byteCount}`);
|
|
991
|
+
}
|
|
787
992
|
const arr = new Uint8Array(byteCount);
|
|
788
993
|
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
789
994
|
crypto.getRandomValues(arr);
|
|
790
995
|
}
|
|
791
996
|
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
|
-
}
|
|
997
|
+
throw new Error("randomBytes: no CSPRNG available in this runtime — crypto.getRandomValues is required");
|
|
805
998
|
}
|
|
806
999
|
return Array.from(arr)
|
|
807
1000
|
.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
|
// ============================================================================
|
|
@@ -144,6 +147,10 @@ export class WSClient {
|
|
|
144
147
|
_openWaiters = [];
|
|
145
148
|
_correlations = new Map();
|
|
146
149
|
_aborted = false;
|
|
150
|
+
/** Optional external abort signal from config (listener detached on close). */
|
|
151
|
+
_externalSignal = null;
|
|
152
|
+
/** Bound abort handler for the optional external `cfg.signal` (removed on close). */
|
|
153
|
+
_onExternalAbort = () => this.close(1000, "AbortSignal aborted");
|
|
147
154
|
// ── Async iterator ──────────────────────────────────────────────────────
|
|
148
155
|
_iterQueue = [];
|
|
149
156
|
_iterWaiter = null;
|
|
@@ -212,7 +219,8 @@ export class WSClient {
|
|
|
212
219
|
this._rooms.push({ room: r, joinedAt: 0 });
|
|
213
220
|
}
|
|
214
221
|
}
|
|
215
|
-
|
|
222
|
+
this._externalSignal = cfg.signal ?? null;
|
|
223
|
+
this._externalSignal?.addEventListener("abort", this._onExternalAbort, {
|
|
216
224
|
once: true,
|
|
217
225
|
});
|
|
218
226
|
}
|
|
@@ -295,6 +303,10 @@ export class WSClient {
|
|
|
295
303
|
*/
|
|
296
304
|
close(code = 1000, reason = "Client closed") {
|
|
297
305
|
this._aborted = true;
|
|
306
|
+
// Detach the external abort listener so a long-lived caller signal does
|
|
307
|
+
// not retain every closed client (leak fix).
|
|
308
|
+
this._externalSignal?.removeEventListener("abort", this._onExternalAbort);
|
|
309
|
+
this._externalSignal = null;
|
|
298
310
|
this._stopAll();
|
|
299
311
|
for (const w of this._drainWaiters.splice(0))
|
|
300
312
|
w();
|
|
@@ -760,7 +772,9 @@ export class WSClient {
|
|
|
760
772
|
data = evt.data;
|
|
761
773
|
bytes = new TextEncoder().encode(data).byteLength;
|
|
762
774
|
try {
|
|
763
|
-
|
|
775
|
+
// FIX (H6): remote WS payloads are untrusted — strip
|
|
776
|
+
// prototype-pollution keys before dispatching to listeners.
|
|
777
|
+
json = sanitizeParsedJSON(JSON.parse(data));
|
|
764
778
|
}
|
|
765
779
|
catch {
|
|
766
780
|
/* not JSON */
|