@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 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 axios({
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 the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
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"] = "sha256=" + import_node_crypto6.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
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: "Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.",
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 declared to be: hubspotToken
1524
- // is the one provider field that is not `required`, because this is
1525
- // internal CRM tooling no merchant sees. A deployment with no portal
1526
- // is a supported state, not a missing credential.
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 the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload.'
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' ] = 'sha256=' + crypto
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 to confirm each payload.',
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 declared to be: hubspotToken
1524
- // is the one provider field that is not `required`, because this is
1525
- // internal CRM tooling no merchant sees. A deployment with no portal
1526
- // is a supported state, not a missing credential.
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 the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload.'
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' ] = 'sha256=' + crypto
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 to confirm each payload.',
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 axios({
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 the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
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"] = "sha256=" + crypto2.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
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: "Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.",
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
- export { toCanonicalEmail };
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
- export { toCanonicalEmail };
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
  };
@@ -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 axios({
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 the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
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"] = "sha256=" + import_node_crypto6.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
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: "Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.",
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 axios({
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 the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
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"] = "sha256=" + crypto3.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
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: "Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.",
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
  }
@@ -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 axios({
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,
@@ -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 axios({
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,
@@ -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 axios({
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 axios({
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
@@ -216,5 +216,5 @@
216
216
  "prepublishOnly": ". \"$HOME/.nvm/nvm.sh\" && nvm use && tsup && node --test"
217
217
  },
218
218
  "types": "dist/index.d.ts",
219
- "version": "0.0.155"
219
+ "version": "0.0.157"
220
220
  }