@revenexx/integrations-node-sdk 1.9.1 → 1.10.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/dist/fetch.d.ts CHANGED
@@ -37,11 +37,33 @@ export interface SafeFetchRetry {
37
37
  attempts: number;
38
38
  delayMs?: number;
39
39
  }
40
+ /**
41
+ * Binds a request to the one origin its credential belongs to (PO-688).
42
+ *
43
+ * Setting it declares the whole request secret-bearing — headers, query, path
44
+ * and body alike — so nothing has to name which part carries the secret: a
45
+ * request to any other origin is refused with `CREDENTIAL_ORIGIN`, on the first
46
+ * hop and on every redirect, before that origin is looked up or sent anything.
47
+ */
48
+ export interface SafeFetchCredential {
49
+ /** The only origin this request may reach, e.g. `https://api.pipedrive.com`. */
50
+ origin: string | URL;
51
+ /**
52
+ * What a redirect off that origin does. `'refuse'` (the default) fails the
53
+ * call. `'strip'` follows it the way an unbound request would — every header
54
+ * not known to be harmless dropped, a body refused — for a download that is
55
+ * redirected to a CDN or a signed storage link. A secret in the query or the
56
+ * path is not protected on such a hop.
57
+ */
58
+ crossOriginRedirect?: 'refuse' | 'strip';
59
+ }
40
60
  export interface SafeFetchOptions extends RequestInit {
41
61
  timeoutMs?: number;
42
62
  /** Pass `ctx.signal` so the workflow engine can cancel the request. */
43
63
  signal?: AbortSignal;
44
64
  retry?: SafeFetchRetry;
65
+ /** Bind the request to the origin of the credential it carries. */
66
+ credential?: SafeFetchCredential;
45
67
  }
46
68
  export declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promise<Response>;
47
69
  /**
package/dist/index.cjs CHANGED
@@ -542,6 +542,26 @@ function clampResponseBytes(maxBytes) {
542
542
  if (!Number.isFinite(maxBytes) || maxBytes < 1) return MAX_RESPONSE_BYTES;
543
543
  return Math.min(maxBytes, MAX_RESPONSE_BYTES);
544
544
  }
545
+ function boundOrigin(credential) {
546
+ let parsed;
547
+ try {
548
+ parsed = new URL(credential.origin);
549
+ } catch {
550
+ parsed = void 0;
551
+ }
552
+ if (!parsed || parsed.protocol !== "http:" && parsed.protocol !== "https:") {
553
+ throw new NodeError("CREDENTIAL_ORIGIN", "The origin a credential is bound to is not an http(s) address", { status: 0 });
554
+ }
555
+ return parsed.origin;
556
+ }
557
+ function credentialOriginRefusal(target, bound, hop) {
558
+ const what = hop === void 0 ? "a request" : `a ${hop} redirect`;
559
+ return new NodeError(
560
+ "CREDENTIAL_ORIGIN",
561
+ `Refused ${what} to ${target}: the credential it carries is bound to ${bound}`,
562
+ { status: 0 }
563
+ );
564
+ }
545
565
  async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
546
566
  const ac = new AbortController();
547
567
  const timer = setTimeout(() => ac.abort(), effectiveMs);
@@ -581,7 +601,16 @@ function hasContent(body) {
581
601
  if (body instanceof URLSearchParams) return body.toString().length > 0;
582
602
  return true;
583
603
  }
584
- async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
604
+ function refuseUserinfo(url, status = 0) {
605
+ if (url.username !== "" || url.password !== "") {
606
+ throw new NodeError(
607
+ "INVALID_URL",
608
+ status === 0 ? "Refused a URL with a user name or password in it: pass them in a credential, not in the address" : `Refused a ${status} redirect to a URL with a user name or password in it`,
609
+ { status: 0 }
610
+ );
611
+ }
612
+ }
613
+ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding) {
585
614
  let currentUrl = url;
586
615
  let currentInit = { ...fetchOptions, redirect: "manual" };
587
616
  await guardUrl(currentUrl, ctxSignal, effectiveMs);
@@ -610,6 +639,10 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
610
639
  } catch {
611
640
  throw new NodeError("BLOCKED_ADDRESS", "Blocked redirect with an invalid Location header", { status: 0 });
612
641
  }
642
+ refuseUserinfo(nextUrl, redirectStatus);
643
+ if (binding && binding.crossOriginRedirect === "refuse" && nextUrl.origin !== binding.origin) {
644
+ throw credentialOriginRefusal(nextUrl.origin, binding.origin, redirectStatus);
645
+ }
613
646
  if (base.protocol === "https:" && nextUrl.protocol !== "https:") {
614
647
  throw new NodeError("BLOCKED_ADDRESS", "Blocked https\u2192http downgrade on redirect", { status: 0 });
615
648
  }
@@ -630,7 +663,9 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
630
663
  );
631
664
  }
632
665
  await guardUrl(nextUrl, ctxSignal, effectiveMs);
633
- if (nextUrl.origin !== base.origin) {
666
+ if (binding && nextUrl.origin !== binding.origin) {
667
+ for (const name of [...headers.keys()]) headers.delete(name);
668
+ } else if (nextUrl.origin !== base.origin) {
634
669
  for (const name of [...headers.keys()]) {
635
670
  if (!headerCrossesOrigin(name)) headers.delete(name);
636
671
  }
@@ -640,7 +675,7 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
640
675
  }
641
676
  }
642
677
  async function safeFetch(url, options = {}) {
643
- const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, ...fetchOptions } = options;
678
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, credential, ...fetchOptions } = options;
644
679
  const effectiveMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
645
680
  const rawAttempts = retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
646
681
  const safeAttempts = Number.isFinite(rawAttempts) ? Math.min(Math.max(0, rawAttempts), MAX_RETRY_ATTEMPTS) : DEFAULT_RETRY_ATTEMPTS;
@@ -648,14 +683,26 @@ async function safeFetch(url, options = {}) {
648
683
  const retryDelayMs = retry?.delayMs ?? DEFAULT_RETRY_DELAY_MS;
649
684
  if (ctxSignal?.aborted) throw ctxSignal.reason;
650
685
  if (fetchOptions.headers !== void 0) fetchOptions.headers = sendableHeaders(fetchOptions.headers);
686
+ let parsed;
687
+ try {
688
+ parsed = url instanceof URL ? url : new URL(url);
689
+ } catch {
690
+ throw new NodeError("INVALID_URL", "Refused an address that cannot be read as a URL", { status: 0 });
691
+ }
692
+ refuseUserinfo(parsed);
693
+ let binding;
694
+ if (credential) {
695
+ binding = { origin: boundOrigin(credential), crossOriginRedirect: credential.crossOriginRedirect ?? "refuse" };
696
+ if (parsed.origin !== binding.origin) throw credentialOriginRefusal(parsed.origin, binding.origin);
697
+ }
651
698
  let lastError;
652
699
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
653
700
  if (ctxSignal?.aborted) throw ctxSignal.reason;
654
701
  try {
655
- return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs);
702
+ return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding);
656
703
  } catch (err) {
657
704
  if (ctxSignal?.aborted) throw ctxSignal.reason;
658
- if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS")) {
705
+ if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS" || err.code === "CREDENTIAL_ORIGIN" || err.code === "INVALID_URL")) {
659
706
  throw err;
660
707
  }
661
708
  lastError = err;
@@ -971,15 +1018,23 @@ var BaseCredential = class {
971
1018
  });
972
1019
  const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
973
1020
  if (!res.ok) {
974
- throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
1021
+ throw new Error(`token endpoint ${endpointForMessage(url)} -> ${res.status}${formatOAuthError(text)}`);
975
1022
  }
976
1023
  try {
977
1024
  return JSON.parse(text);
978
1025
  } catch {
979
- throw new Error(`token endpoint ${url} returned non-JSON body`);
1026
+ throw new Error(`token endpoint ${endpointForMessage(url)} returned non-JSON body`);
980
1027
  }
981
1028
  }
982
1029
  };
1030
+ function endpointForMessage(url) {
1031
+ try {
1032
+ const u = new URL(url);
1033
+ return `${u.origin}${u.pathname}`;
1034
+ } catch {
1035
+ return "(unreadable address)";
1036
+ }
1037
+ }
983
1038
  function formatOAuthError(body) {
984
1039
  try {
985
1040
  const parsed = JSON.parse(body);
package/dist/index.js CHANGED
@@ -450,6 +450,26 @@ function clampResponseBytes(maxBytes) {
450
450
  if (!Number.isFinite(maxBytes) || maxBytes < 1) return MAX_RESPONSE_BYTES;
451
451
  return Math.min(maxBytes, MAX_RESPONSE_BYTES);
452
452
  }
453
+ function boundOrigin(credential) {
454
+ let parsed;
455
+ try {
456
+ parsed = new URL(credential.origin);
457
+ } catch {
458
+ parsed = void 0;
459
+ }
460
+ if (!parsed || parsed.protocol !== "http:" && parsed.protocol !== "https:") {
461
+ throw new NodeError("CREDENTIAL_ORIGIN", "The origin a credential is bound to is not an http(s) address", { status: 0 });
462
+ }
463
+ return parsed.origin;
464
+ }
465
+ function credentialOriginRefusal(target, bound, hop) {
466
+ const what = hop === void 0 ? "a request" : `a ${hop} redirect`;
467
+ return new NodeError(
468
+ "CREDENTIAL_ORIGIN",
469
+ `Refused ${what} to ${target}: the credential it carries is bound to ${bound}`,
470
+ { status: 0 }
471
+ );
472
+ }
453
473
  async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
454
474
  const ac = new AbortController();
455
475
  const timer = setTimeout(() => ac.abort(), effectiveMs);
@@ -489,7 +509,16 @@ function hasContent(body) {
489
509
  if (body instanceof URLSearchParams) return body.toString().length > 0;
490
510
  return true;
491
511
  }
492
- async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
512
+ function refuseUserinfo(url, status = 0) {
513
+ if (url.username !== "" || url.password !== "") {
514
+ throw new NodeError(
515
+ "INVALID_URL",
516
+ status === 0 ? "Refused a URL with a user name or password in it: pass them in a credential, not in the address" : `Refused a ${status} redirect to a URL with a user name or password in it`,
517
+ { status: 0 }
518
+ );
519
+ }
520
+ }
521
+ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding) {
493
522
  let currentUrl = url;
494
523
  let currentInit = { ...fetchOptions, redirect: "manual" };
495
524
  await guardUrl(currentUrl, ctxSignal, effectiveMs);
@@ -518,6 +547,10 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
518
547
  } catch {
519
548
  throw new NodeError("BLOCKED_ADDRESS", "Blocked redirect with an invalid Location header", { status: 0 });
520
549
  }
550
+ refuseUserinfo(nextUrl, redirectStatus);
551
+ if (binding && binding.crossOriginRedirect === "refuse" && nextUrl.origin !== binding.origin) {
552
+ throw credentialOriginRefusal(nextUrl.origin, binding.origin, redirectStatus);
553
+ }
521
554
  if (base.protocol === "https:" && nextUrl.protocol !== "https:") {
522
555
  throw new NodeError("BLOCKED_ADDRESS", "Blocked https\u2192http downgrade on redirect", { status: 0 });
523
556
  }
@@ -538,7 +571,9 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
538
571
  );
539
572
  }
540
573
  await guardUrl(nextUrl, ctxSignal, effectiveMs);
541
- if (nextUrl.origin !== base.origin) {
574
+ if (binding && nextUrl.origin !== binding.origin) {
575
+ for (const name of [...headers.keys()]) headers.delete(name);
576
+ } else if (nextUrl.origin !== base.origin) {
542
577
  for (const name of [...headers.keys()]) {
543
578
  if (!headerCrossesOrigin(name)) headers.delete(name);
544
579
  }
@@ -548,7 +583,7 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
548
583
  }
549
584
  }
550
585
  async function safeFetch(url, options = {}) {
551
- const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, ...fetchOptions } = options;
586
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, credential, ...fetchOptions } = options;
552
587
  const effectiveMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
553
588
  const rawAttempts = retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
554
589
  const safeAttempts = Number.isFinite(rawAttempts) ? Math.min(Math.max(0, rawAttempts), MAX_RETRY_ATTEMPTS) : DEFAULT_RETRY_ATTEMPTS;
@@ -556,14 +591,26 @@ async function safeFetch(url, options = {}) {
556
591
  const retryDelayMs = retry?.delayMs ?? DEFAULT_RETRY_DELAY_MS;
557
592
  if (ctxSignal?.aborted) throw ctxSignal.reason;
558
593
  if (fetchOptions.headers !== void 0) fetchOptions.headers = sendableHeaders(fetchOptions.headers);
594
+ let parsed;
595
+ try {
596
+ parsed = url instanceof URL ? url : new URL(url);
597
+ } catch {
598
+ throw new NodeError("INVALID_URL", "Refused an address that cannot be read as a URL", { status: 0 });
599
+ }
600
+ refuseUserinfo(parsed);
601
+ let binding;
602
+ if (credential) {
603
+ binding = { origin: boundOrigin(credential), crossOriginRedirect: credential.crossOriginRedirect ?? "refuse" };
604
+ if (parsed.origin !== binding.origin) throw credentialOriginRefusal(parsed.origin, binding.origin);
605
+ }
559
606
  let lastError;
560
607
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
561
608
  if (ctxSignal?.aborted) throw ctxSignal.reason;
562
609
  try {
563
- return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs);
610
+ return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding);
564
611
  } catch (err) {
565
612
  if (ctxSignal?.aborted) throw ctxSignal.reason;
566
- if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS")) {
613
+ if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS" || err.code === "CREDENTIAL_ORIGIN" || err.code === "INVALID_URL")) {
567
614
  throw err;
568
615
  }
569
616
  lastError = err;
@@ -844,15 +891,23 @@ var BaseCredential = class {
844
891
  });
845
892
  const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
846
893
  if (!res.ok) {
847
- throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
894
+ throw new Error(`token endpoint ${endpointForMessage(url)} -> ${res.status}${formatOAuthError(text)}`);
848
895
  }
849
896
  try {
850
897
  return JSON.parse(text);
851
898
  } catch {
852
- throw new Error(`token endpoint ${url} returned non-JSON body`);
899
+ throw new Error(`token endpoint ${endpointForMessage(url)} returned non-JSON body`);
853
900
  }
854
901
  }
855
902
  };
903
+ function endpointForMessage(url) {
904
+ try {
905
+ const u = new URL(url);
906
+ return `${u.origin}${u.pathname}`;
907
+ } catch {
908
+ return "(unreadable address)";
909
+ }
910
+ }
856
911
  function formatOAuthError(body) {
857
912
  try {
858
913
  const parsed = JSON.parse(body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/integrations-node-sdk",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "TypeScript interfaces and utilities for Revenexx integration nodes",
5
5
  "license": "MIT",
6
6
  "author": "revenexx GmbH",