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
- .construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE) ⇒ RelayGrid::Event
-
.parse_header(signature_header) ⇒ Object
"t=1753722191,v1=abc..." -> [1753722191, ["abc..."]].
-
.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. -
.signature(payload, timestamp, secret) ⇒ Object
The MAC of "
.", which is what the sender signed. -
.verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE) ⇒ true
Same checks as construct_event, without building the event.
Class Method Details
.construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE) ⇒ RelayGrid::Event
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.}" 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 } = pairs.find { |name, _| name.strip == "t" }&.last signatures = pairs.select { |name, _| name.strip == SIGNATURE_VERSION }.map(&:last) if .nil? || signatures.empty? raise SignatureVerificationError, "Webhook signature header is malformed: #{signature_header.inspect}" end [Integer(, 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 "
88 89 90 |
# File 'lib/relaygrid/webhooks.rb', line 88 def signature(payload, , secret) OpenSSL::HMAC.hexdigest(DIGEST, secret.to_s, "#{}.#{payload}") end |
.verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE) ⇒ true
Same checks as construct_event, without building the event.
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? , signatures = parse_header(signature_header) if tolerance age = Time.now.to_i - if age.abs > tolerance raise SignatureVerificationError, "Webhook timestamp is outside the #{tolerance}s tolerance (off by #{age}s)" end end expected = signature(payload, , secret) unless signatures.any? { |candidate| secure_compare(candidate, expected) } raise SignatureVerificationError, "Webhook signature does not match the expected value" end true end |