Skip to content

Commit 0d691ef

Browse files
committed
Add webhook signature verification example coming from a Bullet Train app
First we super-scaffolded a new incoming webhooks controller `rails g super_scaffold:incoming_webhooks BulletTrain`. Then, the `verify` and `verify_request` methods were taken from the Bullet Train repo to verify the signature in the controller. If a signature is not verified, we don't save a new Webhook record to the DB.
1 parent a4ab55e commit 0d691ef

File tree

5 files changed

+155
-0
lines changed

5 files changed

+155
-0
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
class Webhooks::Incoming::BulletTrainWebhooksController < Webhooks::Incoming::WebhooksController
2+
before_action :verify_authenticity, only: [:create]
3+
4+
def create
5+
Webhooks::Incoming::BulletTrainWebhook.create(data: JSON.parse(request.raw_post)).process_async
6+
render json: {status: "OK"}, status: :created
7+
end
8+
9+
private
10+
11+
def verify_authenticity
12+
unless Webhooks::Incoming::Signature.verify_request(request, ENV["BULLET_TRAIN_WEBHOOK_SECRET"])
13+
render json: {error: "Signature verification failed"}, status: :forbidden
14+
end
15+
end
16+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class Webhooks::Incoming::BulletTrainWebhook < BulletTrain::Configuration.incoming_webhooks_parent_class_name.constantize
2+
include Webhooks::Incoming::Webhook
3+
4+
def verify_authenticity
5+
# Signature is verified in the controller.
6+
true
7+
end
8+
9+
def process
10+
end
11+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# A usage example of using Bullet Train's webhook signature verification
2+
# methods.
3+
class Webhooks::Incoming::Signature
4+
# Verifies the authenticity of a webhook request.
5+
#
6+
# @param payload [String] The raw request body as a string.
7+
# @param timestamp [String] The timestamp from the Timestamp request header.
8+
# @param signature [String] The signature from the Signature request header.
9+
# @param secret [String] The webhook secret attached to the endpoint the event comes from.
10+
# @return [Boolean] True if the signature is valid, false otherwise.
11+
def self.verify(payload, signature, timestamp, secret)
12+
return false if payload.blank? || signature.blank? || timestamp.blank? || secret.blank?
13+
14+
tolerance_seconds = 300
15+
# Check if the timestamp is too old
16+
timestamp_int = timestamp.to_i
17+
now = Time.now.to_i
18+
19+
if (now - timestamp_int).abs > tolerance_seconds
20+
return false # Webhook is too old or timestamp is from the future
21+
end
22+
23+
# Compute the expected signature
24+
signature_payload = "#{timestamp}.#{payload}"
25+
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signature_payload)
26+
27+
# Compare signatures using constant-time comparison to prevent timing attacks
28+
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
29+
end
30+
31+
# A Rails controller helper example to verify webhook requests.
32+
#
33+
# @param request [ActionDispatch::Request] The Rails request object.
34+
# @param secret [String] The webhook secret shared with the sender.
35+
# @return [Boolean] True if the signature is valid, false otherwise.
36+
def self.verify_request(request, secret)
37+
return false if request.blank? || secret.blank?
38+
39+
signature = request.headers["X-Webhook-Bullet-Train-Signature"]
40+
timestamp = request.headers["X-Webhook-Bullet-Train-Timestamp"]
41+
payload = request.raw_post
42+
43+
return false if signature.blank? || timestamp.blank?
44+
45+
verify(payload, signature, timestamp, secret)
46+
end
47+
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
namespace :webhooks do
2929
namespace :incoming do
30+
resources :bullet_train_webhooks
3031
resources :click_funnels_webhooks, only: [:create]
3132
namespace :oauth do
3233
# 🚅 super scaffolding will insert new oauth provider webhooks above this line.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
require "test_helper"
2+
3+
class Webhooks::Incoming::BulletTrainWebhooksControllerTest < ActionDispatch::IntegrationTest
4+
setup do
5+
@user = FactoryBot.create(:onboarded_user)
6+
@membership = @user.memberships.first
7+
@team = @user.current_team
8+
9+
@original_webhook_secret = ENV["BULLET_TRAIN_WEBHOOK_SECRET"]
10+
ENV["BULLET_TRAIN_WEBHOOK_SECRET"] = "test_webhook_secret"
11+
end
12+
13+
teardown do
14+
ENV["BULLET_TRAIN_WEBHOOK_SECRET"] = @original_webhook_secret
15+
end
16+
17+
test "should process incoming webhook with valid signature" do
18+
json_payload = sample_webhook_payload
19+
timestamp = Time.now.to_i.to_s
20+
signature = generate_signature(json_payload, timestamp)
21+
22+
post "/webhooks/incoming/bullet_train_webhooks",
23+
params: json_payload,
24+
headers: {
25+
"X-Webhook-Bullet-Train-Signature" => signature,
26+
"X-Webhook-Bullet-Train-Timestamp" => timestamp,
27+
"Content-Type" => "application/json"
28+
}
29+
30+
assert_response :created
31+
assert_equal({"status" => "OK"}, response.parsed_body)
32+
33+
webhook = Webhooks::Incoming::BulletTrainWebhook.first
34+
assert_equal JSON.parse(json_payload), webhook.data
35+
end
36+
37+
test "should reject incoming webhook with invalid signature" do
38+
json_payload = sample_webhook_payload
39+
timestamp = Time.now.to_i.to_s
40+
invalid_signature = "invalid_signature_here"
41+
42+
post "/webhooks/incoming/bullet_train_webhooks",
43+
params: json_payload,
44+
headers: {
45+
"X-Webhook-Bullet-Train-Signature" => invalid_signature,
46+
"X-Webhook-Bullet-Train-Timestamp" => timestamp,
47+
"Content-Type" => "application/json"
48+
}
49+
50+
assert_response :forbidden
51+
assert_equal({"error" => "Signature verification failed"}, response.parsed_body)
52+
53+
# Verify no webhook was created
54+
assert_equal 0, Webhooks::Incoming::BulletTrainWebhook.count
55+
end
56+
57+
private
58+
59+
def sample_webhook_payload
60+
'{
61+
"data": {
62+
"id": 4,
63+
"name": "aaa",
64+
"team_id": 3,
65+
"created_at": "2025-06-05T09:27:41.939Z",
66+
"updated_at": "2025-06-05T09:27:41.939Z",
67+
"description": ""
68+
},
69+
"event_id": "9abb8b6e49e5e7ffab7212bce01a7f97",
70+
"event_type": "scaffolding/absolutely_abstract/creative_concept.created",
71+
"subject_id": 4,
72+
"subject_type": "Scaffolding::AbsolutelyAbstract::CreativeConcept"
73+
}'
74+
end
75+
76+
def generate_signature(payload, timestamp)
77+
signature_payload = "#{timestamp}.#{payload}"
78+
OpenSSL::HMAC.hexdigest("SHA256", ENV["BULLET_TRAIN_WEBHOOK_SECRET"], signature_payload)
79+
end
80+
end

0 commit comments

Comments
 (0)