Module: VisionAPI::Webhook
- Defined in:
- lib/vision_api/webhook.rb
Overview
Webhook signature verification.
The delivery carries X-Vision-Signature: t=
- The HMAC covers the exact bytes received. Verify before parsing, and never re-serialize the JSON first — a re-encode changes key order and whitespace, and the signature stops matching for reasons that look like a bug in this library.
- After a secret rotation the header carries several
v1=parts, one per valid secret, for a 24-hour grace period. Accept the delivery if any of them matches.
Constant Summary collapse
- DEFAULT_TOLERANCE =
300
Class Method Summary collapse
- .secure_compare(a, b) ⇒ Object private
-
.verify(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: nil) ⇒ Hash
Verifies a delivery and returns its parsed payload.
Class Method Details
.secure_compare(a, b) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
88 89 90 91 92 |
# File 'lib/vision_api/webhook.rb', line 88 def secure_compare(a, b) return false unless a.bytesize == b.bytesize OpenSSL.secure_compare(a, b) end |
.verify(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: nil) ⇒ Hash
Verifies a delivery and returns its parsed payload.
# Rails: config.middleware handles the raw body for you via request.raw_post
def vision_hook
event = VisionAPI::Webhook.verify(
request.raw_post, request.headers["X-Vision-Signature"], ENV["VISION_WEBHOOK_SECRET"]
)
VisionResultJob.perform_later(event)
head :accepted # any 2xx is success — ack fast, work after
rescue VisionAPI::WebhookSignatureError
head :bad_request # never parse an unverified body
end
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 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/vision_api/webhook.rb', line 43 def verify(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: nil) raise WebhookSignatureError, "No signing secret provided." if secret.nil? || secret.empty? raise WebhookSignatureError, "Missing X-Vision-Signature header." if signature.nil? || signature.empty? = nil candidates = [] signature.split(",").each do |part| key, _, value = part.strip.partition("=") case key when "t" then = value when "v1" then candidates << value unless value.empty? end end if .nil? || .empty? || candidates.empty? raise WebhookSignatureError, "Malformed X-Vision-Signature header." end sent_at = Integer(, exception: false) raise WebhookSignatureError, "Malformed timestamp in X-Vision-Signature." if sent_at.nil? if tolerance.positive? drift = ((now || Time.now).to_i - sent_at).abs if drift > tolerance raise WebhookSignatureError, "Delivery timestamp is #{drift}s away from now, outside the #{tolerance}s tolerance." end end body = payload.to_s.b expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{}.".b + body) # Rotation sends one v1= per valid secret. secure_compare keeps each comparison # constant-time; checking them all rather than stopping at the first match is the point. matched = candidates.map { |candidate| secure_compare(expected, candidate) }.any? raise WebhookSignatureError, "Signature does not match the request body." unless matched begin JSON.parse(body) rescue JSON::ParserError raise WebhookSignatureError, "Delivery body is not valid JSON." end end |