Module: Cadenya::Webhooks
- Defined in:
- lib/cadenya/webhooks.rb
Class Method Summary collapse
-
.verify(secret, payload, headers, tolerance_seconds: 300) ⇒ Object
Raises WebhookVerificationError unless the payload is authentic.
Class Method Details
.verify(secret, payload, headers, tolerance_seconds: 300) ⇒ Object
Raises WebhookVerificationError unless the payload is authentic.
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 |
# File 'lib/cadenya/webhooks.rb', line 18 def verify(secret, payload, headers, tolerance_seconds: 300) raise WebhookVerificationError, "webhook secret must be a String" unless secret.is_a?(String) normalized = headers.to_h { |k, v| [k.to_s.downcase, v.is_a?(Array) ? v.first : v] } msg_id = normalized["webhook-id"] = normalized["webhook-timestamp"] signatures = normalized["webhook-signature"] if msg_id.to_s.empty? || .to_s.empty? || signatures.to_s.empty? raise WebhookVerificationError, "missing webhook-id, webhook-timestamp, or webhook-signature header" end # Tolerance must be finite and non-negative: an infinite tolerance # would silently disable replay protection. unless tolerance_seconds.is_a?(Numeric) && tolerance_seconds.finite? && tolerance_seconds >= 0 raise WebhookVerificationError, "tolerance_seconds must be a finite non-negative number" end # The spec calls webhook-timestamp an integer Unix timestamp. Integer() # accepts "+1", whitespace, and underscores; require the wire grammar. unless /\A[0-9]+\z/.match?(.to_s) raise WebhookVerificationError, "webhook-timestamp is not an integer" end sent = Integer() raise WebhookVerificationError, "webhook-timestamp is out of range" if sent > 2**63 if (Time.now.to_f - sent).abs > tolerance_seconds raise WebhookVerificationError, "webhook-timestamp outside tolerance" end key = begin Base64.strict_decode64(secret.delete_prefix("whsec_")) rescue ArgumentError raise WebhookVerificationError, "webhook secret is not valid base64" end # Standard Webhooks symmetric keys are 24–64 bytes. A shorter key — # especially the zero-byte key from a bare "whsec_" — must never verify. unless key.bytesize.between?(24, 64) raise WebhookVerificationError, "decoded webhook secret must be 24-64 bytes, got #{key.bytesize}" end data = "#{msg_id}.#{}.#{payload}" expected = OpenSSL::HMAC.digest("SHA256", key, data) signatures.split(" ").each do |candidate| version, _, signature = candidate.partition(",") next unless version == "v1" && !signature.empty? provided = begin Base64.strict_decode64(signature) rescue ArgumentError next end return nil if provided.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(provided, expected) end raise WebhookVerificationError, "no matching v1 signature" end |