Module: HookSniff
- Defined in:
- lib/hooksniff.rb,
lib/hooksniff/client.rb,
lib/hooksniff/errors.rb,
lib/hooksniff/models.rb,
lib/hooksniff/version.rb,
lib/hooksniff/verification.rb
Defined Under Namespace
Modules: Models Classes: AuthenticationError, Client, EndpointsResource, Error, NotFoundError, PayloadTooLargeError, RateLimitError, ValidationError, WebhooksResource
Constant Summary collapse
- DEFAULT_BASE_URL =
Default API base URL
"https://hooksniff-api-1046140057667.europe-west1.run.app/v1"- DEFAULT_TIMEOUT =
Default request timeout in seconds
30- VERSION =
"0.1.0"
Class Method Summary collapse
-
.secure_compare(a, b) ⇒ Object
Constant-time string comparison.
-
.verify_signature(payload, signature, secret) ⇒ Boolean
Verify a webhook signature using HMAC-SHA256.
-
.verify_webhook(payload:, msg_id:, timestamp:, signature_header:, secret:, tolerance_secs: 300) ⇒ Hash
Verify a webhook signature from an incoming request (Standard Webhooks compatible).
-
.verify_webhook_from_headers(payload:, headers:, secret:, tolerance_secs: 300) ⇒ Hash
Verify a webhook signature from an incoming request (Standard Webhooks + Svix compatible).
Class Method Details
.secure_compare(a, b) ⇒ Object
Constant-time string comparison
126 127 128 129 130 131 132 133 |
# File 'lib/hooksniff/verification.rb', line 126 def self.secure_compare(a, b) return false if a.nil? || b.nil? return false if a.bytesize != b.bytesize result = 0 a.bytes.zip(b.bytes) { |x, y| result |= x ^ y } result == 0 end |
.verify_signature(payload, signature, secret) ⇒ Boolean
Verify a webhook signature using HMAC-SHA256.
10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# File 'lib/hooksniff/verification.rb', line 10 def self.verify_signature(payload, signature, secret) return false if payload.nil? || payload.empty? return false if signature.nil? || signature.empty? return false if secret.nil? || secret.empty? expected_hex = signature.start_with?("sha256=") ? signature[7..] : signature computed = OpenSSL::HMAC.hexdigest("SHA256", secret, payload) # Constant-time comparison to prevent timing attacks secure_compare(computed, expected_hex) rescue false end |
.verify_webhook(payload:, msg_id:, timestamp:, signature_header:, secret:, tolerance_secs: 300) ⇒ Hash
Verify a webhook signature from an incoming request (Standard Webhooks compatible).
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/hooksniff/verification.rb', line 69 def self.verify_webhook(payload:, msg_id:, timestamp:, signature_header:, secret:, tolerance_secs: 300) return { valid: false, error: "Missing webhook-id header" } if msg_id.nil? || msg_id.empty? return { valid: false, error: "Missing webhook-timestamp header" } if .nil? || .empty? return { valid: false, error: "Missing webhook-signature header" } if signature_header.nil? || signature_header.empty? return { valid: false, error: "Missing request body" } if payload.nil? || payload.empty? ts = .to_i return { valid: false, error: "Invalid webhook timestamp" } if ts == 0 now = Time.now.to_i if now - ts > tolerance_secs return { valid: false, error: "Message timestamp too old" } end if ts > now + tolerance_secs return { valid: false, error: "Message timestamp too new" } end # Compute expected signature signed_content = "#{msg_id}.#{}.#{payload}" secret_bytes = decode_secret(secret) expected_sig = Base64.strict_encode64( OpenSSL::HMAC.digest("SHA256", secret_bytes, signed_content) ) expected_full = "v1,#{expected_sig}" # Check each signature in the header (space-separated) signatures = signature_header.split(" ") verified = signatures.any? do |sig| sig_stripped = sig.strip next unless sig_stripped.start_with?("v1,") secure_compare(sig_stripped, expected_full) end unless verified return { valid: false, error: "Invalid webhook signature" } end # Parse the payload begin parsed = JSON.parse(payload) { valid: true, payload: parsed } rescue JSON::ParserError { valid: true, payload: payload } end end |
.verify_webhook_from_headers(payload:, headers:, secret:, tolerance_secs: 300) ⇒ Hash
Verify a webhook signature from an incoming request (Standard Webhooks + Svix compatible).
Supports both Standard Webheaders headers (webhook-id, webhook-signature, webhook-timestamp) and Svix headers (svix-id, svix-signature, svix-timestamp) as fallback.
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/hooksniff/verification.rb', line 35 def self.verify_webhook_from_headers(payload:, headers:, secret:, tolerance_secs: 300) # Normalize header keys to lowercase strings normalized = headers.transform_keys { |k| k.to_s.downcase } msg_id = normalized["webhook-id"] = normalized["webhook-timestamp"] signature_header = normalized["webhook-signature"] # Fallback to Svix headers unless msg_id && && signature_header msg_id ||= normalized["svix-id"] ||= normalized["svix-timestamp"] signature_header ||= normalized["svix-signature"] end verify_webhook( payload: payload, msg_id: msg_id, timestamp: , signature_header: signature_header, secret: secret, tolerance_secs: tolerance_secs, ) end |