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.
Files changed (61) hide show
  1. package/README.md +104 -16
  2. package/dist/browser/kinetex.esm.js +18 -18
  3. package/dist/browser/kinetex.js +581 -275
  4. package/dist/browser/kinetex.min.js +18 -18
  5. package/dist/cjs/client.js +180 -11
  6. package/dist/cjs/cookie-store.js +10 -0
  7. package/dist/cjs/core.js +53 -27
  8. package/dist/cjs/graphql.js +37 -8
  9. package/dist/cjs/interceptors.js +22 -11
  10. package/dist/cjs/logging.js +10 -2
  11. package/dist/cjs/mod.js +1 -1
  12. package/dist/cjs/pagination.js +6 -1
  13. package/dist/cjs/progress.js +13 -6
  14. package/dist/cjs/response.js +8 -6
  15. package/dist/cjs/sse.js +5 -2
  16. package/dist/cjs/utils.js +250 -57
  17. package/dist/cjs/ws.js +16 -2
  18. package/dist/esm/client.js +180 -11
  19. package/dist/esm/client.js.map +1 -1
  20. package/dist/esm/cookie-store.js +10 -0
  21. package/dist/esm/cookie-store.js.map +1 -1
  22. package/dist/esm/core.js +53 -27
  23. package/dist/esm/core.js.map +1 -1
  24. package/dist/esm/graphql.js +37 -8
  25. package/dist/esm/graphql.js.map +1 -1
  26. package/dist/esm/interceptors.js +22 -11
  27. package/dist/esm/interceptors.js.map +1 -1
  28. package/dist/esm/logging.js +10 -2
  29. package/dist/esm/logging.js.map +1 -1
  30. package/dist/esm/mod.js +1 -1
  31. package/dist/esm/mod.js.map +1 -1
  32. package/dist/esm/pagination.js +6 -1
  33. package/dist/esm/pagination.js.map +1 -1
  34. package/dist/esm/progress.js +13 -6
  35. package/dist/esm/progress.js.map +1 -1
  36. package/dist/esm/response.js +8 -6
  37. package/dist/esm/response.js.map +1 -1
  38. package/dist/esm/sse.js +5 -2
  39. package/dist/esm/sse.js.map +1 -1
  40. package/dist/esm/utils.js +250 -57
  41. package/dist/esm/utils.js.map +1 -1
  42. package/dist/esm/ws.js +16 -2
  43. package/dist/esm/ws.js.map +1 -1
  44. package/dist/types/client.d.ts.map +1 -1
  45. package/dist/types/cookie-store.d.ts.map +1 -1
  46. package/dist/types/core.d.ts +4 -0
  47. package/dist/types/core.d.ts.map +1 -1
  48. package/dist/types/graphql.d.ts.map +1 -1
  49. package/dist/types/interceptors.d.ts.map +1 -1
  50. package/dist/types/logging.d.ts.map +1 -1
  51. package/dist/types/mod.d.ts +1 -1
  52. package/dist/types/mod.d.ts.map +1 -1
  53. package/dist/types/pagination.d.ts.map +1 -1
  54. package/dist/types/progress.d.ts.map +1 -1
  55. package/dist/types/response.d.ts.map +1 -1
  56. package/dist/types/sse.d.ts.map +1 -1
  57. package/dist/types/utils.d.ts +18 -2
  58. package/dist/types/utils.d.ts.map +1 -1
  59. package/dist/types/ws.d.ts +4 -0
  60. package/dist/types/ws.d.ts.map +1 -1
  61. package/package.json +5 -5
@@ -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]) => ({ 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]) => ({ 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
- headers["authorization"] = `Bearer ${token}`;
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
- headers[auth.header.toLowerCase()] = key;
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
- // FormData size estimation is complex, skip for now
1328
- // In practice, browsers enforce their own limits
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).length;
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
- // Apply auth headers on every hop (they may have been lost during redirect)
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
- currentReq = await applyAuth(currentReq, appliedAuth);
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,