Module: VisionAPI::Webhook

Defined in:
lib/vision_api/webhook.rb

Overview

Webhook signature verification.

The delivery carries X-Vision-Signature: t=,v1=, where the HMAC is HMAC-SHA256(secret, "{t}.{raw body}"). Two details are load-bearing:

  1. 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.
  2. 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

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

Parameters:

  • payload (String)

    the bytes exactly as received, untouched

  • signature (String, nil)

    the X-Vision-Signature header

  • secret (String)
  • tolerance (Integer) (defaults to: DEFAULT_TOLERANCE)

    how far the timestamp may be from now, in seconds. This is what stops a captured delivery being replayed later. Pass 0 to skip the check.

Returns:

  • (Hash)

    the parsed event

Raises:



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?

  timestamp = nil
  candidates = []
  signature.split(",").each do |part|
    key, _, value = part.strip.partition("=")
    case key
    when "t" then timestamp = value
    when "v1" then candidates << value unless value.empty?
    end
  end

  if timestamp.nil? || timestamp.empty? || candidates.empty?
    raise WebhookSignatureError, "Malformed X-Vision-Signature header."
  end

  sent_at = Integer(timestamp, 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, "#{timestamp}.".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