Validating Mandrill webhook signature on Ruby on Rails
I'm writing this post partially because I couldn't find anything exactly like this and to keep a log for me, in case I have to deal with this once again.
Since Mandrill does not provide a way to authenticate their webhooks using, for example, a user/password combination, they came up with a smart way to validate their webhooks: they sign the webhook with a private key, generated during the webhook creation and you can use it to check webhook's authenticity. Mandrill says that this process is optional but, of course, it is always good to have one more layer of security in your application.
Setting up the webhook
Once you go to the Mandrill admin panel, you will provide the URL for your backend and Mandrill will then set up a webhook and generate a private key
When Mandrill starts sending events to your endpoint, the request will look like this
The signature is in the headers, accessible via X-Mandrill-Signature
Generate the signature locally
Mandrill documentation provides a comprehensive step by step on how to generate the signature and validate the webhook:
- Create a string with the webhook's URL, exactly as you entered it in Mandrill (including any query strings, if applicable). Mandrill always signs webhook requests with the exact URL you provided when you configured the webhook. A difference as small as including or removing a trailing slash will prevent the signature from validating.
- Sort the request's POST variables alphabetically by key.
- Append each POST variable's key and value to the URL string, with no delimiter.
- Hash the resulting string with HMAC-SHA1, using your webhook's authentication key to generate a binary signature.
- Base64 encode the binary signature
- Compare the binary signature that you generated to the signature provided in the X-Mandrill-Signature HTTP header.
There is even an example with PHP that can be easily translated to Ruby.
Creating a class to generate a signature
This is my attempt to translate the PHP code to Ruby
class MandrillNotary
HASH_ALGORITHM = 'sha1'
MANDRILL_WEBHOOK_KEY = 'MANDRILL_WEBHOOK_KEY'
def self.call(url, params)
new(url, params).call
end
def call
raw_data = url + params.sort.join
Base64.strict_encode64(
OpenSSL::HMAC.digest(HASH_ALGORITHM, mandrill_private_key, raw_data)
)
end
private
def mandrill_private_key
ENV[MANDRILL_WEBHOOK_KEY]
end
# private constructor and attr_reader omitted
end
This is class will generate a signature than can then be compared to X-Mandrill-Signature
header.
It took me a couple of hours to figure out exactly what url
and params
should be. I tried to send different things to this class and always got the wrong signature. The secret here is to understand how Mandrill parameters are sent and what you should use.
Mandrill documentation says that they currently are sending only one parameter: mandrill_events
with a bunch of events like this:
[{
"event": "send",
"msg": {
"ts": 1365109999,
"subject": "This an example webhook message",
"email": "example.webhook@mandrillapp.com",
"sender": "example.sender@mandrillapp.com",
"tags": ["webhook-example"],
"opens": [],
"clicks": [],
"state": "sent",
"metadata": {
"user_id": 111
},
"_id": "exampleaaaaaaaaaaaaaaaaaaaaaaaaa",
"_version": "exampleaaaaaaaaaaaaaaa"
},
"_id": "exampleaaaaaaaaaaaaaaaaaaaaaaaaa",
"ts": 1587734998
}, {
"event": "send",
"msg": {
"ts": 1365109999,
"subject": "This an example webhook message",
"email": "example.webhook@mandrillapp.com",
"sender": "example.sender@mandrillapp.com",
"tags": ["webhook-example"],
"opens": [],
"clicks": [],
"state": "sent",
"metadata": {
"user_id": 111
},
"_id": "exampleaaaaaaaaaaaaaaaaaaaaaaaaa1",
"_version": "exampleaaaaaaaaaaaaaaa"
},
"_id": "exampleaaaaaaaaaaaaaaaaaaaaaaaaa1",
"ts": 1587734998
}]
If you try to generate the signature using Rails's params
it will fail because Rails will parse all of the parameters to JSON but Mandrill does not use the JSON to generate the signature. Mandrill parameters are something like this:
{ "mandrill_events": "long_string_of_events" }
Yes, all events are sent as one String and Mandrill use this String to generate the signature.
To get the raw parameters, we need to access the request.request_parameters
, as follows:
MandrillNotary.call(request.url, request.request_parameters)
Putting it all together in the controller
# frozen_string_literal: true
class Callbacks::MandrillWebhooksController < Callbacks::BaseController
before_action :verify_request_signature
MANDRILL_SIGNATURE_HEAD = 'X-Mandrill-Signature'
def create
return head(:ok) if empty_request?
# do something usefull with the webhook here
end
private
def verify_request_signature
return if empty_request?
head :unprocessable_entity unless signed_request == mandrill_signature
end
def mandrill_signature
request.headers[MANDRILL_SIGNATURE_HEAD]
end
def signed_request
MandrillNotary.call(request.url, request.request_parameters)
end
def empty_request?
events.empty?
end
def events
@events ||= JSON.parse(params[:mandrill_events])
end
end
Empty requests
You might have noticed that we are ignoring empty requests. The reason so is because Mandrill will send a webhook with empty events to check if the endpoint is up and running. We can't validate the signature yet because the private key will only be generated after a successful response from this webhook and without it, we can't do anything.
Testing
I created the following RSpec to test signature generation
# frozen_string_literal: true
describe MandrillNotary do
describe '.call' do
subject(:sign_request) do
with_environment(mandrill_key) do
described_class.call(url, params)
end
end
context 'when validating the signature' do
let(:mandrill_key) { { 'MANDRILL_WEBHOOK_KEY' => 'your_private_key' } }
let(:expected_signature) { 'IonkvkzbSSmYpEZMp1C1BNCjIzw=' }
let(:url) { 'https://webhook_url' }
let(:params) do
{
'mandrill_events' => '[your_events_here]'
}
end
it 'generates the correct hash' do
expect(sign_request).to be_eql(expected_signature)
end
end
end
end
Notice that the events inside mandrill_events
are actually a String
Wrapping up
If you need to generate real data to test the validation, you can setup a webhook to a site like webhook.site and inspect the response, get real data and play with it.
Hope this helps