Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for deserializing webhook events and verifying signatures #537

Merged
merged 1 commit into from Apr 28, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/stripe.rb
Expand Up @@ -28,6 +28,7 @@
require 'stripe/list_object'
require 'stripe/api_resource'
require 'stripe/singleton_api_resource'
require 'stripe/webhook'

# Named API resources
require 'stripe/account'
Expand Down
11 changes: 11 additions & 0 deletions lib/stripe/errors.rb
Expand Up @@ -89,4 +89,15 @@ class PermissionError < StripeError
# 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
12 changes: 12 additions & 0 deletions lib/stripe/util.rb
Expand Up @@ -256,5 +256,17 @@ def self.check_array_of_maps_start_keys!(arr)
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 changes: 79 additions & 0 deletions lib/stripe/webhook.rb
@@ -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
92 changes: 92 additions & 0 deletions test/stripe/webhook_test.rb
@@ -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