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/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
|
@@ -248,6 +248,8 @@ export class NodeHTTP2Transport {
|
|
|
248
248
|
sessionUsage = new Map();
|
|
249
249
|
_strict;
|
|
250
250
|
_onDroppedHeader;
|
|
251
|
+
/** Optional CA bundle for origins with self-signed / private-PKI certificates. */
|
|
252
|
+
_ca;
|
|
251
253
|
/** FIX 11: Configurable connect timeout (replaces hardcoded 30 000 ms) */
|
|
252
254
|
_connectTimeoutMs;
|
|
253
255
|
/** FIX 11: Configurable per-request stream timeout (replaces hardcoded 30 000 ms) */
|
|
@@ -263,6 +265,7 @@ export class NodeHTTP2Transport {
|
|
|
263
265
|
this.maxSessions = options.maxSessions ?? 100;
|
|
264
266
|
this._strict = options.strict ?? false;
|
|
265
267
|
this._onDroppedHeader = options.onDroppedHeader;
|
|
268
|
+
this._ca = options.ca;
|
|
266
269
|
this._connectTimeoutMs = options.connectTimeoutMs ?? 30_000;
|
|
267
270
|
this._requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
268
271
|
}
|
|
@@ -323,7 +326,10 @@ export class NodeHTTP2Transport {
|
|
|
323
326
|
}
|
|
324
327
|
}
|
|
325
328
|
const session = await new Promise((resolve, reject) => {
|
|
326
|
-
const s = http2.connect(origin, {
|
|
329
|
+
const s = http2.connect(origin, {
|
|
330
|
+
rejectUnauthorized: true,
|
|
331
|
+
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
332
|
+
});
|
|
327
333
|
// FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
|
|
328
334
|
// FIX 9: unref() the timer so it does not prevent process exit
|
|
329
335
|
const connectTimeout = setTimeout(() => {
|
|
@@ -451,29 +457,38 @@ export class NodeHTTP2Transport {
|
|
|
451
457
|
":authority": currentUrl.host,
|
|
452
458
|
...currentReq.headers,
|
|
453
459
|
};
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
460
|
+
// Header validation (HTTP/2 control-character check). Runs in BOTH modes:
|
|
461
|
+
// strict throws, non-strict drops with callback/warn — matching the
|
|
462
|
+
// FetchTransport contract. (Previously the whole loop was gated on
|
|
463
|
+
// strict mode, so non-strict requests never validated and a header with
|
|
464
|
+
// forbidden control characters crashed session.request() with a raw
|
|
465
|
+
// ERR_INVALID_HEADER_VALUE instead of being dropped.)
|
|
466
|
+
for (const [hName, hValue] of Object.entries(h2ReqHeaders)) {
|
|
467
|
+
if (hName.startsWith(":"))
|
|
468
|
+
continue;
|
|
469
|
+
const hStr = Array.isArray(hValue) ? hValue.join(", ") : String(hValue);
|
|
470
|
+
let hasForbidden = false;
|
|
471
|
+
for (let ci = 0; ci < hStr.length; ci++) {
|
|
472
|
+
const code = hStr.charCodeAt(ci);
|
|
473
|
+
if ((code >= 0x00 && code <= 0x08) || (code >= 0x0a && code <= 0x1f) || code === 0x7f) {
|
|
474
|
+
hasForbidden = true;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (hasForbidden) {
|
|
479
|
+
if (this._strict) {
|
|
480
|
+
throw new KinetexError(`Strict mode: header "${hName}" contains forbidden control characters`, "EVALIDATION", { request: currentReq });
|
|
467
481
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
else {
|
|
474
|
-
throw new KinetexError(`Strict mode: header "${hName}" contains forbidden control characters`, "EVALIDATION", { request: currentReq });
|
|
475
|
-
}
|
|
482
|
+
// FIX (H3): non-strict mode must match FetchTransport behavior —
|
|
483
|
+
// notify the callback (if any) and warn, never drop silently.
|
|
484
|
+
if (this._onDroppedHeader) {
|
|
485
|
+
this._onDroppedHeader(hName, hStr);
|
|
476
486
|
}
|
|
487
|
+
else if (typeof console !== "undefined") {
|
|
488
|
+
console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
|
|
489
|
+
`Pass strictHeaders: true to throw instead.`);
|
|
490
|
+
}
|
|
491
|
+
delete h2ReqHeaders[hName];
|
|
477
492
|
}
|
|
478
493
|
}
|
|
479
494
|
const endStream = !currentReq.body || currentReq.method === "GET" || currentReq.method === "HEAD";
|
|
@@ -713,9 +728,14 @@ export class NodeHTTP2Transport {
|
|
|
713
728
|
* @param transportOptions - Header validation options (strict mode, dropped-header callback).
|
|
714
729
|
*/
|
|
715
730
|
export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, transportOptions) {
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
731
|
+
// FIX (M7-class silent no-op): a custom `fetch` config was silently ignored on
|
|
732
|
+
// Node.js when HTTP/2 was preferred (the default) — NodeHTTP2Transport has no
|
|
733
|
+
// fetchFn input, so the caller's fetch was never used. When a custom fetch is
|
|
734
|
+
// supplied, always route through FetchTransport so the documented
|
|
735
|
+
// "Custom fetch implementation" behavior holds on every runtime.
|
|
736
|
+
// Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
|
|
737
|
+
// fetch is given. Falls back to FetchTransport otherwise.
|
|
738
|
+
if (IS_NODE && preferHTTP2 && !fetchFn) {
|
|
719
739
|
return new NodeHTTP2Transport({
|
|
720
740
|
...(sessionOptions?.sessionTTLMs !== undefined
|
|
721
741
|
? { sessionTTLMs: sessionOptions.sessionTTLMs }
|
|
@@ -767,8 +787,14 @@ export async function sendWithTimeout(transport, request, timeoutMs) {
|
|
|
767
787
|
// Merge signals
|
|
768
788
|
const signal = mergeSignals(request.signal, controller.signal) ?? null;
|
|
769
789
|
const req = { ...request, signal };
|
|
790
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
791
|
+
controller.signal.addEventListener("abort", () => reject(new TimeoutError(timeoutMs, request)), { once: true });
|
|
792
|
+
});
|
|
770
793
|
try {
|
|
771
|
-
|
|
794
|
+
// Race the transport against the timer. The race is the actual deadline
|
|
795
|
+
// — a transport that never resolves (or ignores the abort signal) can no
|
|
796
|
+
// longer hang the caller past timeoutMs.
|
|
797
|
+
const result = await Promise.race([transport.send(req), timeoutPromise]);
|
|
772
798
|
clearTimeout(timer);
|
|
773
799
|
// Safety net: the transport may have resolved despite the abort signal
|
|
774
800
|
// (e.g. Node.js HTTP/2 'close' fires before 'error' in some versions).
|
|
@@ -780,7 +806,7 @@ export async function sendWithTimeout(transport, request, timeoutMs) {
|
|
|
780
806
|
}
|
|
781
807
|
catch (err) {
|
|
782
808
|
clearTimeout(timer);
|
|
783
|
-
if (controller.signal.aborted) {
|
|
809
|
+
if (controller.signal.aborted && !(err instanceof TimeoutError)) {
|
|
784
810
|
throw new TimeoutError(timeoutMs, request);
|
|
785
811
|
}
|
|
786
812
|
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);
|
|
@@ -370,8 +383,10 @@ async function executeHTTP(req, config, signal, apqMode = "none", getAPQHashFn)
|
|
|
370
383
|
fetchHeaders["content-type"] = "application/json";
|
|
371
384
|
const controller = new AbortController();
|
|
372
385
|
const timer = config.timeoutMs > 0 ? setTimeout(() => controller.abort(), config.timeoutMs) : null;
|
|
373
|
-
// Merge external signal
|
|
374
|
-
|
|
386
|
+
// Merge external signal. The listener is removed once the fetch settles so
|
|
387
|
+
// it does not accumulate on long-lived caller-provided signals (leak fix).
|
|
388
|
+
const onExternalAbort = () => controller.abort();
|
|
389
|
+
signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
375
390
|
let response;
|
|
376
391
|
try {
|
|
377
392
|
response = await config.fetch(fetchUrl, {
|
|
@@ -392,6 +407,7 @@ async function executeHTTP(req, config, signal, apqMode = "none", getAPQHashFn)
|
|
|
392
407
|
finally {
|
|
393
408
|
if (timer)
|
|
394
409
|
clearTimeout(timer);
|
|
410
|
+
signal?.removeEventListener("abort", onExternalAbort);
|
|
395
411
|
}
|
|
396
412
|
return parseGraphQLResponse(response, req);
|
|
397
413
|
}
|
|
@@ -621,7 +637,10 @@ export class GraphQLClient {
|
|
|
621
637
|
delete headers["content-type"]; // FormData sets it with boundary
|
|
622
638
|
const form = buildMultipartBody(req, uploads);
|
|
623
639
|
const controller = new AbortController();
|
|
624
|
-
|
|
640
|
+
// Listener removed when the fetch settles — avoids accumulating on the
|
|
641
|
+
// caller's signal across many uploads (leak fix).
|
|
642
|
+
const onExternalAbort = () => controller.abort();
|
|
643
|
+
options.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
625
644
|
this.config.onRequest(req);
|
|
626
645
|
let response;
|
|
627
646
|
try {
|
|
@@ -637,6 +656,9 @@ export class GraphQLClient {
|
|
|
637
656
|
this.config.onError(clientErr, req);
|
|
638
657
|
throw clientErr;
|
|
639
658
|
}
|
|
659
|
+
finally {
|
|
660
|
+
options.signal?.removeEventListener("abort", onExternalAbort);
|
|
661
|
+
}
|
|
640
662
|
const gqlRes = await parseGraphQLResponse(response, req);
|
|
641
663
|
this.config.onResponse(gqlRes, req);
|
|
642
664
|
if (gqlRes.errors?.length) {
|
|
@@ -666,7 +688,10 @@ export class GraphQLClient {
|
|
|
666
688
|
async batch(requests, options = {}) {
|
|
667
689
|
const headers = await buildHeaders(this.config);
|
|
668
690
|
const controller = new AbortController();
|
|
669
|
-
|
|
691
|
+
// Listener removed when the fetch settles — avoids accumulating on the
|
|
692
|
+
// caller's signal across many batches (leak fix).
|
|
693
|
+
const onExternalAbort = () => controller.abort();
|
|
694
|
+
options.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
670
695
|
let response;
|
|
671
696
|
try {
|
|
672
697
|
response = await this.config.fetch(this.config.url, {
|
|
@@ -679,7 +704,11 @@ export class GraphQLClient {
|
|
|
679
704
|
catch (err) {
|
|
680
705
|
throw new GraphQLClientError(err instanceof Error ? err.message : "Batch network error", "ENETWORK", undefined, requests[0], undefined, err);
|
|
681
706
|
}
|
|
682
|
-
|
|
707
|
+
finally {
|
|
708
|
+
options.signal?.removeEventListener("abort", onExternalAbort);
|
|
709
|
+
}
|
|
710
|
+
// FIX (H6): sanitize untrusted batch response JSON before validation.
|
|
711
|
+
const rawResults = sanitizeParsedJSON(await response.json());
|
|
683
712
|
// Validate response is array (B-5 fix)
|
|
684
713
|
if (!Array.isArray(rawResults)) {
|
|
685
714
|
throw new GraphQLClientError("Batch response must be an array of GraphQL responses", "EINVALIDRESPONSE", undefined, requests[0], rawResults);
|
|
@@ -771,7 +800,7 @@ export class GraphQLClient {
|
|
|
771
800
|
continue;
|
|
772
801
|
let gqlRes;
|
|
773
802
|
try {
|
|
774
|
-
gqlRes = JSON.parse(event.data);
|
|
803
|
+
gqlRes = sanitizeParsedJSON(JSON.parse(event.data));
|
|
775
804
|
}
|
|
776
805
|
catch {
|
|
777
806
|
continue;
|
package/dist/cjs/interceptors.js
CHANGED
|
@@ -513,33 +513,43 @@ export function createTimeoutInterceptor(config = {}) {
|
|
|
513
513
|
ctx.aborted = true;
|
|
514
514
|
controller.abort(new TimeoutError(cfg.message, ms));
|
|
515
515
|
}, ms);
|
|
516
|
-
// Merge with any existing signal
|
|
516
|
+
// Merge with any existing signal. The listener is removed in the
|
|
517
|
+
// response/error cleanup below so a shared external signal does not
|
|
518
|
+
// accumulate one closure per request (leak fix).
|
|
517
519
|
const existing = ctx.request.signal;
|
|
518
520
|
if (existing) {
|
|
519
|
-
|
|
521
|
+
const onExistingAbort = () => {
|
|
520
522
|
clearTimeout(timer);
|
|
521
523
|
controller.abort(existing.reason);
|
|
522
|
-
}
|
|
524
|
+
};
|
|
525
|
+
existing.addEventListener("abort", onExistingAbort, { once: true });
|
|
526
|
+
ctx.store.set(TIMEOUT_SIGNAL_KEY, { existing, onExistingAbort });
|
|
523
527
|
}
|
|
524
528
|
ctx.request = { ...ctx.request, signal: controller.signal };
|
|
525
529
|
// Store cleanup ref in context
|
|
526
530
|
ctx.store.set(TIMEOUT_TIMER_KEY, timer);
|
|
527
531
|
};
|
|
528
|
-
|
|
529
|
-
const
|
|
532
|
+
/** Shared cleanup for the response and error phases. */
|
|
533
|
+
const cleanupTimeout = (ctx) => {
|
|
530
534
|
const timer = ctx.store.get(TIMEOUT_TIMER_KEY);
|
|
531
535
|
if (timer) {
|
|
532
536
|
clearTimeout(timer);
|
|
533
537
|
ctx.store.delete(TIMEOUT_TIMER_KEY);
|
|
534
538
|
}
|
|
539
|
+
const merged = ctx.store.get(TIMEOUT_SIGNAL_KEY);
|
|
540
|
+
if (merged) {
|
|
541
|
+
merged.existing.removeEventListener("abort", merged.onExistingAbort);
|
|
542
|
+
ctx.store.delete(TIMEOUT_SIGNAL_KEY);
|
|
543
|
+
}
|
|
535
544
|
};
|
|
536
|
-
//
|
|
545
|
+
// Response interceptor cleans up the timer and merged-signal listener after
|
|
546
|
+
// the request completes
|
|
547
|
+
const responseInterceptor = (ctx) => {
|
|
548
|
+
cleanupTimeout(ctx);
|
|
549
|
+
};
|
|
550
|
+
// Error interceptor also cleans up the timer and merged-signal listener
|
|
537
551
|
const errorInterceptor = (ctx) => {
|
|
538
|
-
|
|
539
|
-
if (timer) {
|
|
540
|
-
clearTimeout(timer);
|
|
541
|
-
ctx.store.delete(TIMEOUT_TIMER_KEY);
|
|
542
|
-
}
|
|
552
|
+
cleanupTimeout(ctx);
|
|
543
553
|
};
|
|
544
554
|
return {
|
|
545
555
|
id: "timeout",
|
|
@@ -549,6 +559,7 @@ export function createTimeoutInterceptor(config = {}) {
|
|
|
549
559
|
};
|
|
550
560
|
}
|
|
551
561
|
const TIMEOUT_TIMER_KEY = Symbol("timeoutTimer");
|
|
562
|
+
const TIMEOUT_SIGNAL_KEY = Symbol("timeoutSignal");
|
|
552
563
|
/** Error thrown when a request exceeds the configured timeout. */
|
|
553
564
|
export class TimeoutError extends Error {
|
|
554
565
|
timeoutMs;
|
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
|
}
|