Skip to content
This repository was archived by the owner on Sep 12, 2024. It is now read-only.

Commit daaeeed

Browse files
author
Tim Rogers
authored
Merge pull request #24 from duffelhq/webhook-validation
Allow verifying the genuineness of webhook events with `WebhookEvent.genuine?`
2 parents 8062159 + c85613e commit daaeeed

File tree

5 files changed

+185
-0
lines changed

5 files changed

+185
-0
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,31 @@ If an error has been raised, you can call `#api_response` on the exception, whic
209209

210210
From the `APIResponse`, you can call `#headers`, `#status_code`, `#raw_body`, `#parsed_body`, `#meta` or `#request_id` to get key information from the response.
211211

212+
## Verifying webhooks
213+
214+
You can set up [webhooks](https://duffel.com/docs/guides/receiving-webhooks) with Duffel to receive notifications about events that happen in your Duffel account - for example, when an airline has a schedule change affecting one of your orders.
215+
216+
These webhook events are signed with a shared secret. This allows you to be sure that any webhook events are genuinely sent from Duffel when you receive them.
217+
218+
When you create a webhook, you'll set a secret. With that secret in mind, you can verify that a webhook is genuine like this:
219+
220+
```ruby
221+
# In Rails, you'd get this with `request.raw_post`.
222+
request_body = '{"created_at":"2022-01-08T18:44:56.129339Z","data":{"changes":{},"object":{}},"id":"eve_0000AFEsrBKZAcKgGtZCnQ","live_mode":false,"object":"order","type":"order.updated"}'
223+
# In Rails, you'd get this with `request.headers['X-Duffel-Signature']`.
224+
request_signature = "t=1641667496,v1=691f25ffb1f206c0fda5bb7b1a9d60fafe42c5f42819d44a06a7cfe09486f102"
225+
226+
# Note that this code doesn't require your access token - `DuffelAPI::WebhookEvent`
227+
# doesn't expect you to have a `Client` initialised
228+
if DuffelAPI::WebhookEvent.genuine?(
229+
request_body: request_body,
230+
request_signature: request_signature,
231+
webhook_secret: "a_secret"
232+
)
233+
puts "This is a real webhook from Duffel 🌟"
234+
else
235+
puts "This is a fake webhook! ☠️"
236+
end
237+
```
238+
212239

duffel_api.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
2727
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
2828
spec.require_paths = ["lib"]
2929

30+
spec.add_dependency "base16", "~> 0.0.2"
3031
spec.add_dependency "faraday", [">= 0.9.2", "< 2"]
3132

3233
# Uncomment to register a new dependency of your gem

lib/duffel_api.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@
5151
require_relative "duffel_api/list_response"
5252
require_relative "duffel_api/request"
5353
require_relative "duffel_api/response"
54+
require_relative "duffel_api/webhook_event"
5455

5556
module DuffelAPI; end

lib/duffel_api/webhook_event.rb

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
require "base16"
4+
require "openssl"
5+
6+
module DuffelAPI
7+
module WebhookEvent
8+
# An exception raised internally within the library - but not exposed - if the
9+
# webhook signature provided does not match the format of a valid signature
10+
class InvalidRequestSignatureError < StandardError; end
11+
12+
SIGNATURE_REGEXP = /\At=(.+),v1=(.+)\z/.freeze
13+
14+
SHA_256 = OpenSSL::Digest.new("sha256")
15+
16+
class << self
17+
# Checks if a webhook event you received was a genuine webhook event from Duffel by
18+
# checking that it was signed with your shared secret.
19+
#
20+
# Assuming that you've kept that secret secure and only shared it with Duffel,
21+
# this can give you confidence that a webhook event was genuinely sent by Duffel.
22+
#
23+
# @param request_body [String] The raw body of the received request
24+
# @param request_signature [String] The signature provided with the received
25+
# request, found in the `X-Duffel-Signature` request header
26+
# @param webhook_secret [String] The secret of the webhook, registered with Duffel
27+
# @return [Boolean] whether the webhook signature matches
28+
def genuine?(request_body:, request_signature:, webhook_secret:)
29+
parsed_signature = parse_signature!(request_signature)
30+
31+
calculated_hmac = calculate_hmac(
32+
payload: request_body,
33+
secret: webhook_secret,
34+
timestamp: parsed_signature[:timestamp],
35+
)
36+
37+
secure_compare(calculated_hmac, parsed_signature[:v1])
38+
rescue InvalidRequestSignatureError
39+
# If the signature doesn't even look like a valid one, then the webhook
40+
# event can't be genuine
41+
false
42+
end
43+
44+
private
45+
46+
# Calculates the signature for a request body in the same way that the Duffel API
47+
# does it
48+
#
49+
# @param secret [String]
50+
# @param payload [String]
51+
# @param timestamp [String]
52+
# @return [String]
53+
def calculate_hmac(secret:, payload:, timestamp:)
54+
signed_payload = %(#{timestamp}.#{payload})
55+
Base16.encode16(OpenSSL::HMAC.digest(SHA_256, secret,
56+
signed_payload)).strip.downcase
57+
end
58+
59+
# Parses a webhook signature and extracts the `v1` and `timestamp` values, if
60+
# available.
61+
#
62+
# @param signature [String] A webhook event signature received in a request
63+
# @return [Hash]
64+
# @raise InvalidRequestSignatureError when the signature isn't valid
65+
def parse_signature!(signature)
66+
matches = signature.match(SIGNATURE_REGEXP)
67+
68+
if matches
69+
{
70+
v1: matches[2],
71+
timestamp: matches[1],
72+
}
73+
else
74+
raise InvalidRequestSignatureError
75+
end
76+
end
77+
78+
# Taken from `Rack::Utils`
79+
# (<https://github.com/rack/rack/blob/03b4b9708f375db46ee214b219f709d08ed6eeb0/lib/rack/utils.rb#L371-L393>).
80+
#
81+
# Licensed under the MIT License
82+
# (<https://github.com/rack/rack/blob/master/MIT-LICENSE>).
83+
if defined?(OpenSSL.fixed_length_secure_compare)
84+
# Checks if two strings are equal, performing a constant time string comparison
85+
# resistant to timing attacks.
86+
#
87+
# @param a [String]
88+
# @param b [String]
89+
# @return [Boolean] whether the two strings are equal
90+
# rubocop:disable Naming/MethodParameterName
91+
def secure_compare(a, b)
92+
# rubocop:enable Naming/MethodParameterName
93+
return false unless a.bytesize == b.bytesize
94+
95+
OpenSSL.fixed_length_secure_compare(a, b)
96+
end
97+
else
98+
# Checks if two strings are equal, performing a constant time string comparison
99+
# resistant to timing attacks.
100+
#
101+
# @param [String] a
102+
# @param [String] b
103+
# @return [Boolean] whether the two strings are equal
104+
# rubocop:disable Naming/MethodParameterName
105+
def secure_compare(a, b)
106+
# rubocop:enable Naming/MethodParameterName
107+
return false unless a.bytesize == b.bytesize
108+
109+
l = a.unpack("C*")
110+
111+
r = 0
112+
i = -1
113+
b.each_byte { |v| r |= v ^ l[i += 1] }
114+
r.zero?
115+
end
116+
end
117+
end
118+
end
119+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
describe DuffelAPI::WebhookEvent do
6+
describe ".genuine?" do
7+
subject(:is_genuine) do
8+
described_class.genuine?(request_body: request_body,
9+
request_signature: request_signature,
10+
webhook_secret: webhook_secret)
11+
end
12+
13+
let(:request_body) do
14+
'{"created_at":"2022-01-08T18:44:56.129339Z","data":{"changes":{},"object":{}},' \
15+
'"id":"eve_0000AFEsrBKZAcKgGtZCnQ","live_mode":false,"object":"order","type":"' \
16+
'order.updated"}'
17+
end
18+
let(:webhook_secret) { "a_secret" }
19+
let(:request_signature) do
20+
"t=1641667496,v1=691f25ffb1f206c0fda5bb7b1a9d60fafe42c5f42819d44a06a7cfe09486f102"
21+
end
22+
23+
it { is_expected.to be(true) }
24+
25+
context "when the signature doesn't look like a real signature at all" do
26+
let(:request_signature) { "nah" }
27+
28+
it { is_expected.to be(false) }
29+
end
30+
31+
context "when the signature doesn't match the body" do
32+
let(:request_body) { "foo" }
33+
34+
it { is_expected.to be(false) }
35+
end
36+
end
37+
end

0 commit comments

Comments
 (0)