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/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,
|
package/dist/cjs/cookie-store.js
CHANGED
|
@@ -311,6 +311,16 @@ export class CookieJar {
|
|
|
311
311
|
// §5.4 step 2e: SameSite
|
|
312
312
|
if (!sameSiteAllows(cookie.sameSite, context))
|
|
313
313
|
continue;
|
|
314
|
+
// FIX (H5): defense-in-depth — re-validate __Secure-/__Host- prefix
|
|
315
|
+
// rules at retrieval time. Set-time enforcement (enforcePrefixRules)
|
|
316
|
+
// is the RFC rule, but storage can be rehydrated via loadCookieJar
|
|
317
|
+
// from external JSON, so never emit a prefix cookie whose Secure/
|
|
318
|
+
// Path/hostOnly flags no longer satisfy the contract.
|
|
319
|
+
if (cookie.name.startsWith("__Secure-") && !(cookie.secure && isSecure))
|
|
320
|
+
continue;
|
|
321
|
+
if (cookie.name.startsWith("__Host-") &&
|
|
322
|
+
!(cookie.secure && isSecure && cookie.path === "/" && cookie.hostOnly))
|
|
323
|
+
continue;
|
|
314
324
|
result.push(cookie);
|
|
315
325
|
}
|
|
316
326
|
}
|
package/dist/cjs/core.js
CHANGED
|
@@ -466,13 +466,19 @@ export class NodeHTTP2Transport {
|
|
|
466
466
|
}
|
|
467
467
|
}
|
|
468
468
|
if (hasForbidden) {
|
|
469
|
+
if (this._strict) {
|
|
470
|
+
throw new KinetexError(`Strict mode: header "${hName}" contains forbidden control characters`, "EVALIDATION", { request: currentReq });
|
|
471
|
+
}
|
|
472
|
+
// FIX (H3): non-strict mode must match FetchTransport behavior —
|
|
473
|
+
// notify the callback (if any) and warn, never drop silently.
|
|
469
474
|
if (this._onDroppedHeader) {
|
|
470
475
|
this._onDroppedHeader(hName, hStr);
|
|
471
|
-
delete h2ReqHeaders[hName];
|
|
472
476
|
}
|
|
473
|
-
else {
|
|
474
|
-
|
|
477
|
+
else if (typeof console !== "undefined") {
|
|
478
|
+
console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
|
|
479
|
+
`Pass strictHeaders: true to throw instead.`);
|
|
475
480
|
}
|
|
481
|
+
delete h2ReqHeaders[hName];
|
|
476
482
|
}
|
|
477
483
|
}
|
|
478
484
|
}
|
|
@@ -713,9 +719,14 @@ export class NodeHTTP2Transport {
|
|
|
713
719
|
* @param transportOptions - Header validation options (strict mode, dropped-header callback).
|
|
714
720
|
*/
|
|
715
721
|
export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, transportOptions) {
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
722
|
+
// FIX (M7-class silent no-op): a custom `fetch` config was silently ignored on
|
|
723
|
+
// Node.js when HTTP/2 was preferred (the default) — NodeHTTP2Transport has no
|
|
724
|
+
// fetchFn input, so the caller's fetch was never used. When a custom fetch is
|
|
725
|
+
// supplied, always route through FetchTransport so the documented
|
|
726
|
+
// "Custom fetch implementation" behavior holds on every runtime.
|
|
727
|
+
// Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
|
|
728
|
+
// fetch is given. Falls back to FetchTransport otherwise.
|
|
729
|
+
if (IS_NODE && preferHTTP2 && !fetchFn) {
|
|
719
730
|
return new NodeHTTP2Transport({
|
|
720
731
|
...(sessionOptions?.sessionTTLMs !== undefined
|
|
721
732
|
? { sessionTTLMs: sessionOptions.sessionTTLMs }
|
|
@@ -767,8 +778,14 @@ export async function sendWithTimeout(transport, request, timeoutMs) {
|
|
|
767
778
|
// Merge signals
|
|
768
779
|
const signal = mergeSignals(request.signal, controller.signal) ?? null;
|
|
769
780
|
const req = { ...request, signal };
|
|
781
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
782
|
+
controller.signal.addEventListener("abort", () => reject(new TimeoutError(timeoutMs, request)), { once: true });
|
|
783
|
+
});
|
|
770
784
|
try {
|
|
771
|
-
|
|
785
|
+
// Race the transport against the timer. The race is the actual deadline
|
|
786
|
+
// — a transport that never resolves (or ignores the abort signal) can no
|
|
787
|
+
// longer hang the caller past timeoutMs.
|
|
788
|
+
const result = await Promise.race([transport.send(req), timeoutPromise]);
|
|
772
789
|
clearTimeout(timer);
|
|
773
790
|
// Safety net: the transport may have resolved despite the abort signal
|
|
774
791
|
// (e.g. Node.js HTTP/2 'close' fires before 'error' in some versions).
|
|
@@ -780,7 +797,7 @@ export async function sendWithTimeout(transport, request, timeoutMs) {
|
|
|
780
797
|
}
|
|
781
798
|
catch (err) {
|
|
782
799
|
clearTimeout(timer);
|
|
783
|
-
if (controller.signal.aborted) {
|
|
800
|
+
if (controller.signal.aborted && !(err instanceof TimeoutError)) {
|
|
784
801
|
throw new TimeoutError(timeoutMs, request);
|
|
785
802
|
}
|
|
786
803
|
throw err;
|
package/dist/cjs/graphql.js
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
* - Retry on network error
|
|
28
28
|
*/
|
|
29
29
|
import { ValidationError } from "./types.js";
|
|
30
|
+
// FIX (H6): untrusted GraphQL responses are parsed with raw JSON.parse below —
|
|
31
|
+
// sanitize prototype-pollution keys before they reach user code or merges.
|
|
32
|
+
import { sanitizeParsedJSON } from "./utils.js";
|
|
30
33
|
// ============================================================================
|
|
31
34
|
// §2 ERRORS
|
|
32
35
|
// ============================================================================
|
|
@@ -307,11 +310,20 @@ function setNestedValue(obj, path, value) {
|
|
|
307
310
|
let current = obj;
|
|
308
311
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
309
312
|
const key = parts[i];
|
|
313
|
+
// FIX (H6): reject prototype-pollution path segments — a GraphQL upload
|
|
314
|
+
// path like "__proto__.polluted" must never write through the prototype.
|
|
315
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
316
|
+
throw new ValidationError(`Invalid upload path: "${path}" — reserved key "${key}"`);
|
|
317
|
+
}
|
|
310
318
|
if (!current[key] || typeof current[key] !== "object")
|
|
311
319
|
current[key] = {};
|
|
312
320
|
current = current[key];
|
|
313
321
|
}
|
|
314
|
-
|
|
322
|
+
const leaf = parts[parts.length - 1];
|
|
323
|
+
if (leaf === "__proto__" || leaf === "constructor" || leaf === "prototype") {
|
|
324
|
+
throw new ValidationError(`Invalid upload path: "${path}" — reserved key "${leaf}"`);
|
|
325
|
+
}
|
|
326
|
+
current[leaf] = value;
|
|
315
327
|
}
|
|
316
328
|
// ============================================================================
|
|
317
329
|
// §8 RESPONSE HANDLING
|
|
@@ -324,7 +336,8 @@ async function parseGraphQLResponse(response, req) {
|
|
|
324
336
|
}
|
|
325
337
|
let json;
|
|
326
338
|
try {
|
|
327
|
-
|
|
339
|
+
// FIX (H6): sanitize untrusted response JSON before it reaches user code.
|
|
340
|
+
json = sanitizeParsedJSON((await response.json()));
|
|
328
341
|
}
|
|
329
342
|
catch (err) {
|
|
330
343
|
throw new GraphQLClientError("Failed to parse GraphQL response as JSON", "EPARSE", undefined, req, undefined, err);
|
|
@@ -679,7 +692,8 @@ export class GraphQLClient {
|
|
|
679
692
|
catch (err) {
|
|
680
693
|
throw new GraphQLClientError(err instanceof Error ? err.message : "Batch network error", "ENETWORK", undefined, requests[0], undefined, err);
|
|
681
694
|
}
|
|
682
|
-
|
|
695
|
+
// FIX (H6): sanitize untrusted batch response JSON before validation.
|
|
696
|
+
const rawResults = sanitizeParsedJSON(await response.json());
|
|
683
697
|
// Validate response is array (B-5 fix)
|
|
684
698
|
if (!Array.isArray(rawResults)) {
|
|
685
699
|
throw new GraphQLClientError("Batch response must be an array of GraphQL responses", "EINVALIDRESPONSE", undefined, requests[0], rawResults);
|
|
@@ -771,7 +785,7 @@ export class GraphQLClient {
|
|
|
771
785
|
continue;
|
|
772
786
|
let gqlRes;
|
|
773
787
|
try {
|
|
774
|
-
gqlRes = JSON.parse(event.data);
|
|
788
|
+
gqlRes = sanitizeParsedJSON(JSON.parse(event.data));
|
|
775
789
|
}
|
|
776
790
|
catch {
|
|
777
791
|
continue;
|
package/dist/cjs/logging.js
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* consider using external log rotation (e.g., logrotate) or shipping logs
|
|
26
26
|
* to a remote service (RemoteTransport) rather than writing to files.
|
|
27
27
|
*/
|
|
28
|
+
// FIX (H6): logged bodies are untrusted — parsed JSON is stripped of
|
|
29
|
+
// prototype-pollution keys before redaction writes (see sanitizeParsedJSON).
|
|
30
|
+
import { sanitizeParsedJSON } from "./utils.js";
|
|
28
31
|
// Cross-runtime: safely detect Node.js process.env
|
|
29
32
|
const getNodeEnv = () => {
|
|
30
33
|
try {
|
|
@@ -483,7 +486,9 @@ export class Redactor {
|
|
|
483
486
|
// Redact body fields (JSON)
|
|
484
487
|
if (ct.includes("application/json") && this.bodyFields.length > 0) {
|
|
485
488
|
try {
|
|
486
|
-
|
|
489
|
+
// FIX (H6): log bodies are untrusted — sanitize before mutation so a
|
|
490
|
+
// crafted payload cannot smuggle __proto__ paths into redaction writes.
|
|
491
|
+
const parsed = sanitizeParsedJSON(JSON.parse(str));
|
|
487
492
|
for (const path of this.bodyFields)
|
|
488
493
|
redactObjectPath(parsed, path.split("."));
|
|
489
494
|
str = JSON.stringify(parsed);
|
|
@@ -510,9 +515,12 @@ function redactObjectPath(obj, path) {
|
|
|
510
515
|
const [head, ...rest] = path;
|
|
511
516
|
if (head === undefined)
|
|
512
517
|
return;
|
|
518
|
+
// FIX (H6): never traverse or write through prototype-pollution keys.
|
|
519
|
+
if (head === "__proto__" || head === "constructor" || head === "prototype")
|
|
520
|
+
return;
|
|
513
521
|
const o = obj;
|
|
514
522
|
if (rest.length === 0) {
|
|
515
|
-
if (head
|
|
523
|
+
if (Object.prototype.hasOwnProperty.call(o, head))
|
|
516
524
|
o[head] = "***";
|
|
517
525
|
return;
|
|
518
526
|
}
|
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/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 {
|