Verifying Webhooks
Because of the way webhooks work, attackers can impersonate services by simply sending a fake webhook to an endpoint. It’s just an HTTP POST from an unknown source. This is a potential security hole for many applications, or at the very least, a source of problems. In order to prevent it, every webhook and its metadata are signed with a unique key for each endpoint. This signature can then be used to verify the webhook, and only process it if it’s valid.
Another potential security hole is what’s called replay attacks. A replay attack is when an attacker intercepts a valid payload (including the signature), and re-transmits it to your endpoint. This payload will pass signature validation, and will therefore be acted upon. To mitigate this attack, a timestamp for when the webhook attempt occurred is included. Webhooks with a timestamp that are more than five minutes away (past or future) from the current time are automatically rejected. This requires your server’s clock to be synchronised and accurate, and it’s recommended that you use NTP to achieve this.
Here is a short example in Ruby of how to verify signatures to get you started.
require 'rack'
require 'rack/handler/puma'
require 'json'
require 'logger'
require 'openssl'
require 'base64'
# In the portal, you can retrieve the secret for your endpoint once it's created
secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
class VerifyWebhook
def initialize(secret)
@secret = secret
end
def call(env)
request = Rack::Request.new(env)
body = request.body.read
if valid_signature?(request, body)
# Process the webhook as you'd like
return [200, {'content-type' => 'application/json'}, [{ message: "Webhook Received" }.to_json]]
else
# Handle and return the error accordingly
return [403, {'content-type' => 'text/plain'}, ['Forbidden']]
end
end
private
def valid_signature?(request, body)
webhook_id = request.env['HTTP_SVIX_ID']
webhook_timestamp = request.env['HTTP_SVIX_TIMESTAMP']
received_signatures = request.env['HTTP_SVIX_SIGNATURE'].split(' ')
signed_content = "#{webhook_id}.#{webhook_timestamp}.#{body}"
expected_signature = compute_signature(signed_content)
received_signatures.any? do |sig|
version, signature = sig.split(',')
secure_compare(signature, expected_signature)
end
end
def compute_signature(data)
# Webhooks are signed using an HMAC with SHA-256
key = Base64.decode64(@secret.split('_')[1])
digest = OpenSSL::Digest.new('sha256')
hmac = OpenSSL::HMAC.digest(digest, key, data)
Base64.strict_encode64(hmac)
end
def secure_compare(a, b)
# This method is used to mitigate timing attacks
return false unless a.bytesize == b.bytesize
l = a.unpack "C#{a.bytesize}"
res = 0
b.each_byte { |byte| res |= byte ^ l.shift }
res.zero?
end
end
Rack::Handler::Puma.run VerifyWebhook.new(secret), Port: 4567
Firewalls (IP blocking)
In case your webhook receiving endpoint is behind a firewall or NAT, you may need to allow traffic from these IP addresses. This is the US list of IP addresses that webhooks may originate from.
44.228.126.217
50.112.21.217
52.24.126.164
54.148.139.208
2600:1f24:64:8000::/52