Module: RelayGrid::Webhooks

Defined in:
lib/relaygrid/webhooks.rb

Overview

Verification for delivery-status webhooks -- the piece you drop into your receiving controller.

class RelayGridWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token

def create
  event = RelayGrid::Webhooks.construct_event(
    request.raw_post,
    request.headers["X-RelayGrid-Signature"],
    ENV.fetch("RELAYGRID_WEBHOOK_SECRET")
  )

  MyDeliveryTracker.handle(event)
  head :ok
rescue RelayGrid::SignatureVerificationError
  head :bad_request
end
end

Two things are checked, and both matter. The HMAC proves the body came from RelayGrid and was not altered; the timestamp -- which is signed with the body, not merely sent beside it -- bounds how long a captured request stays replayable. Verify before you parse: it is the raw bytes that were signed, so re-serializing the JSON first will not verify.

Constant Summary collapse

SIGNATURE_VERSION =
"v1"
DIGEST =
"SHA256"
DEFAULT_TOLERANCE =

How far a webhook's timestamp may be from your clock. Five minutes matches Stripe's default: wide enough for ordinary clock skew and a slow retry, narrow enough that a captured request stops working quickly.

300

Class Method Summary collapse

Class Method Details

.construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE) ⇒ RelayGrid::Event

Parameters:

  • payload (String)

    the raw request body, exactly as received

  • signature_header (String)

    the X-RelayGrid-Signature header

  • secret (String)

    the endpoint's signing secret ("whsec_...")

  • tolerance (Integer, nil) (defaults to: DEFAULT_TOLERANCE)

    seconds; nil disables the replay window

Returns:

Raises:



50
51
52
53
54
55
56
57
58
59
60
# File 'lib/relaygrid/webhooks.rb', line 50

def construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
  verify_signature(payload, signature_header, secret, tolerance: tolerance)

  data = begin
    JSON.parse(payload.to_s)
  rescue JSON::ParserError => e
    raise SignatureVerificationError, "Webhook body is not valid JSON: #{e.message}"
  end

  Event.new(data)
end

.parse_header(signature_header) ⇒ Object

"t=1753722191,v1=abc..." -> [1753722191, ["abc..."]]

Several v1 entries are accepted so RelayGrid can sign with an old and a new secret at once during a rotation.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/relaygrid/webhooks.rb', line 96

def parse_header(signature_header)
  raise SignatureVerificationError, "Webhook signature header is missing" if signature_header.nil?

  parts = signature_header.to_s.split(",").map { |part| part.split("=", 2) }
  pairs = parts.select { |pair| pair.length == 2 }

  timestamp = pairs.find { |name, _| name.strip == "t" }&.last
  signatures = pairs.select { |name, _| name.strip == SIGNATURE_VERSION }.map(&:last)

  if timestamp.nil? || signatures.empty?
    raise SignatureVerificationError,
          "Webhook signature header is malformed: #{signature_header.inspect}"
  end

  [Integer(timestamp, 10), signatures]
rescue ArgumentError, TypeError
  raise SignatureVerificationError,
        "Webhook signature header carries a non-numeric timestamp: #{signature_header.inspect}"
end

.secure_compare(left, right) ⇒ Object

Constant-time comparison: a byte-by-byte == returns early on the first mismatch, leaking through its own timing how much of a guessed signature was correct. Written out rather than delegated so the gem doesn't depend on which OpenSSL binding version the host application happens to have.



120
121
122
123
124
125
126
127
128
# File 'lib/relaygrid/webhooks.rb', line 120

def secure_compare(left, right)
  left = left.to_s.b
  right = right.to_s.b
  return false unless left.bytesize == right.bytesize

  result = 0
  left.each_byte.with_index { |byte, index| result |= byte ^ right.getbyte(index) }
  result.zero?
end

.signature(payload, timestamp, secret) ⇒ Object

The MAC of ".", which is what the sender signed.



88
89
90
# File 'lib/relaygrid/webhooks.rb', line 88

def signature(payload, timestamp, secret)
  OpenSSL::HMAC.hexdigest(DIGEST, secret.to_s, "#{timestamp}.#{payload}")
end

.verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE) ⇒ true

Same checks as construct_event, without building the event.

Returns:

  • (true)

Raises:



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/relaygrid/webhooks.rb', line 66

def verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
  raise SignatureVerificationError, "Webhook signing secret is missing" if secret.nil? || secret.empty?

  timestamp, signatures = parse_header(signature_header)

  if tolerance
    age = Time.now.to_i - timestamp
    if age.abs > tolerance
      raise SignatureVerificationError,
            "Webhook timestamp is outside the #{tolerance}s tolerance (off by #{age}s)"
    end
  end

  expected = signature(payload, timestamp, secret)
  unless signatures.any? { |candidate| secure_compare(candidate, expected) }
    raise SignatureVerificationError, "Webhook signature does not match the expected value"
  end

  true
end