@drawbridge/drawbridge-utils 0.0.155 → 0.0.157
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/axios.cjs +5 -0
- package/dist/axios.d.cts +14 -0
- package/dist/axios.d.ts +14 -0
- package/dist/axios.js +5 -0
- package/dist/connections/index.cjs +29 -6
- package/dist/connections/index.d.cts +53 -11
- package/dist/connections/index.d.ts +53 -11
- package/dist/connections/index.js +29 -6
- package/dist/email.cjs +14 -0
- package/dist/email.d.cts +53 -1
- package/dist/email.d.ts +53 -1
- package/dist/email.js +13 -0
- package/dist/providers.cjs +29 -6
- package/dist/providers.js +29 -6
- package/dist/safe-http.cjs +9 -2
- package/dist/safe-http.d.cts +9 -2
- package/dist/safe-http.d.ts +9 -2
- package/dist/safe-http.js +9 -2
- package/package.json +1 -1
package/dist/axios.cjs
CHANGED
|
@@ -58,9 +58,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
58
58
|
if (a >= 224) return true;
|
|
59
59
|
return false;
|
|
60
60
|
};
|
|
61
|
+
var transition = new import_net.default.BlockList();
|
|
62
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
63
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
64
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
61
65
|
var isBlockedIPv6 = (ip) => {
|
|
62
66
|
const lower = ip.toLowerCase();
|
|
63
67
|
if (lower === "::1" || lower === "::") return true;
|
|
68
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
64
69
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
65
70
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
66
71
|
if (lower.startsWith("ff")) return true;
|
package/dist/axios.d.cts
CHANGED
|
@@ -34,12 +34,26 @@ const isBlockedIPv4 = ( ip ) => {
|
|
|
34
34
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
// The IPv6 transition mechanisms each carry a v4 address inside the v6 one and
|
|
38
|
+
// a gateway unwraps it, so 10.0.0.1 can arrive dressed as 64:ff9b::a00:1 and
|
|
39
|
+
// pass a check that only knows the plain ranges. No merchant endpoint lives on
|
|
40
|
+
// one — the public internet runs native v6 or v4 — so the three prefixes are
|
|
41
|
+
// refused whole rather than unpacked. BlockList understands every textual form,
|
|
42
|
+
// which the prefix comparisons below do not.
|
|
43
|
+
const transition = new net.BlockList();
|
|
44
|
+
|
|
45
|
+
transition.addSubnet( '64:ff9b::', 96, 'ipv6' );
|
|
46
|
+
transition.addSubnet( '2002::', 16, 'ipv6' );
|
|
47
|
+
transition.addSubnet( '2001::', 32, 'ipv6' );
|
|
48
|
+
|
|
37
49
|
const isBlockedIPv6 = ( ip ) => {
|
|
38
50
|
|
|
39
51
|
const lower = ip.toLowerCase();
|
|
40
52
|
|
|
41
53
|
if( lower === '::1' || lower === '::' ) return true;
|
|
42
54
|
|
|
55
|
+
if( transition.check( ip, 'ipv6' ) ) return true;
|
|
56
|
+
|
|
43
57
|
// fc00::/7 — unique local addresses
|
|
44
58
|
if( lower.startsWith( 'fc' ) || lower.startsWith( 'fd' ) ) return true;
|
|
45
59
|
|
package/dist/axios.d.ts
CHANGED
|
@@ -34,12 +34,26 @@ const isBlockedIPv4 = ( ip ) => {
|
|
|
34
34
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
// The IPv6 transition mechanisms each carry a v4 address inside the v6 one and
|
|
38
|
+
// a gateway unwraps it, so 10.0.0.1 can arrive dressed as 64:ff9b::a00:1 and
|
|
39
|
+
// pass a check that only knows the plain ranges. No merchant endpoint lives on
|
|
40
|
+
// one — the public internet runs native v6 or v4 — so the three prefixes are
|
|
41
|
+
// refused whole rather than unpacked. BlockList understands every textual form,
|
|
42
|
+
// which the prefix comparisons below do not.
|
|
43
|
+
const transition = new net.BlockList();
|
|
44
|
+
|
|
45
|
+
transition.addSubnet( '64:ff9b::', 96, 'ipv6' );
|
|
46
|
+
transition.addSubnet( '2002::', 16, 'ipv6' );
|
|
47
|
+
transition.addSubnet( '2001::', 32, 'ipv6' );
|
|
48
|
+
|
|
37
49
|
const isBlockedIPv6 = ( ip ) => {
|
|
38
50
|
|
|
39
51
|
const lower = ip.toLowerCase();
|
|
40
52
|
|
|
41
53
|
if( lower === '::1' || lower === '::' ) return true;
|
|
42
54
|
|
|
55
|
+
if( transition.check( ip, 'ipv6' ) ) return true;
|
|
56
|
+
|
|
43
57
|
// fc00::/7 — unique local addresses
|
|
44
58
|
if( lower.startsWith( 'fc' ) || lower.startsWith( 'fd' ) ) return true;
|
|
45
59
|
|
package/dist/axios.js
CHANGED
|
@@ -23,9 +23,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
23
23
|
if (a >= 224) return true;
|
|
24
24
|
return false;
|
|
25
25
|
};
|
|
26
|
+
var transition = new net.BlockList();
|
|
27
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
28
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
29
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
26
30
|
var isBlockedIPv6 = (ip) => {
|
|
27
31
|
const lower = ip.toLowerCase();
|
|
28
32
|
if (lower === "::1" || lower === "::") return true;
|
|
33
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
29
34
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
30
35
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
31
36
|
if (lower.startsWith("ff")) return true;
|
|
@@ -4867,9 +4867,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
4867
4867
|
if (a >= 224) return true;
|
|
4868
4868
|
return false;
|
|
4869
4869
|
};
|
|
4870
|
+
var transition = new import_net.default.BlockList();
|
|
4871
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
4872
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
4873
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
4870
4874
|
var isBlockedIPv6 = (ip) => {
|
|
4871
4875
|
const lower = ip.toLowerCase();
|
|
4872
4876
|
if (lower === "::1" || lower === "::") return true;
|
|
4877
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
4873
4878
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
4874
4879
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
4875
4880
|
if (lower.startsWith("ff")) return true;
|
|
@@ -4928,12 +4933,13 @@ var pinnedAgent = async (url) => {
|
|
|
4928
4933
|
var safeRequest = async ({
|
|
4929
4934
|
body,
|
|
4930
4935
|
headers = {},
|
|
4936
|
+
maxContentLength,
|
|
4931
4937
|
method = "GET",
|
|
4932
4938
|
query,
|
|
4933
4939
|
timeout = DEFAULT_TIMEOUT_MS2,
|
|
4934
4940
|
type = "json",
|
|
4935
4941
|
url
|
|
4936
|
-
}) => {
|
|
4942
|
+
}, { transport = axios } = {}) => {
|
|
4937
4943
|
const full = new URL(url);
|
|
4938
4944
|
if (query) {
|
|
4939
4945
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -4941,7 +4947,7 @@ var safeRequest = async ({
|
|
|
4941
4947
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
4942
4948
|
const isForm = type === "form";
|
|
4943
4949
|
try {
|
|
4944
|
-
const response = await
|
|
4950
|
+
const response = await transport({
|
|
4945
4951
|
method,
|
|
4946
4952
|
url: full.toString(),
|
|
4947
4953
|
headers: {
|
|
@@ -4951,6 +4957,7 @@ var safeRequest = async ({
|
|
|
4951
4957
|
...body !== void 0 && {
|
|
4952
4958
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
4953
4959
|
},
|
|
4960
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
4954
4961
|
timeout,
|
|
4955
4962
|
maxRedirects: 0,
|
|
4956
4963
|
httpAgent: protocol === "http:" ? agent : void 0,
|
|
@@ -4973,6 +4980,19 @@ var safeRequest = async ({
|
|
|
4973
4980
|
};
|
|
4974
4981
|
|
|
4975
4982
|
// lib/connections/providers/webhook.js
|
|
4983
|
+
var RESPONSE_LIMIT = 64 * 1024;
|
|
4984
|
+
var signature = ({ body, settings }) => {
|
|
4985
|
+
var _a;
|
|
4986
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
4987
|
+
const payload = timestamp + "." + JSON.stringify(body);
|
|
4988
|
+
const previous = ((_a = settings.previous) == null ? void 0 : _a.secret) && new Date(settings.previous.until) > /* @__PURE__ */ new Date() ? [settings.previous.secret] : [];
|
|
4989
|
+
return [
|
|
4990
|
+
"t=" + timestamp,
|
|
4991
|
+
...[settings.secret, ...previous].map(
|
|
4992
|
+
(secret) => "v1=" + import_node_crypto6.default.createHmac("sha256", secret).update(payload).digest("hex")
|
|
4993
|
+
)
|
|
4994
|
+
].join(",");
|
|
4995
|
+
};
|
|
4976
4996
|
var webhook_default = {
|
|
4977
4997
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
4978
4998
|
// so the buttons say what actually happens.
|
|
@@ -5003,7 +5023,9 @@ var webhook_default = {
|
|
|
5003
5023
|
"Press Connect. Drawbridge generates a signing secret and shows it here.",
|
|
5004
5024
|
"Copy the secret into your own endpoint.",
|
|
5005
5025
|
"Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
|
|
5006
|
-
"On each request, compute HMAC-SHA256 of
|
|
5026
|
+
"On each request, read the X-Drawbridge-Signature header \u2014 `t=<unix seconds>,v1=<hex>` \u2014 and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.",
|
|
5027
|
+
"Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.",
|
|
5028
|
+
"After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window."
|
|
5007
5029
|
]
|
|
5008
5030
|
},
|
|
5009
5031
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -5071,13 +5093,14 @@ var webhook_default = {
|
|
|
5071
5093
|
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
5072
5094
|
const request2 = { method, url: url || null };
|
|
5073
5095
|
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
5096
|
+
if (!/^https:\/\//i.test(url)) return { message: "Outgoing webhook URL must use https.", request: request2, response: { skipped: true }, skipped: true };
|
|
5074
5097
|
const body = lead || context;
|
|
5075
5098
|
request2.body = body;
|
|
5076
5099
|
const outgoing = { ...headers };
|
|
5077
5100
|
if (settings == null ? void 0 : settings.secret) {
|
|
5078
|
-
outgoing["X-Drawbridge-Signature"] =
|
|
5101
|
+
outgoing["X-Drawbridge-Signature"] = signature({ body, settings });
|
|
5079
5102
|
}
|
|
5080
|
-
const response = await send2({ body, headers: outgoing, method, url });
|
|
5103
|
+
const response = await send2({ body, headers: outgoing, maxContentLength: RESPONSE_LIMIT, method, url });
|
|
5081
5104
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
5082
5105
|
}
|
|
5083
5106
|
}
|
|
@@ -5126,7 +5149,7 @@ var webhook_default = {
|
|
|
5126
5149
|
// and a task repeating it talks over the button.
|
|
5127
5150
|
tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
|
|
5128
5151
|
{
|
|
5129
|
-
message:
|
|
5152
|
+
message: 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
|
|
5130
5153
|
title: "Requests must be verified",
|
|
5131
5154
|
type: "warning"
|
|
5132
5155
|
}
|
|
@@ -1520,10 +1520,15 @@ const contacts = {
|
|
|
1520
1520
|
remove : async ( { email, id, token }, { fetcher } = {} ) => {
|
|
1521
1521
|
|
|
1522
1522
|
// NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
|
|
1523
|
-
// difference is what the credential is
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1523
|
+
// difference is what the credential is FOR: this is internal CRM
|
|
1524
|
+
// tooling no merchant sees, so a deployment with no portal is a
|
|
1525
|
+
// supported state rather than a missing credential.
|
|
1526
|
+
//
|
|
1527
|
+
// The manifest does mark hubspotToken `required`, and that is not a
|
|
1528
|
+
// contradiction — the flag feeds `isLive` and nothing else, so it
|
|
1529
|
+
// decides whether the admin card reads live. Not live is the honest
|
|
1530
|
+
// badge when nothing will sync. It gates no behaviour, and
|
|
1531
|
+
// saveProviderSettings never consults it, so the row still writes.
|
|
1527
1532
|
if( ! token ) return;
|
|
1528
1533
|
|
|
1529
1534
|
const contact = id || await lookup({ email, fetcher, token });
|
|
@@ -5867,6 +5872,39 @@ var shopify = {
|
|
|
5867
5872
|
// Webhooks — the only connection with no third party behind it. Connecting
|
|
5868
5873
|
// generates a signing secret rather than asking for a credential, which is why
|
|
5869
5874
|
// its one field declares no `input`.
|
|
5875
|
+
|
|
5876
|
+
// A receiver owes us a status, not a document. Whatever it returns is buffered
|
|
5877
|
+
// whole in the worker and written to the run record, so it is bounded.
|
|
5878
|
+
const RESPONSE_LIMIT = 64 * 1024;
|
|
5879
|
+
|
|
5880
|
+
// STRIPE-SHAPED: `t=<unix seconds>,v1=<hex>[,v1=<hex>]`, over `<t>.<raw body>`.
|
|
5881
|
+
// The timestamp is INSIDE the signed string, so a captured delivery stops
|
|
5882
|
+
// verifying once the receiver's tolerance passes — signing the body alone made a
|
|
5883
|
+
// replay valid forever. A second v1 carries the previous secret while a
|
|
5884
|
+
// regeneration's grace window is open (the api stamps `previous.until`), so a
|
|
5885
|
+
// receiver still on the old value keeps verifying mid-rotation instead of
|
|
5886
|
+
// failing every delivery between the button press and their config change.
|
|
5887
|
+
const signature = ( { body, settings } ) => {
|
|
5888
|
+
|
|
5889
|
+
const timestamp = Math.floor( Date.now() / 1000 );
|
|
5890
|
+
|
|
5891
|
+
const payload = timestamp + '.' + JSON.stringify( body );
|
|
5892
|
+
|
|
5893
|
+
const previous = settings.previous?.secret && new Date( settings.previous.until ) > new Date()
|
|
5894
|
+
? [ settings.previous.secret ]
|
|
5895
|
+
: [];
|
|
5896
|
+
|
|
5897
|
+
return [
|
|
5898
|
+
't=' + timestamp,
|
|
5899
|
+
...[ settings.secret, ...previous ].map( ( secret ) => 'v1=' + crypto
|
|
5900
|
+
.createHmac( 'sha256', secret )
|
|
5901
|
+
.update( payload )
|
|
5902
|
+
.digest( 'hex' )
|
|
5903
|
+
)
|
|
5904
|
+
].join( ',' );
|
|
5905
|
+
|
|
5906
|
+
};
|
|
5907
|
+
|
|
5870
5908
|
var webhook = {
|
|
5871
5909
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
5872
5910
|
// so the buttons say what actually happens.
|
|
@@ -5897,7 +5935,9 @@ var webhook = {
|
|
|
5897
5935
|
'Press Connect. Drawbridge generates a signing secret and shows it here.',
|
|
5898
5936
|
'Copy the secret into your own endpoint.',
|
|
5899
5937
|
'Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.',
|
|
5900
|
-
'On each request, compute HMAC-SHA256 of
|
|
5938
|
+
'On each request, read the X-Drawbridge-Signature header — `t=<unix seconds>,v1=<hex>` — and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.',
|
|
5939
|
+
'Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.',
|
|
5940
|
+
'After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window.'
|
|
5901
5941
|
]
|
|
5902
5942
|
},
|
|
5903
5943
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -5970,6 +6010,11 @@ var webhook = {
|
|
|
5970
6010
|
|
|
5971
6011
|
if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
|
|
5972
6012
|
|
|
6013
|
+
// HTTPS ONLY. The body is the lead — email, phone, address — and a
|
|
6014
|
+
// signature authenticates it without hiding it. The api refuses one on
|
|
6015
|
+
// save; this catches a document written any other way.
|
|
6016
|
+
if( ! /^https:\/\//i.test( url ) ) return { message : 'Outgoing webhook URL must use https.', request, response : { skipped : true }, skipped : true };
|
|
6017
|
+
|
|
5973
6018
|
// The LEAD, when there is one, rather than the accumulated context. A
|
|
5974
6019
|
// receiver wants the entrant's record, not our internal step state —
|
|
5975
6020
|
// and the shell already resolved it, because every hook that names a
|
|
@@ -5985,14 +6030,11 @@ var webhook = {
|
|
|
5985
6030
|
// tells the merchant to check it.
|
|
5986
6031
|
if( settings?.secret ){
|
|
5987
6032
|
|
|
5988
|
-
outgoing[ 'X-Drawbridge-Signature' ] =
|
|
5989
|
-
.createHmac( 'sha256', settings.secret )
|
|
5990
|
-
.update( JSON.stringify( body ) )
|
|
5991
|
-
.digest( 'hex' );
|
|
6033
|
+
outgoing[ 'X-Drawbridge-Signature' ] = signature({ body, settings });
|
|
5992
6034
|
|
|
5993
6035
|
}
|
|
5994
6036
|
|
|
5995
|
-
const response = await send({ body, headers : outgoing, method, url });
|
|
6037
|
+
const response = await send({ body, headers : outgoing, maxContentLength : RESPONSE_LIMIT, method, url });
|
|
5996
6038
|
|
|
5997
6039
|
return { message : 'Webhook POSTed to ' + url + '.', request, response : response || { delivered : true } };
|
|
5998
6040
|
|
|
@@ -6047,7 +6089,7 @@ var webhook = {
|
|
|
6047
6089
|
: settings?.secret
|
|
6048
6090
|
? [
|
|
6049
6091
|
{
|
|
6050
|
-
message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header
|
|
6092
|
+
message : 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
|
|
6051
6093
|
title : 'Requests must be verified',
|
|
6052
6094
|
type : 'warning'
|
|
6053
6095
|
}
|
|
@@ -1520,10 +1520,15 @@ const contacts = {
|
|
|
1520
1520
|
remove : async ( { email, id, token }, { fetcher } = {} ) => {
|
|
1521
1521
|
|
|
1522
1522
|
// NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
|
|
1523
|
-
// difference is what the credential is
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1523
|
+
// difference is what the credential is FOR: this is internal CRM
|
|
1524
|
+
// tooling no merchant sees, so a deployment with no portal is a
|
|
1525
|
+
// supported state rather than a missing credential.
|
|
1526
|
+
//
|
|
1527
|
+
// The manifest does mark hubspotToken `required`, and that is not a
|
|
1528
|
+
// contradiction — the flag feeds `isLive` and nothing else, so it
|
|
1529
|
+
// decides whether the admin card reads live. Not live is the honest
|
|
1530
|
+
// badge when nothing will sync. It gates no behaviour, and
|
|
1531
|
+
// saveProviderSettings never consults it, so the row still writes.
|
|
1527
1532
|
if( ! token ) return;
|
|
1528
1533
|
|
|
1529
1534
|
const contact = id || await lookup({ email, fetcher, token });
|
|
@@ -5867,6 +5872,39 @@ var shopify = {
|
|
|
5867
5872
|
// Webhooks — the only connection with no third party behind it. Connecting
|
|
5868
5873
|
// generates a signing secret rather than asking for a credential, which is why
|
|
5869
5874
|
// its one field declares no `input`.
|
|
5875
|
+
|
|
5876
|
+
// A receiver owes us a status, not a document. Whatever it returns is buffered
|
|
5877
|
+
// whole in the worker and written to the run record, so it is bounded.
|
|
5878
|
+
const RESPONSE_LIMIT = 64 * 1024;
|
|
5879
|
+
|
|
5880
|
+
// STRIPE-SHAPED: `t=<unix seconds>,v1=<hex>[,v1=<hex>]`, over `<t>.<raw body>`.
|
|
5881
|
+
// The timestamp is INSIDE the signed string, so a captured delivery stops
|
|
5882
|
+
// verifying once the receiver's tolerance passes — signing the body alone made a
|
|
5883
|
+
// replay valid forever. A second v1 carries the previous secret while a
|
|
5884
|
+
// regeneration's grace window is open (the api stamps `previous.until`), so a
|
|
5885
|
+
// receiver still on the old value keeps verifying mid-rotation instead of
|
|
5886
|
+
// failing every delivery between the button press and their config change.
|
|
5887
|
+
const signature = ( { body, settings } ) => {
|
|
5888
|
+
|
|
5889
|
+
const timestamp = Math.floor( Date.now() / 1000 );
|
|
5890
|
+
|
|
5891
|
+
const payload = timestamp + '.' + JSON.stringify( body );
|
|
5892
|
+
|
|
5893
|
+
const previous = settings.previous?.secret && new Date( settings.previous.until ) > new Date()
|
|
5894
|
+
? [ settings.previous.secret ]
|
|
5895
|
+
: [];
|
|
5896
|
+
|
|
5897
|
+
return [
|
|
5898
|
+
't=' + timestamp,
|
|
5899
|
+
...[ settings.secret, ...previous ].map( ( secret ) => 'v1=' + crypto
|
|
5900
|
+
.createHmac( 'sha256', secret )
|
|
5901
|
+
.update( payload )
|
|
5902
|
+
.digest( 'hex' )
|
|
5903
|
+
)
|
|
5904
|
+
].join( ',' );
|
|
5905
|
+
|
|
5906
|
+
};
|
|
5907
|
+
|
|
5870
5908
|
var webhook = {
|
|
5871
5909
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
5872
5910
|
// so the buttons say what actually happens.
|
|
@@ -5897,7 +5935,9 @@ var webhook = {
|
|
|
5897
5935
|
'Press Connect. Drawbridge generates a signing secret and shows it here.',
|
|
5898
5936
|
'Copy the secret into your own endpoint.',
|
|
5899
5937
|
'Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.',
|
|
5900
|
-
'On each request, compute HMAC-SHA256 of
|
|
5938
|
+
'On each request, read the X-Drawbridge-Signature header — `t=<unix seconds>,v1=<hex>` — and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.',
|
|
5939
|
+
'Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.',
|
|
5940
|
+
'After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window.'
|
|
5901
5941
|
]
|
|
5902
5942
|
},
|
|
5903
5943
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -5970,6 +6010,11 @@ var webhook = {
|
|
|
5970
6010
|
|
|
5971
6011
|
if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
|
|
5972
6012
|
|
|
6013
|
+
// HTTPS ONLY. The body is the lead — email, phone, address — and a
|
|
6014
|
+
// signature authenticates it without hiding it. The api refuses one on
|
|
6015
|
+
// save; this catches a document written any other way.
|
|
6016
|
+
if( ! /^https:\/\//i.test( url ) ) return { message : 'Outgoing webhook URL must use https.', request, response : { skipped : true }, skipped : true };
|
|
6017
|
+
|
|
5973
6018
|
// The LEAD, when there is one, rather than the accumulated context. A
|
|
5974
6019
|
// receiver wants the entrant's record, not our internal step state —
|
|
5975
6020
|
// and the shell already resolved it, because every hook that names a
|
|
@@ -5985,14 +6030,11 @@ var webhook = {
|
|
|
5985
6030
|
// tells the merchant to check it.
|
|
5986
6031
|
if( settings?.secret ){
|
|
5987
6032
|
|
|
5988
|
-
outgoing[ 'X-Drawbridge-Signature' ] =
|
|
5989
|
-
.createHmac( 'sha256', settings.secret )
|
|
5990
|
-
.update( JSON.stringify( body ) )
|
|
5991
|
-
.digest( 'hex' );
|
|
6033
|
+
outgoing[ 'X-Drawbridge-Signature' ] = signature({ body, settings });
|
|
5992
6034
|
|
|
5993
6035
|
}
|
|
5994
6036
|
|
|
5995
|
-
const response = await send({ body, headers : outgoing, method, url });
|
|
6037
|
+
const response = await send({ body, headers : outgoing, maxContentLength : RESPONSE_LIMIT, method, url });
|
|
5996
6038
|
|
|
5997
6039
|
return { message : 'Webhook POSTed to ' + url + '.', request, response : response || { delivered : true } };
|
|
5998
6040
|
|
|
@@ -6047,7 +6089,7 @@ var webhook = {
|
|
|
6047
6089
|
: settings?.secret
|
|
6048
6090
|
? [
|
|
6049
6091
|
{
|
|
6050
|
-
message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header
|
|
6092
|
+
message : 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
|
|
6051
6093
|
title : 'Requests must be verified',
|
|
6052
6094
|
type : 'warning'
|
|
6053
6095
|
}
|
|
@@ -4791,9 +4791,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
4791
4791
|
if (a >= 224) return true;
|
|
4792
4792
|
return false;
|
|
4793
4793
|
};
|
|
4794
|
+
var transition = new net.BlockList();
|
|
4795
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
4796
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
4797
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
4794
4798
|
var isBlockedIPv6 = (ip) => {
|
|
4795
4799
|
const lower = ip.toLowerCase();
|
|
4796
4800
|
if (lower === "::1" || lower === "::") return true;
|
|
4801
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
4797
4802
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
4798
4803
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
4799
4804
|
if (lower.startsWith("ff")) return true;
|
|
@@ -4852,12 +4857,13 @@ var pinnedAgent = async (url) => {
|
|
|
4852
4857
|
var safeRequest = async ({
|
|
4853
4858
|
body,
|
|
4854
4859
|
headers = {},
|
|
4860
|
+
maxContentLength,
|
|
4855
4861
|
method = "GET",
|
|
4856
4862
|
query,
|
|
4857
4863
|
timeout = DEFAULT_TIMEOUT_MS2,
|
|
4858
4864
|
type = "json",
|
|
4859
4865
|
url
|
|
4860
|
-
}) => {
|
|
4866
|
+
}, { transport = axios } = {}) => {
|
|
4861
4867
|
const full = new URL(url);
|
|
4862
4868
|
if (query) {
|
|
4863
4869
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -4865,7 +4871,7 @@ var safeRequest = async ({
|
|
|
4865
4871
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
4866
4872
|
const isForm = type === "form";
|
|
4867
4873
|
try {
|
|
4868
|
-
const response = await
|
|
4874
|
+
const response = await transport({
|
|
4869
4875
|
method,
|
|
4870
4876
|
url: full.toString(),
|
|
4871
4877
|
headers: {
|
|
@@ -4875,6 +4881,7 @@ var safeRequest = async ({
|
|
|
4875
4881
|
...body !== void 0 && {
|
|
4876
4882
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
4877
4883
|
},
|
|
4884
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
4878
4885
|
timeout,
|
|
4879
4886
|
maxRedirects: 0,
|
|
4880
4887
|
httpAgent: protocol === "http:" ? agent : void 0,
|
|
@@ -4897,6 +4904,19 @@ var safeRequest = async ({
|
|
|
4897
4904
|
};
|
|
4898
4905
|
|
|
4899
4906
|
// lib/connections/providers/webhook.js
|
|
4907
|
+
var RESPONSE_LIMIT = 64 * 1024;
|
|
4908
|
+
var signature = ({ body, settings }) => {
|
|
4909
|
+
var _a;
|
|
4910
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
4911
|
+
const payload = timestamp + "." + JSON.stringify(body);
|
|
4912
|
+
const previous = ((_a = settings.previous) == null ? void 0 : _a.secret) && new Date(settings.previous.until) > /* @__PURE__ */ new Date() ? [settings.previous.secret] : [];
|
|
4913
|
+
return [
|
|
4914
|
+
"t=" + timestamp,
|
|
4915
|
+
...[settings.secret, ...previous].map(
|
|
4916
|
+
(secret) => "v1=" + crypto2.createHmac("sha256", secret).update(payload).digest("hex")
|
|
4917
|
+
)
|
|
4918
|
+
].join(",");
|
|
4919
|
+
};
|
|
4900
4920
|
var webhook_default = {
|
|
4901
4921
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
4902
4922
|
// so the buttons say what actually happens.
|
|
@@ -4927,7 +4947,9 @@ var webhook_default = {
|
|
|
4927
4947
|
"Press Connect. Drawbridge generates a signing secret and shows it here.",
|
|
4928
4948
|
"Copy the secret into your own endpoint.",
|
|
4929
4949
|
"Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
|
|
4930
|
-
"On each request, compute HMAC-SHA256 of
|
|
4950
|
+
"On each request, read the X-Drawbridge-Signature header \u2014 `t=<unix seconds>,v1=<hex>` \u2014 and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.",
|
|
4951
|
+
"Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.",
|
|
4952
|
+
"After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window."
|
|
4931
4953
|
]
|
|
4932
4954
|
},
|
|
4933
4955
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -4995,13 +5017,14 @@ var webhook_default = {
|
|
|
4995
5017
|
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
4996
5018
|
const request2 = { method, url: url || null };
|
|
4997
5019
|
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
5020
|
+
if (!/^https:\/\//i.test(url)) return { message: "Outgoing webhook URL must use https.", request: request2, response: { skipped: true }, skipped: true };
|
|
4998
5021
|
const body = lead || context;
|
|
4999
5022
|
request2.body = body;
|
|
5000
5023
|
const outgoing = { ...headers };
|
|
5001
5024
|
if (settings == null ? void 0 : settings.secret) {
|
|
5002
|
-
outgoing["X-Drawbridge-Signature"] =
|
|
5025
|
+
outgoing["X-Drawbridge-Signature"] = signature({ body, settings });
|
|
5003
5026
|
}
|
|
5004
|
-
const response = await send2({ body, headers: outgoing, method, url });
|
|
5027
|
+
const response = await send2({ body, headers: outgoing, maxContentLength: RESPONSE_LIMIT, method, url });
|
|
5005
5028
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
5006
5029
|
}
|
|
5007
5030
|
}
|
|
@@ -5050,7 +5073,7 @@ var webhook_default = {
|
|
|
5050
5073
|
// and a task repeating it talks over the button.
|
|
5051
5074
|
tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
|
|
5052
5075
|
{
|
|
5053
|
-
message:
|
|
5076
|
+
message: 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
|
|
5054
5077
|
title: "Requests must be verified",
|
|
5055
5078
|
type: "warning"
|
|
5056
5079
|
}
|
package/dist/email.cjs
CHANGED
|
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// lib/email.js
|
|
20
20
|
var email_exports = {};
|
|
21
21
|
__export(email_exports, {
|
|
22
|
+
subAddress: () => subAddress,
|
|
22
23
|
toCanonicalEmail: () => toCanonicalEmail
|
|
23
24
|
});
|
|
24
25
|
module.exports = __toCommonJS(email_exports);
|
|
@@ -36,7 +37,20 @@ var toCanonicalEmail = (value) => {
|
|
|
36
37
|
if (!local) return null;
|
|
37
38
|
return local + "@" + domain;
|
|
38
39
|
};
|
|
40
|
+
var LOCAL_PART_LIMIT = 64;
|
|
41
|
+
var subAddress = (email, tag) => {
|
|
42
|
+
if (!email || typeof email !== "string") return email;
|
|
43
|
+
if (!tag || typeof tag !== "string") return email;
|
|
44
|
+
if (!/^[A-Za-z0-9]+$/.test(tag)) return email;
|
|
45
|
+
const at = email.lastIndexOf("@");
|
|
46
|
+
if (at < 1 || at === email.length - 1) return email;
|
|
47
|
+
const local = email.slice(0, at);
|
|
48
|
+
if (local.includes("+")) return email;
|
|
49
|
+
if (local.length + 1 + tag.length > LOCAL_PART_LIMIT) return email;
|
|
50
|
+
return local + "+" + tag + "@" + email.slice(at + 1);
|
|
51
|
+
};
|
|
39
52
|
// Annotate the CommonJS export names for ESM import in node:
|
|
40
53
|
0 && (module.exports = {
|
|
54
|
+
subAddress,
|
|
41
55
|
toCanonicalEmail
|
|
42
56
|
});
|
package/dist/email.d.cts
CHANGED
|
@@ -33,4 +33,56 @@ const toCanonicalEmail = ( value ) => {
|
|
|
33
33
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// RFC 5322 caps the local part at 64 octets. Everything here is ASCII, so
|
|
37
|
+
// length is octets.
|
|
38
|
+
const LOCAL_PART_LIMIT = 64;
|
|
39
|
+
|
|
40
|
+
// The inverse of toCanonicalEmail: put a tag INTO the local part rather than
|
|
41
|
+
// strip one out. `subAddress( 'org@send.drwbrdg.com', '507f…' )` gives
|
|
42
|
+
// `org+507f…@send.drwbrdg.com` — RFC 5233 sub-addressing, which Google delivers
|
|
43
|
+
// to the untagged mailbox natively.
|
|
44
|
+
//
|
|
45
|
+
// This is how the platform's lead-facing sender carries the ORGANIZATION.
|
|
46
|
+
// Shopify does the same thing with `store+<shopId>@shopifyemail.com`: one
|
|
47
|
+
// mailbox, one MX record, one authenticated domain, and the tenant rides in the
|
|
48
|
+
// address instead of in infrastructure. There is no per-organization address to
|
|
49
|
+
// store, so there is nothing to migrate or backfill.
|
|
50
|
+
//
|
|
51
|
+
// It is derived at send time, never persisted. Worth settling BEFORE the first
|
|
52
|
+
// send rather than after: the From line is frozen into every message already
|
|
53
|
+
// delivered, so a shared address cannot be split apart retroactively.
|
|
54
|
+
//
|
|
55
|
+
// Returns the address UNCHANGED whenever it cannot be tagged safely — a flat
|
|
56
|
+
// from-address is a working email, a malformed one is a send the provider
|
|
57
|
+
// rejects outright. Callers rely on that: the sender resolvers fall through to
|
|
58
|
+
// their own defaults on a falsy return, and never on a broken string.
|
|
59
|
+
const subAddress = ( email, tag ) => {
|
|
60
|
+
|
|
61
|
+
if( ! email || typeof email !== 'string' ) return email;
|
|
62
|
+
if( ! tag || typeof tag !== 'string' ) return email;
|
|
63
|
+
|
|
64
|
+
// Organization ids are 24-char ObjectId hex, and the short-id generator is
|
|
65
|
+
// base36 — both alphanumeric, both safely atext. Checked rather than assumed
|
|
66
|
+
// because the api schema types `id` as a bare string with no pattern, and
|
|
67
|
+
// `formats.documents.insert` spreads caller data OVER the generated id, so a
|
|
68
|
+
// hand-written id would win.
|
|
69
|
+
if( ! /^[A-Za-z0-9]+$/.test( tag ) ) return email;
|
|
70
|
+
|
|
71
|
+
const at = email.lastIndexOf( '@' );
|
|
72
|
+
|
|
73
|
+
if( at < 1 || at === email.length - 1 ) return email;
|
|
74
|
+
|
|
75
|
+
const local = email.slice( 0, at );
|
|
76
|
+
|
|
77
|
+
// Already tagged. Re-tagging would nest (`org+a+b@`), and the first tag is
|
|
78
|
+
// the one a receiving mailbox would route on — so the second would be a
|
|
79
|
+
// silent lie about which tenant the mail belongs to.
|
|
80
|
+
if( local.includes( '+' ) ) return email;
|
|
81
|
+
|
|
82
|
+
if( local.length + 1 + tag.length > LOCAL_PART_LIMIT ) return email;
|
|
83
|
+
|
|
84
|
+
return local + '+' + tag + '@' + email.slice( at + 1 );
|
|
85
|
+
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export { subAddress, toCanonicalEmail };
|
package/dist/email.d.ts
CHANGED
|
@@ -33,4 +33,56 @@ const toCanonicalEmail = ( value ) => {
|
|
|
33
33
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// RFC 5322 caps the local part at 64 octets. Everything here is ASCII, so
|
|
37
|
+
// length is octets.
|
|
38
|
+
const LOCAL_PART_LIMIT = 64;
|
|
39
|
+
|
|
40
|
+
// The inverse of toCanonicalEmail: put a tag INTO the local part rather than
|
|
41
|
+
// strip one out. `subAddress( 'org@send.drwbrdg.com', '507f…' )` gives
|
|
42
|
+
// `org+507f…@send.drwbrdg.com` — RFC 5233 sub-addressing, which Google delivers
|
|
43
|
+
// to the untagged mailbox natively.
|
|
44
|
+
//
|
|
45
|
+
// This is how the platform's lead-facing sender carries the ORGANIZATION.
|
|
46
|
+
// Shopify does the same thing with `store+<shopId>@shopifyemail.com`: one
|
|
47
|
+
// mailbox, one MX record, one authenticated domain, and the tenant rides in the
|
|
48
|
+
// address instead of in infrastructure. There is no per-organization address to
|
|
49
|
+
// store, so there is nothing to migrate or backfill.
|
|
50
|
+
//
|
|
51
|
+
// It is derived at send time, never persisted. Worth settling BEFORE the first
|
|
52
|
+
// send rather than after: the From line is frozen into every message already
|
|
53
|
+
// delivered, so a shared address cannot be split apart retroactively.
|
|
54
|
+
//
|
|
55
|
+
// Returns the address UNCHANGED whenever it cannot be tagged safely — a flat
|
|
56
|
+
// from-address is a working email, a malformed one is a send the provider
|
|
57
|
+
// rejects outright. Callers rely on that: the sender resolvers fall through to
|
|
58
|
+
// their own defaults on a falsy return, and never on a broken string.
|
|
59
|
+
const subAddress = ( email, tag ) => {
|
|
60
|
+
|
|
61
|
+
if( ! email || typeof email !== 'string' ) return email;
|
|
62
|
+
if( ! tag || typeof tag !== 'string' ) return email;
|
|
63
|
+
|
|
64
|
+
// Organization ids are 24-char ObjectId hex, and the short-id generator is
|
|
65
|
+
// base36 — both alphanumeric, both safely atext. Checked rather than assumed
|
|
66
|
+
// because the api schema types `id` as a bare string with no pattern, and
|
|
67
|
+
// `formats.documents.insert` spreads caller data OVER the generated id, so a
|
|
68
|
+
// hand-written id would win.
|
|
69
|
+
if( ! /^[A-Za-z0-9]+$/.test( tag ) ) return email;
|
|
70
|
+
|
|
71
|
+
const at = email.lastIndexOf( '@' );
|
|
72
|
+
|
|
73
|
+
if( at < 1 || at === email.length - 1 ) return email;
|
|
74
|
+
|
|
75
|
+
const local = email.slice( 0, at );
|
|
76
|
+
|
|
77
|
+
// Already tagged. Re-tagging would nest (`org+a+b@`), and the first tag is
|
|
78
|
+
// the one a receiving mailbox would route on — so the second would be a
|
|
79
|
+
// silent lie about which tenant the mail belongs to.
|
|
80
|
+
if( local.includes( '+' ) ) return email;
|
|
81
|
+
|
|
82
|
+
if( local.length + 1 + tag.length > LOCAL_PART_LIMIT ) return email;
|
|
83
|
+
|
|
84
|
+
return local + '+' + tag + '@' + email.slice( at + 1 );
|
|
85
|
+
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export { subAddress, toCanonicalEmail };
|
package/dist/email.js
CHANGED
|
@@ -13,6 +13,19 @@ var toCanonicalEmail = (value) => {
|
|
|
13
13
|
if (!local) return null;
|
|
14
14
|
return local + "@" + domain;
|
|
15
15
|
};
|
|
16
|
+
var LOCAL_PART_LIMIT = 64;
|
|
17
|
+
var subAddress = (email, tag) => {
|
|
18
|
+
if (!email || typeof email !== "string") return email;
|
|
19
|
+
if (!tag || typeof tag !== "string") return email;
|
|
20
|
+
if (!/^[A-Za-z0-9]+$/.test(tag)) return email;
|
|
21
|
+
const at = email.lastIndexOf("@");
|
|
22
|
+
if (at < 1 || at === email.length - 1) return email;
|
|
23
|
+
const local = email.slice(0, at);
|
|
24
|
+
if (local.includes("+")) return email;
|
|
25
|
+
if (local.length + 1 + tag.length > LOCAL_PART_LIMIT) return email;
|
|
26
|
+
return local + "+" + tag + "@" + email.slice(at + 1);
|
|
27
|
+
};
|
|
16
28
|
export {
|
|
29
|
+
subAddress,
|
|
17
30
|
toCanonicalEmail
|
|
18
31
|
};
|
package/dist/providers.cjs
CHANGED
|
@@ -4792,9 +4792,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
4792
4792
|
if (a >= 224) return true;
|
|
4793
4793
|
return false;
|
|
4794
4794
|
};
|
|
4795
|
+
var transition = new import_net.default.BlockList();
|
|
4796
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
4797
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
4798
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
4795
4799
|
var isBlockedIPv6 = (ip) => {
|
|
4796
4800
|
const lower = ip.toLowerCase();
|
|
4797
4801
|
if (lower === "::1" || lower === "::") return true;
|
|
4802
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
4798
4803
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
4799
4804
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
4800
4805
|
if (lower.startsWith("ff")) return true;
|
|
@@ -4853,12 +4858,13 @@ var pinnedAgent = async (url) => {
|
|
|
4853
4858
|
var safeRequest = async ({
|
|
4854
4859
|
body,
|
|
4855
4860
|
headers = {},
|
|
4861
|
+
maxContentLength,
|
|
4856
4862
|
method = "GET",
|
|
4857
4863
|
query,
|
|
4858
4864
|
timeout = DEFAULT_TIMEOUT_MS2,
|
|
4859
4865
|
type = "json",
|
|
4860
4866
|
url
|
|
4861
|
-
}) => {
|
|
4867
|
+
}, { transport = axios } = {}) => {
|
|
4862
4868
|
const full = new URL(url);
|
|
4863
4869
|
if (query) {
|
|
4864
4870
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -4866,7 +4872,7 @@ var safeRequest = async ({
|
|
|
4866
4872
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
4867
4873
|
const isForm = type === "form";
|
|
4868
4874
|
try {
|
|
4869
|
-
const response = await
|
|
4875
|
+
const response = await transport({
|
|
4870
4876
|
method,
|
|
4871
4877
|
url: full.toString(),
|
|
4872
4878
|
headers: {
|
|
@@ -4876,6 +4882,7 @@ var safeRequest = async ({
|
|
|
4876
4882
|
...body !== void 0 && {
|
|
4877
4883
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
4878
4884
|
},
|
|
4885
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
4879
4886
|
timeout,
|
|
4880
4887
|
maxRedirects: 0,
|
|
4881
4888
|
httpAgent: protocol === "http:" ? agent : void 0,
|
|
@@ -4898,6 +4905,19 @@ var safeRequest = async ({
|
|
|
4898
4905
|
};
|
|
4899
4906
|
|
|
4900
4907
|
// lib/connections/providers/webhook.js
|
|
4908
|
+
var RESPONSE_LIMIT = 64 * 1024;
|
|
4909
|
+
var signature = ({ body, settings }) => {
|
|
4910
|
+
var _a;
|
|
4911
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
4912
|
+
const payload = timestamp + "." + JSON.stringify(body);
|
|
4913
|
+
const previous = ((_a = settings.previous) == null ? void 0 : _a.secret) && new Date(settings.previous.until) > /* @__PURE__ */ new Date() ? [settings.previous.secret] : [];
|
|
4914
|
+
return [
|
|
4915
|
+
"t=" + timestamp,
|
|
4916
|
+
...[settings.secret, ...previous].map(
|
|
4917
|
+
(secret) => "v1=" + import_node_crypto6.default.createHmac("sha256", secret).update(payload).digest("hex")
|
|
4918
|
+
)
|
|
4919
|
+
].join(",");
|
|
4920
|
+
};
|
|
4901
4921
|
var webhook_default = {
|
|
4902
4922
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
4903
4923
|
// so the buttons say what actually happens.
|
|
@@ -4928,7 +4948,9 @@ var webhook_default = {
|
|
|
4928
4948
|
"Press Connect. Drawbridge generates a signing secret and shows it here.",
|
|
4929
4949
|
"Copy the secret into your own endpoint.",
|
|
4930
4950
|
"Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
|
|
4931
|
-
"On each request, compute HMAC-SHA256 of
|
|
4951
|
+
"On each request, read the X-Drawbridge-Signature header \u2014 `t=<unix seconds>,v1=<hex>` \u2014 and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.",
|
|
4952
|
+
"Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.",
|
|
4953
|
+
"After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window."
|
|
4932
4954
|
]
|
|
4933
4955
|
},
|
|
4934
4956
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -4996,13 +5018,14 @@ var webhook_default = {
|
|
|
4996
5018
|
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
4997
5019
|
const request2 = { method, url: url || null };
|
|
4998
5020
|
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
5021
|
+
if (!/^https:\/\//i.test(url)) return { message: "Outgoing webhook URL must use https.", request: request2, response: { skipped: true }, skipped: true };
|
|
4999
5022
|
const body = lead || context;
|
|
5000
5023
|
request2.body = body;
|
|
5001
5024
|
const outgoing = { ...headers };
|
|
5002
5025
|
if (settings == null ? void 0 : settings.secret) {
|
|
5003
|
-
outgoing["X-Drawbridge-Signature"] =
|
|
5026
|
+
outgoing["X-Drawbridge-Signature"] = signature({ body, settings });
|
|
5004
5027
|
}
|
|
5005
|
-
const response = await send2({ body, headers: outgoing, method, url });
|
|
5028
|
+
const response = await send2({ body, headers: outgoing, maxContentLength: RESPONSE_LIMIT, method, url });
|
|
5006
5029
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
5007
5030
|
}
|
|
5008
5031
|
}
|
|
@@ -5051,7 +5074,7 @@ var webhook_default = {
|
|
|
5051
5074
|
// and a task repeating it talks over the button.
|
|
5052
5075
|
tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
|
|
5053
5076
|
{
|
|
5054
|
-
message:
|
|
5077
|
+
message: 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
|
|
5055
5078
|
title: "Requests must be verified",
|
|
5056
5079
|
type: "warning"
|
|
5057
5080
|
}
|
package/dist/providers.js
CHANGED
|
@@ -4746,9 +4746,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
4746
4746
|
if (a >= 224) return true;
|
|
4747
4747
|
return false;
|
|
4748
4748
|
};
|
|
4749
|
+
var transition = new net.BlockList();
|
|
4750
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
4751
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
4752
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
4749
4753
|
var isBlockedIPv6 = (ip) => {
|
|
4750
4754
|
const lower = ip.toLowerCase();
|
|
4751
4755
|
if (lower === "::1" || lower === "::") return true;
|
|
4756
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
4752
4757
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
4753
4758
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
4754
4759
|
if (lower.startsWith("ff")) return true;
|
|
@@ -4807,12 +4812,13 @@ var pinnedAgent = async (url) => {
|
|
|
4807
4812
|
var safeRequest = async ({
|
|
4808
4813
|
body,
|
|
4809
4814
|
headers = {},
|
|
4815
|
+
maxContentLength,
|
|
4810
4816
|
method = "GET",
|
|
4811
4817
|
query,
|
|
4812
4818
|
timeout = DEFAULT_TIMEOUT_MS2,
|
|
4813
4819
|
type = "json",
|
|
4814
4820
|
url
|
|
4815
|
-
}) => {
|
|
4821
|
+
}, { transport = axios } = {}) => {
|
|
4816
4822
|
const full = new URL(url);
|
|
4817
4823
|
if (query) {
|
|
4818
4824
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -4820,7 +4826,7 @@ var safeRequest = async ({
|
|
|
4820
4826
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
4821
4827
|
const isForm = type === "form";
|
|
4822
4828
|
try {
|
|
4823
|
-
const response = await
|
|
4829
|
+
const response = await transport({
|
|
4824
4830
|
method,
|
|
4825
4831
|
url: full.toString(),
|
|
4826
4832
|
headers: {
|
|
@@ -4830,6 +4836,7 @@ var safeRequest = async ({
|
|
|
4830
4836
|
...body !== void 0 && {
|
|
4831
4837
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
4832
4838
|
},
|
|
4839
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
4833
4840
|
timeout,
|
|
4834
4841
|
maxRedirects: 0,
|
|
4835
4842
|
httpAgent: protocol === "http:" ? agent : void 0,
|
|
@@ -4852,6 +4859,19 @@ var safeRequest = async ({
|
|
|
4852
4859
|
};
|
|
4853
4860
|
|
|
4854
4861
|
// lib/connections/providers/webhook.js
|
|
4862
|
+
var RESPONSE_LIMIT = 64 * 1024;
|
|
4863
|
+
var signature = ({ body, settings }) => {
|
|
4864
|
+
var _a;
|
|
4865
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
4866
|
+
const payload = timestamp + "." + JSON.stringify(body);
|
|
4867
|
+
const previous = ((_a = settings.previous) == null ? void 0 : _a.secret) && new Date(settings.previous.until) > /* @__PURE__ */ new Date() ? [settings.previous.secret] : [];
|
|
4868
|
+
return [
|
|
4869
|
+
"t=" + timestamp,
|
|
4870
|
+
...[settings.secret, ...previous].map(
|
|
4871
|
+
(secret) => "v1=" + crypto3.createHmac("sha256", secret).update(payload).digest("hex")
|
|
4872
|
+
)
|
|
4873
|
+
].join(",");
|
|
4874
|
+
};
|
|
4855
4875
|
var webhook_default = {
|
|
4856
4876
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
4857
4877
|
// so the buttons say what actually happens.
|
|
@@ -4882,7 +4902,9 @@ var webhook_default = {
|
|
|
4882
4902
|
"Press Connect. Drawbridge generates a signing secret and shows it here.",
|
|
4883
4903
|
"Copy the secret into your own endpoint.",
|
|
4884
4904
|
"Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
|
|
4885
|
-
"On each request, compute HMAC-SHA256 of
|
|
4905
|
+
"On each request, read the X-Drawbridge-Signature header \u2014 `t=<unix seconds>,v1=<hex>` \u2014 and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.",
|
|
4906
|
+
"Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.",
|
|
4907
|
+
"After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window."
|
|
4886
4908
|
]
|
|
4887
4909
|
},
|
|
4888
4910
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -4950,13 +4972,14 @@ var webhook_default = {
|
|
|
4950
4972
|
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
4951
4973
|
const request2 = { method, url: url || null };
|
|
4952
4974
|
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
4975
|
+
if (!/^https:\/\//i.test(url)) return { message: "Outgoing webhook URL must use https.", request: request2, response: { skipped: true }, skipped: true };
|
|
4953
4976
|
const body = lead || context;
|
|
4954
4977
|
request2.body = body;
|
|
4955
4978
|
const outgoing = { ...headers };
|
|
4956
4979
|
if (settings == null ? void 0 : settings.secret) {
|
|
4957
|
-
outgoing["X-Drawbridge-Signature"] =
|
|
4980
|
+
outgoing["X-Drawbridge-Signature"] = signature({ body, settings });
|
|
4958
4981
|
}
|
|
4959
|
-
const response = await send2({ body, headers: outgoing, method, url });
|
|
4982
|
+
const response = await send2({ body, headers: outgoing, maxContentLength: RESPONSE_LIMIT, method, url });
|
|
4960
4983
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
4961
4984
|
}
|
|
4962
4985
|
}
|
|
@@ -5005,7 +5028,7 @@ var webhook_default = {
|
|
|
5005
5028
|
// and a task repeating it talks over the button.
|
|
5006
5029
|
tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
|
|
5007
5030
|
{
|
|
5008
|
-
message:
|
|
5031
|
+
message: 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
|
|
5009
5032
|
title: "Requests must be verified",
|
|
5010
5033
|
type: "warning"
|
|
5011
5034
|
}
|
package/dist/safe-http.cjs
CHANGED
|
@@ -64,9 +64,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
64
64
|
if (a >= 224) return true;
|
|
65
65
|
return false;
|
|
66
66
|
};
|
|
67
|
+
var transition = new import_net.default.BlockList();
|
|
68
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
69
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
70
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
67
71
|
var isBlockedIPv6 = (ip) => {
|
|
68
72
|
const lower = ip.toLowerCase();
|
|
69
73
|
if (lower === "::1" || lower === "::") return true;
|
|
74
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
70
75
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
71
76
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
72
77
|
if (lower.startsWith("ff")) return true;
|
|
@@ -128,12 +133,13 @@ var pinnedAgent = async (url) => {
|
|
|
128
133
|
var safeRequest = async ({
|
|
129
134
|
body,
|
|
130
135
|
headers = {},
|
|
136
|
+
maxContentLength,
|
|
131
137
|
method = "GET",
|
|
132
138
|
query,
|
|
133
139
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
134
140
|
type = "json",
|
|
135
141
|
url
|
|
136
|
-
}) => {
|
|
142
|
+
}, { transport = axios } = {}) => {
|
|
137
143
|
const full = new URL(url);
|
|
138
144
|
if (query) {
|
|
139
145
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -141,7 +147,7 @@ var safeRequest = async ({
|
|
|
141
147
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
142
148
|
const isForm = type === "form";
|
|
143
149
|
try {
|
|
144
|
-
const response = await
|
|
150
|
+
const response = await transport({
|
|
145
151
|
method,
|
|
146
152
|
url: full.toString(),
|
|
147
153
|
headers: {
|
|
@@ -151,6 +157,7 @@ var safeRequest = async ({
|
|
|
151
157
|
...body !== void 0 && {
|
|
152
158
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
153
159
|
},
|
|
160
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
154
161
|
timeout,
|
|
155
162
|
maxRedirects: 0,
|
|
156
163
|
httpAgent: protocol === "http:" ? agent : void 0,
|
package/dist/safe-http.d.cts
CHANGED
|
@@ -103,15 +103,21 @@ const pinnedAgent = async ( url ) => {
|
|
|
103
103
|
// but pins the vetted IP and refuses redirects. A webhook endpoint that
|
|
104
104
|
// 3xx-redirects is treated as a failure — an intentional trade for closing the
|
|
105
105
|
// redirect-to-internal vector.
|
|
106
|
+
//
|
|
107
|
+
// `maxContentLength` bounds the RESPONSE. Unset, the transport's default stands
|
|
108
|
+
// (video assets and scrape targets are legitimately large); a caller that only
|
|
109
|
+
// owes the far side a status — an outgoing webhook — sets it, because whatever
|
|
110
|
+
// comes back is buffered whole in the worker. `transport` is the test seam.
|
|
106
111
|
const safeRequest = async ({
|
|
107
112
|
body,
|
|
108
113
|
headers = {},
|
|
114
|
+
maxContentLength,
|
|
109
115
|
method = 'GET',
|
|
110
116
|
query,
|
|
111
117
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
112
118
|
type = 'json',
|
|
113
119
|
url
|
|
114
|
-
}) => {
|
|
120
|
+
}, { transport = axios } = {}) => {
|
|
115
121
|
|
|
116
122
|
const full = new URL( url );
|
|
117
123
|
|
|
@@ -126,7 +132,7 @@ const safeRequest = async ({
|
|
|
126
132
|
|
|
127
133
|
try {
|
|
128
134
|
|
|
129
|
-
const response = await
|
|
135
|
+
const response = await transport({
|
|
130
136
|
method,
|
|
131
137
|
url : full.toString(),
|
|
132
138
|
headers : {
|
|
@@ -136,6 +142,7 @@ const safeRequest = async ({
|
|
|
136
142
|
...( body !== undefined && {
|
|
137
143
|
data : isForm ? new URLSearchParams( body ).toString() : body
|
|
138
144
|
}),
|
|
145
|
+
...( maxContentLength !== undefined && { maxContentLength } ),
|
|
139
146
|
timeout,
|
|
140
147
|
maxRedirects : 0,
|
|
141
148
|
httpAgent : protocol === 'http:' ? agent : undefined,
|
package/dist/safe-http.d.ts
CHANGED
|
@@ -103,15 +103,21 @@ const pinnedAgent = async ( url ) => {
|
|
|
103
103
|
// but pins the vetted IP and refuses redirects. A webhook endpoint that
|
|
104
104
|
// 3xx-redirects is treated as a failure — an intentional trade for closing the
|
|
105
105
|
// redirect-to-internal vector.
|
|
106
|
+
//
|
|
107
|
+
// `maxContentLength` bounds the RESPONSE. Unset, the transport's default stands
|
|
108
|
+
// (video assets and scrape targets are legitimately large); a caller that only
|
|
109
|
+
// owes the far side a status — an outgoing webhook — sets it, because whatever
|
|
110
|
+
// comes back is buffered whole in the worker. `transport` is the test seam.
|
|
106
111
|
const safeRequest = async ({
|
|
107
112
|
body,
|
|
108
113
|
headers = {},
|
|
114
|
+
maxContentLength,
|
|
109
115
|
method = 'GET',
|
|
110
116
|
query,
|
|
111
117
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
112
118
|
type = 'json',
|
|
113
119
|
url
|
|
114
|
-
}) => {
|
|
120
|
+
}, { transport = axios } = {}) => {
|
|
115
121
|
|
|
116
122
|
const full = new URL( url );
|
|
117
123
|
|
|
@@ -126,7 +132,7 @@ const safeRequest = async ({
|
|
|
126
132
|
|
|
127
133
|
try {
|
|
128
134
|
|
|
129
|
-
const response = await
|
|
135
|
+
const response = await transport({
|
|
130
136
|
method,
|
|
131
137
|
url : full.toString(),
|
|
132
138
|
headers : {
|
|
@@ -136,6 +142,7 @@ const safeRequest = async ({
|
|
|
136
142
|
...( body !== undefined && {
|
|
137
143
|
data : isForm ? new URLSearchParams( body ).toString() : body
|
|
138
144
|
}),
|
|
145
|
+
...( maxContentLength !== undefined && { maxContentLength } ),
|
|
139
146
|
timeout,
|
|
140
147
|
maxRedirects : 0,
|
|
141
148
|
httpAgent : protocol === 'http:' ? agent : undefined,
|
package/dist/safe-http.js
CHANGED
|
@@ -28,9 +28,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
28
28
|
if (a >= 224) return true;
|
|
29
29
|
return false;
|
|
30
30
|
};
|
|
31
|
+
var transition = new net.BlockList();
|
|
32
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
33
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
34
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
31
35
|
var isBlockedIPv6 = (ip) => {
|
|
32
36
|
const lower = ip.toLowerCase();
|
|
33
37
|
if (lower === "::1" || lower === "::") return true;
|
|
38
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
34
39
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
35
40
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
36
41
|
if (lower.startsWith("ff")) return true;
|
|
@@ -92,12 +97,13 @@ var pinnedAgent = async (url) => {
|
|
|
92
97
|
var safeRequest = async ({
|
|
93
98
|
body,
|
|
94
99
|
headers = {},
|
|
100
|
+
maxContentLength,
|
|
95
101
|
method = "GET",
|
|
96
102
|
query,
|
|
97
103
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
98
104
|
type = "json",
|
|
99
105
|
url
|
|
100
|
-
}) => {
|
|
106
|
+
}, { transport = axios } = {}) => {
|
|
101
107
|
const full = new URL(url);
|
|
102
108
|
if (query) {
|
|
103
109
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -105,7 +111,7 @@ var safeRequest = async ({
|
|
|
105
111
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
106
112
|
const isForm = type === "form";
|
|
107
113
|
try {
|
|
108
|
-
const response = await
|
|
114
|
+
const response = await transport({
|
|
109
115
|
method,
|
|
110
116
|
url: full.toString(),
|
|
111
117
|
headers: {
|
|
@@ -115,6 +121,7 @@ var safeRequest = async ({
|
|
|
115
121
|
...body !== void 0 && {
|
|
116
122
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
117
123
|
},
|
|
124
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
118
125
|
timeout,
|
|
119
126
|
maxRedirects: 0,
|
|
120
127
|
httpAgent: protocol === "http:" ? agent : void 0,
|
package/package.json
CHANGED