Support for deserializing webhook events and verifying signatures

This commit is contained in:
Olivier Bellone 2017-03-21 17:58:25 -07:00
parent 5f18d3d18c
commit 0376e242d9
5 changed files with 195 additions and 0 deletions

View File

@ -28,6 +28,7 @@ require 'stripe/stripe_response'
require 'stripe/list_object'
require 'stripe/api_resource'
require 'stripe/singleton_api_resource'
require 'stripe/webhook'
# Named API resources
require 'stripe/account'

View File

@ -89,4 +89,15 @@ module Stripe
# back off on request rate.
class RateLimitError < StripeError
end
# SignatureVerificationError is raised when the signature verification for a
# webhook fails
class SignatureVerificationError < StripeError
attr_accessor :sig_header
def initialize(message, sig_header, http_body: nil)
super(message, http_body: http_body)
@sig_header = sig_header
end
end
end

View File

@ -256,5 +256,17 @@ module Stripe
end
end
end
# Constant time string comparison to prevent timing attacks
# Code borrowed from ActiveSupport
def self.secure_compare(a, b)
return false unless a.bytesize == b.bytesize
l = a.unpack "C#{a.bytesize}"
res = 0
b.each_byte { |byte| res |= byte ^ l.shift }
res == 0
end
end
end

79
lib/stripe/webhook.rb Normal file
View File

@ -0,0 +1,79 @@
module Stripe
module Webhook
DEFAULT_TOLERANCE = 300
# Initializes an Event object from a JSON payload.
#
# This may raise JSON::ParserError if the payload is not valid JSON, or
# SignatureVerificationError if the signature verification fails.
def self.construct_event(payload, sig_header, secret, tolerance: DEFAULT_TOLERANCE)
data = JSON.parse(payload, symbolize_names: true)
event = Event.construct_from(data)
Signature.verify_header(payload, sig_header, secret, tolerance: tolerance)
event
end
module Signature
EXPECTED_SCHEME = 'v1'
def self.compute_signature(payload, secret)
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload)
end
private_class_method :compute_signature
# Extracts the timestamp and the signature(s) with the desired scheme
# from the header
def self.get_timestamp_and_signatures(header, scheme)
list_items = header.split(/,\s*/).map { |i| i.split('=', 2) }
timestamp = Integer(list_items.select { |i| i[0] == 't' }[0][1])
signatures = list_items.select { |i| i[0] == scheme }.map { |i| i[1] }
[timestamp, signatures]
end
private_class_method :get_timestamp_and_signatures
# Verifies the signature header for a given payload.
#
# Raises a SignatureVerificationError in the following cases:
# - the header does not match the expected format
# - no signatures found with the expected scheme
# - no signatures matching the expected signature
# - a tolerance is provided and the timestamp is not within the
# tolerance
#
# Returns true otherwise
def self.verify_header(payload, header, secret, tolerance: nil)
begin
timestamp, signatures = get_timestamp_and_signatures(header, EXPECTED_SCHEME)
rescue
raise SignatureVerificationError.new(
"Unable to extract timestamp and signatures from header",
header, http_body: payload)
end
if signatures.empty?
raise SignatureVerificationError.new(
"No signatures found with expected scheme #{EXPECTED_SCHEME}",
header, http_body: payload)
end
signed_payload = "#{timestamp}.#{payload}"
expected_sig = compute_signature(signed_payload, secret)
unless signatures.any? {|s| Util.secure_compare(expected_sig, s)}
raise SignatureVerificationError.new(
"No signatures found matching the expected signature for payload",
header, http_body: payload)
end
if tolerance && timestamp < Time.now.to_f - tolerance
raise SignatureVerificationError.new(
"Timestamp outside the tolerance zone (#{Time.at(timestamp)})",
header, http_body: payload)
end
true
end
end
end
end

View File

@ -0,0 +1,92 @@
require File.expand_path('../../test_helper', __FILE__)
module Stripe
class WebhookTest < Test::Unit::TestCase
EVENT_PAYLOAD = '''{
"id": "evt_test_webhook",
"object": "event"
}'''
SECRET = 'whsec_test_secret'
def generate_header(opts={})
opts[:timestamp] ||= Time.now.to_i
opts[:payload] ||= EVENT_PAYLOAD
opts[:secret] ||= SECRET
opts[:scheme] ||= Stripe::Webhook::Signature::EXPECTED_SCHEME
opts[:signature] ||= Stripe::Webhook::Signature.send(:compute_signature, "#{opts[:timestamp]}.#{opts[:payload]}", opts[:secret])
"t=#{opts[:timestamp]},#{opts[:scheme]}=#{opts[:signature]}"
end
context ".construct_event" do
should "return an Event instance from a valid JSON payload and valid signature header" do
header = generate_header
event = Stripe::Webhook.construct_event(EVENT_PAYLOAD, header, SECRET)
assert event.kind_of?(Stripe::Event)
end
should "raise a JSON::ParserError from an invalid JSON payload" do
assert_raises JSON::ParserError do
payload = 'this is not valid JSON'
header = generate_header(payload: payload)
Stripe::Webhook.construct_event(payload, header, SECRET)
end
end
should "raise a SignatureVerificationError from a valid JSON payload and an invalid signature header" do
header = 'bad_header'
assert_raises Stripe::SignatureVerificationError do
Stripe::Webhook.construct_event(EVENT_PAYLOAD, header, SECRET)
end
end
end
context ".verify_signature_header" do
should "raise a SignatureVerificationError when the header does not have the expected format" do
header = 'i\'m not even a real signature header'
e = assert_raises(Stripe::SignatureVerificationError) do
Stripe::Webhook::Signature.verify_header(EVENT_PAYLOAD, header, 'secret')
end
assert_match("Unable to extract timestamp and signatures from header", e.message)
end
should "raise a SignatureVerificationError when there are no signatures with the expected scheme" do
header = generate_header(scheme: 'v0')
e = assert_raises(Stripe::SignatureVerificationError) do
Stripe::Webhook::Signature.verify_header(EVENT_PAYLOAD, header, 'secret')
end
assert_match("No signatures found with expected scheme", e.message)
end
should "raise a SignatureVerificationError when there are no valid signatures for the payload" do
header = generate_header(signature: 'bad_signature')
e = assert_raises(Stripe::SignatureVerificationError) do
Stripe::Webhook::Signature.verify_header(EVENT_PAYLOAD, header, 'secret')
end
assert_match("No signatures found matching the expected signature for payload", e.message)
end
should "raise a SignatureVerificationError when the timestamp is not within the tolerance" do
header = generate_header(timestamp: Time.now.to_i - 15)
e = assert_raises(Stripe::SignatureVerificationError) do
Stripe::Webhook::Signature.verify_header(EVENT_PAYLOAD, header, SECRET, tolerance: 10)
end
assert_match("Timestamp outside the tolerance zone", e.message)
end
should "return true when the header contains a valid signature and the timestamp is within the tolerance" do
header = generate_header
assert(Stripe::Webhook::Signature.verify_header(EVENT_PAYLOAD, header, SECRET, tolerance: 10))
end
should "return true when the header contains at least one valid signature" do
header = generate_header + ",v1=bad_signature"
assert(Stripe::Webhook::Signature.verify_header(EVENT_PAYLOAD, header, SECRET, tolerance: 10))
end
should "return true when the header contains a valid signature and the timestamp is off but no tolerance is provided" do
header = generate_header(timestamp: 12345)
assert(Stripe::Webhook::Signature.verify_header(EVENT_PAYLOAD, header, SECRET))
end
end
end
end