# 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".freeze 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 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 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