Module: ConcernsOnRails::Controllers::WebhookVerifiable
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/concerns_on_rails/controllers/webhook_verifiable.rb
Overview
HMAC signature verification for inbound webhooks — the receiving side of Stripe/GitHub/Shopify-style integrations. The action only runs when the signature over the raw request body verifies; otherwise a 401/400 is rendered and the action never executes.
class WebhooksController < ApplicationController
include ConcernsOnRails::Controllers::WebhookVerifiable
verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
verify_webhook :shopify, secret: [NEW_SECRET, OLD_SECRET], scheme: :shopify
verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
# verify_webhook secret: ... # no actions = catch-all (declare specific rules first)
def stripe; ...; end
end
Schemes (header defaults in parentheses):
:github ("X-Hub-Signature-256") value "sha256=<hex>"
:shopify ("X-Shopify-Hmac-Sha256") value strict-Base64 of the binary HMAC
:stripe ("Stripe-Signature") value "t=<unix>,v1=<hex>[,v1=...]"; the
signed payload is "#{t}.#{body}"; every v1 is tried (rotation);
`tolerance:` (default 300s, Stripe-only) rejects |now - t| beyond
the window, replayed and far-future headers alike
:hex / :base64 plain hex / strict-Base64 HMAC of the
body; these have no standard header so `header:` is required;
`digest:` (:sha256 default, :sha1/:sha512) applies to these only
secret: a non-blank String, a callable (instance_exec’d per request — use ‘-> { ENV }` for boot-order safety or read params for multi-tenant secrets), or an Array of those (rotation: any match passes). A secret that resolves blank at request time raises ArgumentError — a misconfigured endpoint must alert the operator, not 401 into the provider’s silent retry loop.
Failures render through ‘webhook_verification_failed(message:, status:, code:)` (delegates to Respondable’s render_error when present; override it to customize): missing/blank header -> 401 “webhook_signature_missing”; mismatch -> 401 “webhook_signature_invalid”; stale/future Stripe timestamp -> 401 “webhook_timestamp_stale”; unparseable Stripe header -> 400 “webhook_signature_malformed”. After a pass, ‘webhook_verified?` is true.
IMPORTANT:
* Include/declare this BEFORE Idempotentable (and other around filters
that cache responses) — a 401 that runs inside Idempotentable's
around_action would be cached and replayed for the full ttl.
Verifying before Throttleable also stops forged traffic from burning
legitimate rate budget.
* Webhook endpoints receive third-party POSTs: skip CSRF yourself
(`skip_before_action :verify_authenticity_token`) along with any
session auth filters.
* The signature covers the raw bytes — parse `request.raw_post` in the
action; re-serializing `params` may not round-trip byte-for-byte.
Anything that rewrites the body before the controller breaks
verification.
* In tests, `skip_before_action :verify_webhook_signature!` or sign the
payload for real with OpenSSL::HMAC.
Defined Under Namespace
Modules: ClassMethods
Constant Summary collapse
- LABEL =
"ConcernsOnRails::Controllers::WebhookVerifiable".freeze
- SCHEMES =
{ hex: { header: nil, encoding: :hex }, base64: { header: nil, encoding: :base64 }, github: { header: "X-Hub-Signature-256", encoding: :hex, prefix: "sha256=" }, shopify: { header: "X-Shopify-Hmac-Sha256", encoding: :base64 }, stripe: { header: "Stripe-Signature", encoding: :stripe } }.freeze
- PINNED_DIGEST_SCHEMES =
Schemes whose wire format pins the digest — ‘digest:` cannot override it.
%i[github shopify stripe].freeze
- SUPPORTED_DIGESTS =
{ sha256: "SHA256", sha1: "SHA1", sha512: "SHA512" }.freeze
- STRIPE_DEFAULT_TOLERANCE =
seconds; Stripe’s recommended window
300- MAX_STRIPE_SIGNATURES =
Stripe sends at most two v1 values (during secret rolls); the cap is cheap hygiene against a header stuffed with thousands of candidates.
16- STRIPE_TIMESTAMP_FORMAT =
/\A\d+\z/
Instance Method Summary collapse
-
#verify_webhook_signature! ⇒ Object
before_action entry point.
-
#webhook_verification_failed(message:, status:, code:) ⇒ Object
Single funnel for all failure outcomes (override point).
-
#webhook_verified? ⇒ Boolean
True once the current request’s signature has verified.
Instance Method Details
#verify_webhook_signature! ⇒ Object
before_action entry point. Public and named so apps can ‘skip_before_action :verify_webhook_signature!` (e.g. in tests).
158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/concerns_on_rails/controllers/webhook_verifiable.rb', line 158 def verify_webhook_signature! rule = webhook_rule_for_action return unless rule value = read_webhook_header(rule) if value.nil? return webhook_verification_failed(message: "#{rule[:header]} header is missing.", status: :unauthorized, code: "webhook_signature_missing") end secrets = resolve_webhook_secrets!(rule) webhook_render_outcome(rule, webhook_verification_outcome(rule, value, secrets)) end |
#webhook_verification_failed(message:, status:, code:) ⇒ Object
Single funnel for all failure outcomes (override point). Uses Respondable’s render_error when available, otherwise the same inline envelope as Throttleable / Idempotentable.
180 181 182 183 184 185 186 |
# File 'lib/concerns_on_rails/controllers/webhook_verifiable.rb', line 180 def webhook_verification_failed(message:, status:, code:) return unless respond_to?(:response) && response return render_error(message: , status: status, code: code) if respond_to?(:render_error) render json: { success: false, error: { message: , code: code } }, status: status end |
#webhook_verified? ⇒ Boolean
True once the current request’s signature has verified.
173 174 175 |
# File 'lib/concerns_on_rails/controllers/webhook_verifiable.rb', line 173 def webhook_verified? !!@webhook_verified end |