GHSA-mjgf-xj26-9qf9 pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
Summary`Pay::Webhooks::PaddleBillingController#valid_signature?` (`app/controllers/pay/webhooks/paddle_billing_controller.rb`) verifies the Paddle Billing webhook signature by computing `OpenSSL::HMAC.hexdigest(...)` and comparing it to the attacker-supplied header value using Ruby's `String#==`. Ruby's `==` is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (`OpenSSL.fixed_length_secure_compare` / `ActiveSupport::SecurityUtils.secure_compare`).Impact**CWE-208** — Observable Timing Discrepancy on the webhook signature verifier.An attacker who can deliver requests to the `/pay/webhooks/paddle_billing` mount point can probe the verifier with guessed `Paddle-Signature` header values. Because `String#==` short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. `subscription.created` / `transaction.completed`) against the host application. Pay's webhook processor enqueues a `Pay::Webhooks::ProcessJob` for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.The endpoint is internet-reachable by definition (Paddle must POST events to it).Affected versions`pay` (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).Vulnerable code (file:line)`app/controllers/pay/webhooks/paddle_billing_controller.rb`:`hmac` is the 64-character hex-encoded SHA-256 HMAC of `"<ts>:<raw_post>"` under the application's configured Paddle Billing signing secret. The comparison with `h1` (the attacker-supplied `h1=` token from the `Paddle-Signature` header) uses Ruby's native `String#==`, which is implemented in MRI as `rb_str_equal` and returns immediately on the first byte mismatch.How an attacker reaches this codeAny Pay-using Rails application mounting `Pay::Engine` exposes `POST /pay/webhooks/paddle_billing` to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in `config/routes.rb` when `paddle_billing` is enabled.The controller's `before_action :verify_signature` invokes `valid_signature?` on every inbound request.An attacker repeatedly POSTs forged webhook payloads with `Paddle-Signature: ts=<now>;h1=<guess>` headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of `<guess>` matches the real `hmac`.A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.Proof of concept (microbenchmark)Local Ruby microbenchmark isolating the verifier comparison path:This isolates the same `String#==` path used by `valid_signature?`. The static defect is verifiable by `bundle show pay` and reading line 38 of the controller.End-to-end reproduction against `gem install pay --version 11.6.1`Minimal Rails 8 app mounting `Pay::Engine` with `paddle_billing` enabled:The static defect is verifiable by:After the fix is applied, the verifier uses `ActiveSupport::SecurityUtils.secure_compare`, which compares all bytes regardless of mismatch position, and the timing oracle closes.Suggested fixReplace `==` with `ActiveSupport::SecurityUtils.secure_compare` (Pay is a Rails engine, so ActiveSupport is always available).The bytesize-equality guard ensures `secure_compare` does not return early on a length mismatch (it falls back to `==` if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.CreditReported by tonghuaroot (https://github.com/tonghuaroot).
CVSS v3.1 score: 7.4