lib/stripe/webhook.rb



# 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
          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