bullet_train-outgoing_webhooks 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f25fee3752c94a0cb76bb8c916f12668510c536b2b2c8f4691d3987550c2ce78
4
- data.tar.gz: 9c9af6f83cb45497bfff87a58389028c16203f414c000f9b7ebd6a5a83a824b9
3
+ metadata.gz: dba0cb965743f696358e08d3f2bcfefbec7598b4de625b3315469d030a2be551
4
+ data.tar.gz: 9d33299349ba554bb4d409c39c9831cbbdc1355414f621068c623d6734d360e4
5
5
  SHA512:
6
- metadata.gz: 515e2a9ea6000a18566d87d067fa77b40d549f341ff091313e06b4823caea3221b3d64e8d1e094e667a828a35c88a71804ef5978a2f579b6b53acf92c1756426
7
- data.tar.gz: 040f758cb37aa680a2b5d0052a0240ebfda9c0cbbd0aa078d89b8ee87461e7e6cdbc33fbca6ce4eaca5615d4f7b06187788ad8a6112976b41d657f16ef8fe1f7
6
+ metadata.gz: c7f09f96deff0e056aa9d6e3732e4dba9c407f6c8adbf7ffa627d13f0d94f3e6db4d32a73efd836331b93aed07314f3d7176ff824e0657bc337c522e9dfa6721
7
+ data.tar.gz: 4e4754e98e1e5003ed4ee4ee656368a08fd27cf0e327f332cf191a36e2b1f9056bb327dd12d23e64bac6d61457f6866cbf02e5bf16b0a6186c2f864fcca67d84
@@ -60,6 +60,17 @@ class Account::Webhooks::Outgoing::EndpointsController < Account::ApplicationCon
60
60
  end
61
61
  end
62
62
 
63
+ # POST /account/webhooks/outgoing/endpoints/:id/rotate_secret
64
+ def rotate_secret
65
+ @endpoint ||= Webhooks::Outgoing::Endpoint.accessible_by(current_ability).find(params[:id])
66
+
67
+ respond_to do |format|
68
+ if @endpoint.rotate_webhook_secret!
69
+ format.html { redirect_to [:account, @endpoint], notice: I18n.t("webhooks/outgoing/endpoints.notifications.secret_rotated") }
70
+ end
71
+ end
72
+ end
73
+
63
74
  private
64
75
 
65
76
  # Never trust parameters from the scary internet, only allow the white list through.
@@ -59,10 +59,22 @@ module Webhooks::Outgoing::DeliveryAttemptSupport
59
59
  http.verify_mode = BulletTrain::OutgoingWebhooks.http_verify_mode
60
60
  end
61
61
  end
62
+
62
63
  request = Net::HTTP::Post.new(uri.request_uri)
63
64
  request.add_field("Host", uri.host)
64
65
  request.add_field("Content-Type", "application/json")
65
- request.body = delivery.event.payload.to_json
66
+
67
+ # Generate and add signature headers
68
+ payload = delivery.event.payload
69
+ signature_data = BulletTrain::OutgoingWebhooks::Signature
70
+ .generate(payload, delivery.endpoint.webhook_secret)
71
+
72
+ webhook_headers_namespace = Rails.configuration.outgoing_webhooks[:webhook_headers_namespace]
73
+ request.add_field("#{webhook_headers_namespace}-Signature", signature_data[:signature])
74
+ request.add_field("#{webhook_headers_namespace}-Timestamp", signature_data[:timestamp])
75
+ request.add_field("#{webhook_headers_namespace}-Id", delivery.event.uuid)
76
+
77
+ request.body = payload.to_json
66
78
 
67
79
  begin
68
80
  response = http.request(request)
@@ -14,8 +14,10 @@ module Webhooks::Outgoing::EndpointSupport
14
14
  validates :name, presence: true
15
15
 
16
16
  before_validation { url&.strip! }
17
+ before_validation :generate_webhook_secret, on: :create
17
18
 
18
19
  validates :url, presence: true, allowed_uri: BulletTrain::OutgoingWebhooks.advanced_hostname_security
20
+ validates :webhook_secret, presence: true
19
21
 
20
22
  after_initialize do
21
23
  self.event_type_ids ||= []
@@ -40,4 +42,12 @@ module Webhooks::Outgoing::EndpointSupport
40
42
  def touch_parent
41
43
  send(BulletTrain::OutgoingWebhooks.parent_association).touch
42
44
  end
45
+
46
+ def generate_webhook_secret
47
+ self.webhook_secret ||= SecureRandom.hex(32)
48
+ end
49
+
50
+ def rotate_webhook_secret!
51
+ update!(webhook_secret: SecureRandom.hex(32))
52
+ end
43
53
  end
@@ -14,6 +14,7 @@
14
14
  <%# 🚅 super scaffolding will insert new fields above this line. %>
15
15
  <% end %>
16
16
 
17
+
17
18
  <div class="buttons">
18
19
  <%= form.submit (form.object.persisted? ? t('.buttons.update') : t('.buttons.create')), class: "button" %>
19
20
  <% if form.object.persisted? %>
@@ -15,6 +15,7 @@
15
15
  <tr>
16
16
  <th><%= t('.fields.name.heading') %></th>
17
17
  <th><%= t('.fields.url.heading') %></th>
18
+ <th><%= t('.fields.webhook_secret.heading') %></th>
18
19
  <%# 🚅 super scaffolding will insert new field headers above this line. %>
19
20
  <th class="text-right"></th>
20
21
  </tr>
@@ -25,6 +26,7 @@
25
26
  <tr data-id="<%= endpoint.id %>">
26
27
  <td><%= render 'shared/attributes/text', attribute: :name, url: [:account, endpoint] %></td>
27
28
  <td><%= render 'shared/attributes/code', attribute: :url %></td>
29
+ <td><%= render 'shared/attributes/code', attribute: :webhook_secret, secret: true %></td>
28
30
  <%# 🚅 super scaffolding will insert new fields above this line. %>
29
31
  <td class="buttons">
30
32
  <% unless hide_actions %>
@@ -28,6 +28,7 @@
28
28
  <% end %>
29
29
 
30
30
  <%= render 'shared/attributes/belongs_to', attribute: :scaffolding_absolutely_abstract_creative_concept %>
31
+ <%= render 'shared/attributes/code', attribute: :webhook_secret, secret: true %>
31
32
  <%# 🚅 super scaffolding will insert new fields above this line. %>
32
33
  <% end %>
33
34
  <% end %>
@@ -36,6 +37,14 @@
36
37
  <%= link_to t('.buttons.edit'), [:edit, :account, @endpoint], class: first_button_primary if can? :edit, @endpoint %>
37
38
  <%= button_to t('.buttons.destroy'), [:account, @endpoint], method: :delete, class: first_button_primary, data: { turbo_confirm: t('.buttons.confirmations.destroy', model_locales(@endpoint)) } if can? :destroy, @endpoint %>
38
39
  <%= link_to t('global.buttons.back'), [:account, @parent, :webhooks_outgoing_endpoints], class: first_button_primary %>
40
+ <% if can? :edit, @endpoint %>
41
+ <%=
42
+ button_to t('.buttons.rotate_secret'),
43
+ rotate_secret_account_webhooks_outgoing_endpoint_path(@endpoint),
44
+ method: :post, class: first_button_primary,
45
+ data: { turbo_confirm: t('.buttons.confirmations.rotate_secret', model_locales(@endpoint)) }
46
+ %>
47
+ <% end %>
39
48
  <% end %>
40
49
  <% end %>
41
50
 
@@ -7,3 +7,8 @@ json.extract! endpoint,
7
7
  # 🚅 super scaffolding will insert new fields above this line.
8
8
  :created_at,
9
9
  :updated_at
10
+
11
+ # Avoid spilling secrets via the API. We still need to show it once on create so
12
+ # endpoints created programmaticly via the API can save it and use it to verify
13
+ # the signature.
14
+ json.webhook_secret endpoint.webhook_secret if endpoint.previously_new_record?
@@ -12,12 +12,15 @@ en:
12
12
  edit: Edit Settings
13
13
  update: Update Endpoint
14
14
  destroy: Remove Endpoint
15
+ rotate_secret: Rotate Secret
15
16
  shorthand:
16
17
  edit: Settings
17
18
  destroy: Delete
18
19
  confirmations:
19
- # TODO customize for your use-case.
20
20
  destroy: Are you sure you want to remove %{endpoint_name}? This will also remove it's associated data. This can't be undone.
21
+ rotate_secret: >
22
+ Are you sure you want to rotate the secret of the endpoint %{endpoint_name}?
23
+ Your webhook event verification system will need to start handling the new secret.
21
24
  fields: &fields
22
25
  id:
23
26
  _: &id Endpoint ID
@@ -74,6 +77,15 @@ en:
74
77
 
75
78
  scaffolding_absolutely_abstract_creative_concept: *scaffolding_absolutely_abstract_creative_concept
76
79
 
80
+ webhook_secret:
81
+ _: &webhook_secret Webhook Secret
82
+ label: *webhook_secret
83
+ heading: *webhook_secret
84
+ api_description: |
85
+ Each webhook endpoint has a rollable secret. If you create your endpoints programmatically
86
+ via the API, the secret is only shown after creating the endpoint. If you missed to store
87
+ it, you will need to look it up on the app's UI endpoint show or index pages.
88
+ truncation_with_instructions: ... (use button below to rotate)
77
89
  # 🚅 super scaffolding will insert new fields above this line.
78
90
  created_at:
79
91
  _: &created_at Added
@@ -130,6 +142,7 @@ en:
130
142
  created: Endpoint was successfully created.
131
143
  updated: Endpoint was successfully updated.
132
144
  destroyed: Endpoint was successfully destroyed.
145
+ secret_rotated: Endpoint's webhook secret successfully rotated.
133
146
  account:
134
147
  webhooks:
135
148
  outgoing:
@@ -142,6 +155,7 @@ en:
142
155
  api_version: *api_version
143
156
  event_type_ids: *event_type_ids
144
157
  scaffolding_absolutely_abstract_creative_concept_id: *scaffolding_absolutely_abstract_creative_concept_id
158
+ webhook_secret: *webhook_secret
145
159
  # 🚅 super scaffolding will insert new activerecord attributes above this line.
146
160
  created_at: *created_at
147
- updated_at: *updated_at
161
+ updated_at: *updated_at
data/config/routes.rb CHANGED
@@ -10,6 +10,10 @@ Rails.application.routes.draw do
10
10
  resources :deliveries, only: %i[index show] do
11
11
  resources :delivery_attempts, only: %i[index show]
12
12
  end
13
+
14
+ member do
15
+ post :rotate_secret
16
+ end
13
17
  end
14
18
  end
15
19
  end
@@ -22,7 +22,9 @@ module BulletTrain
22
22
  allowed_schemes: %w[http https],
23
23
  custom_block_callback: nil,
24
24
  custom_allow_callback: nil,
25
- audit_callback: ->(obj, uri) { Rails.logger.error("BlockedURI obj=#{obj.persisted? ? obj.to_global_id : "New #{obj.class}"} uri=#{uri}") }
25
+ audit_callback: ->(obj, uri) { Rails.logger.error("BlockedURI obj=#{obj.persisted? ? obj.to_global_id : "New #{obj.class}"} uri=#{uri}") },
26
+ webhook_headers_namespace: "X-Webhook-Bullet-Train",
27
+ event_verification_tolerance_seconds: 600 # 5-10 minutes is industry-default.
26
28
  }
27
29
  end
28
30
  end
@@ -0,0 +1,68 @@
1
+ module BulletTrain
2
+ module OutgoingWebhooks
3
+ # Provides methods for webhook signatures. This module also serves as an
4
+ # example that can be used by receiving applications to verify webhook
5
+ # authenticity.
6
+ module Signature
7
+ # Verifies the authenticity of a webhook request.
8
+ #
9
+ # @param payload [String] The raw request body as a string.
10
+ # @param timestamp [String] The timestamp from the Timestamp request header.
11
+ # @param signature [String] The signature from the Signature request header.
12
+ # @param secret [String] The webhook secret attached to the endpoint the event comes from.
13
+ # @return [Boolean] True if the signature is valid, false otherwise.
14
+ def self.verify(payload, signature, timestamp, secret)
15
+ return false if payload.blank? || signature.blank? || timestamp.blank? || secret.blank?
16
+
17
+ tolerance = Rails.configuration.outgoing_webhooks[:event_verification_tolerance_seconds]
18
+ # Check if the timestamp is too old
19
+ timestamp_int = timestamp.to_i
20
+ now = Time.now.to_i
21
+
22
+ if (now - timestamp_int).abs > tolerance
23
+ return false # Webhook is too old or timestamp is from the future
24
+ end
25
+
26
+ # Compute the expected signature
27
+ signature_payload = "#{timestamp}.#{payload}"
28
+ expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signature_payload)
29
+
30
+ # Compare signatures using constant-time comparison to prevent timing attacks
31
+ ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
32
+ end
33
+
34
+ # A Rails controller helper example to verify webhook requests.
35
+ #
36
+ # @param request [ActionDispatch::Request] The Rails request object.
37
+ # @param secret [String] The webhook secret shared with the sender.
38
+ # @return [Boolean] True if the signature is valid, false otherwise.
39
+ def self.verify_request(request, secret)
40
+ return false if request.blank? || secret.blank?
41
+
42
+ webhook_headers_namespace = Rails.configuration.outgoing_webhooks[:webhook_headers_namespace]
43
+ signature = request.headers["#{webhook_headers_namespace}-Signature"]
44
+ timestamp = request.headers["#{webhook_headers_namespace}-Timestamp"]
45
+ payload = request.raw_post
46
+
47
+ return false if signature.blank? || timestamp.blank?
48
+
49
+ verify(payload, signature, timestamp, secret)
50
+ end
51
+
52
+ # Algorithm to generate the signature.
53
+ #
54
+ # @payload [Hash] The payload to be encoded into a signature.
55
+ # @secret [String] The secret stored on each webhook endpoint.
56
+ def self.generate(payload, secret)
57
+ timestamp = Time.now.to_i.to_s
58
+ signature_payload = "#{timestamp}.#{payload.to_json}"
59
+ signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signature_payload)
60
+
61
+ {
62
+ signature: signature,
63
+ timestamp: timestamp
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,5 +1,5 @@
1
1
  module BulletTrain
2
2
  module OutgoingWebhooks
3
- VERSION = "1.24.0"
3
+ VERSION = "1.25.0"
4
4
  end
5
5
  end
@@ -1,5 +1,6 @@
1
1
  require "bullet_train/outgoing_webhooks/version"
2
2
  require "bullet_train/outgoing_webhooks/engine"
3
+ require "bullet_train/outgoing_webhooks/signature"
3
4
 
4
5
  module BulletTrain
5
6
  module OutgoingWebhooks
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bullet_train-outgoing_webhooks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.24.0
4
+ version: 1.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Culver
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webmock
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: rails
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -182,6 +196,7 @@ files:
182
196
  - db/migrate/20221231003438_remove_default_from_webhooks_outgoing_events.rb
183
197
  - lib/bullet_train/outgoing_webhooks.rb
184
198
  - lib/bullet_train/outgoing_webhooks/engine.rb
199
+ - lib/bullet_train/outgoing_webhooks/signature.rb
185
200
  - lib/bullet_train/outgoing_webhooks/version.rb
186
201
  - lib/tasks/bullet_train/outgoing_webhooks_tasks.rake
187
202
  homepage: https://github.com/bullet-train-co/bullet_train-core/tree/main/bullet_train-outgoing_webhooks