Module: SeatLayer::Webhook

Defined in:
lib/seatlayer/webhook.rb

Overview

Webhook signature verification.

The most security-sensitive thing an integrator writes by hand, and the two classic mistakes are both easy to make and silent:

  1. verifying against a re-serialised body, which changes bytes and fails — or worse, gets "fixed" by skipping verification entirely;
  2. comparing signatures with ==, which leaks the expected value through timing.

So the SDK does it, takes the RAW body, and compares in constant time.

Class Method Summary collapse

Class Method Details

.verify(payload, signature, secret) ⇒ Object

Verify a delivery and return its decoded payload.

payload must be the raw request body. In Rails that is request.raw_post; in Sinatra, request.body.read. Never a parsed Hash re-encoded.

NOTE ON REPLAY: deliveries are signed over the body, which carries an "at" timestamp — but nothing enforces a freshness window, so a captured delivery stays valid indefinitely. Replay protection is yours: every event carries an occurrenceId, and the correct pattern is to record processed ids and ignore repeats. Do not skip this.

Raises:



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
# File 'lib/seatlayer/webhook.rb', line 33

def verify(payload, signature, secret)
  raise WebhookVerificationError, "A webhook signing secret is required." if secret.nil? || secret.empty?

  if signature.nil? || signature.empty?
    raise WebhookVerificationError, "Missing X-SeatLayer-Signature header."
  end

  scheme, _, provided = signature.partition("=")
  if scheme != "sha256" || provided.empty?
    raise WebhookVerificationError,
          "Unsupported signature format #{signature.inspect}; expected \"sha256=<hex>\"."
  end

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
  # secure_compare is constant time and handles a length mismatch without
  # leaking which of the two failures occurred.
  unless OpenSSL.secure_compare(expected, provided)
    raise WebhookVerificationError, "Webhook signature did not match."
  end

  begin
    JSON.parse(payload)
  rescue JSON::ParserError => e
    raise WebhookVerificationError, "Signature verified but the body is not valid JSON: #{e.message}"
  end
end