• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stripe / stripe-ruby / #5867

18 Apr 2024 09:24PM UTC coverage: 92.724% (-4.8%) from 97.485%
#5867

push

github

ramya-stripe
Bump version to 11.2.0

10067 of 10857 relevant lines covered (92.72%)

258.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.25
/lib/stripe/webhook.rb
1
# frozen_string_literal: true
2

3
module Stripe
1✔
4
  module Webhook
1✔
5
    DEFAULT_TOLERANCE = 300
1✔
6

7
    # Initializes an Event object from a JSON payload.
8
    #
9
    # This may raise JSON::ParserError if the payload is not valid JSON, or
10
    # SignatureVerificationError if the signature verification fails.
11
    def self.construct_event(payload, sig_header, secret,
1✔
12
                             tolerance: DEFAULT_TOLERANCE)
13
      Signature.verify_header(payload, sig_header, secret, tolerance: tolerance)
3✔
14

15
      # It's a good idea to parse the payload only after verifying it. We use
16
      # `symbolize_names` so it would otherwise be technically possible to
17
      # flood a target's memory if they were on an older version of Ruby that
18
      # doesn't GC symbols. It also decreases the likelihood that we receive a
19
      # bad payload that fails to parse and throws an exception.
20
      data = JSON.parse(payload, symbolize_names: true)
2✔
21
      Event.construct_from(data)
1✔
22
    end
23

24
    module Signature
1✔
25
      EXPECTED_SCHEME = "v1"
1✔
26

27
      # Computes a webhook signature given a time (probably the current time),
28
      # a payload, and a signing secret.
29
      def self.compute_signature(timestamp, payload, secret)
1✔
30
        raise ArgumentError, "timestamp should be an instance of Time" \
×
31
          unless timestamp.is_a?(Time)
16✔
32
        raise ArgumentError, "payload should be a string" \
×
33
          unless payload.is_a?(String)
16✔
34
        raise ArgumentError, "secret should be a string" \
×
35
          unless secret.is_a?(String)
16✔
36

37
        timestamped_payload = "#{timestamp.to_i}.#{payload}"
17✔
38
        OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret,
17✔
39
                                timestamped_payload)
40
      end
41

42
      # Generates a value that would be added to a `Stripe-Signature` for a
43
      # given webhook payload.
44
      #
45
      # Note that this isn't needed to verify webhooks in any way, and is
46
      # mainly here for use in test cases (those that are both within this
47
      # project and without).
48
      def self.generate_header(timestamp, signature, scheme: EXPECTED_SCHEME)
1✔
49
        raise ArgumentError, "timestamp should be an instance of Time" \
×
50
          unless timestamp.is_a?(Time)
9✔
51
        raise ArgumentError, "signature should be a string" \
×
52
          unless signature.is_a?(String)
9✔
53
        raise ArgumentError, "scheme should be a string" \
×
54
          unless scheme.is_a?(String)
9✔
55

56
        "t=#{timestamp.to_i},#{scheme}=#{signature}"
10✔
57
      end
58

59
      # Extracts the timestamp and the signature(s) with the desired scheme
60
      # from the header
61
      def self.get_timestamp_and_signatures(header, scheme)
1✔
62
        list_items = header.split(/,\s*/).map { |i| i.split("=", 2) }
32✔
63
        timestamp = Integer(list_items.select { |i| i[0] == "t" }[0][1])
32✔
64
        signatures = list_items.select { |i| i[0] == scheme }.map { |i| i[1] }
37✔
65
        [Time.at(timestamp), signatures]
9✔
66
      end
67
      private_class_method :get_timestamp_and_signatures
1✔
68

69
      # Verifies the signature header for a given payload.
70
      #
71
      # Raises a SignatureVerificationError in the following cases:
72
      # - the header does not match the expected format
73
      # - no signatures found with the expected scheme
74
      # - no signatures matching the expected signature
75
      # - a tolerance is provided and the timestamp is not within the
76
      #   tolerance
77
      #
78
      # Returns true otherwise
79
      def self.verify_header(payload, header, secret, tolerance: nil)
1✔
80
        begin
×
81
          timestamp, signatures =
11✔
82
            get_timestamp_and_signatures(header, EXPECTED_SCHEME)
10✔
83

84
        # TODO: Try to knock over this blanket rescue as it can unintentionally
85
        # swallow many valid errors. Instead, try to validate an incoming
86
        # header one piece at a time, and error with a known exception class if
87
        # any part is found to be invalid. Rescue that class here.
88
        rescue StandardError
89
          raise SignatureVerificationError.new(
2✔
90
            "Unable to extract timestamp and signatures from header",
91
            header, http_body: payload
92
          )
93
        end
94

95
        if signatures.empty?
9✔
96
          raise SignatureVerificationError.new(
1✔
97
            "No signatures found with expected scheme #{EXPECTED_SCHEME}",
×
98
            header, http_body: payload
99
          )
100
        end
101

102
        expected_sig = compute_signature(timestamp, payload, secret)
8✔
103
        unless signatures.any? { |s| Util.secure_compare(expected_sig, s) }
16✔
104
          raise SignatureVerificationError.new(
1✔
105
            "No signatures found matching the expected signature for payload",
106
            header, http_body: payload
107
          )
108
        end
109

110
        if tolerance && timestamp < Time.now - tolerance
7✔
111
          formatted_timestamp = Time.at(timestamp).strftime("%F %T")
1✔
112
          raise SignatureVerificationError.new(
1✔
113
            "Timestamp outside the tolerance zone (#{formatted_timestamp})",
×
114
            header, http_body: payload
115
          )
116
        end
117

118
        true
6✔
119
      end
120
    end
121
  end
122
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc