/
webhook.rb
121 lines (106 loc) · 4.8 KB
/
webhook.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# frozen_string_literal: true
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)
Signature.verify_header(payload, sig_header, secret, tolerance: tolerance)
# It's a good idea to parse the payload only after verifying it. We use
# `symbolize_names` so it would otherwise be technically possible to
# flood a target's memory if they were on an older version of Ruby that
# doesn't GC symbols. It also decreases the likelihood that we receive a
# bad payload that fails to parse and throws an exception.
data = JSON.parse(payload, symbolize_names: true)
Event.construct_from(data)
end
module Signature
EXPECTED_SCHEME = "v1"
# Computes a webhook signature given a time (probably the current time),
# a payload, and a signing secret.
def self.compute_signature(timestamp, payload, secret)
raise ArgumentError, "timestamp should be an instance of Time" \
unless timestamp.is_a?(Time)
raise ArgumentError, "payload should be a string" \
unless payload.is_a?(String)
raise ArgumentError, "secret should be a string" \
unless secret.is_a?(String)
timestamped_payload = "#{timestamp.to_i}.#{payload}"
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret,
timestamped_payload)
end
# Generates a value that would be added to a `Stripe-Signature` for a
# given webhook payload.
#
# Note that this isn't needed to verify webhooks in any way, and is
# mainly here for use in test cases (those that are both within this
# project and without).
def self.generate_header(timestamp, signature, scheme: EXPECTED_SCHEME)
raise ArgumentError, "timestamp should be an instance of Time" \
unless timestamp.is_a?(Time)
raise ArgumentError, "signature should be a string" \
unless signature.is_a?(String)
raise ArgumentError, "scheme should be a string" \
unless scheme.is_a?(String)
"t=#{timestamp.to_i},#{scheme}=#{signature}"
end
# 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] }
[Time.at(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)
# TODO: Try to knock over this blanket rescue as it can unintentionally
# swallow many valid errors. Instead, try to validate an incoming
# header one piece at a time, and error with a known exception class if
# any part is found to be invalid. Rescue that class here.
rescue StandardError
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
expected_sig = compute_signature(timestamp, 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 - tolerance
raise SignatureVerificationError.new(
"Timestamp outside the tolerance zone (#{Time.at(timestamp)})",
header, http_body: payload
)
end
true
end
end
end
end