mirror of
https://github.com/stripe/stripe-ruby.git
synced 2025-05-16 00:01:45 -04:00
* Support for APIs in the new API version 2024-09-30.acacia (#1458) * remove parseSnapshotEvent (#1463) * Bump version to 13.0.0 * Fixed API Version --------- Co-authored-by: Ramya Rao <100975018+ramya-stripe@users.noreply.github.com> Co-authored-by: Ramya Rao <ramya@stripe.com> Co-authored-by: Prathmesh Ranaut <prathmesh@stripe.com>
123 lines
4.9 KiB
Ruby
123 lines
4.9 KiB
Ruby
# 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, {}, nil, :v1)
|
|
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
|
|
formatted_timestamp = Time.at(timestamp).strftime("%F %T")
|
|
raise SignatureVerificationError.new(
|
|
"Timestamp outside the tolerance zone (#{formatted_timestamp})",
|
|
header, http_body: payload
|
|
)
|
|
end
|
|
|
|
true
|
|
end
|
|
end
|
|
end
|
|
end
|