@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 +22 -0
- package/dist/index.cjs +62 -7
- package/dist/index.js +62 -7
- package/package.json +1 -1
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
|
-
|
|
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 !==
|
|
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
|
-
|
|
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 !==
|
|
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);
|