DEV Community

Muni Nitish Kumar Yaddala
Muni Nitish Kumar Yaddala

Posted on • Originally published at Medium

Authenticating a Webhook Isn't Validating It: A Payment-Bypass Lesson (CVE-2026-9189)

If your app receives webhooks (Stripe, PayPal, GitHub, a payment IPN, anything), there is a subtle bug class that keeps shipping to production. A recent WordPress CVE is a perfect, minimal teaching example, so let's use it to make sure none of us write it.

The pattern (this is the part to remember)

Authenticating a webhook  =  "this message really came from the provider"
Validating  a webhook     =  "the data in this message matches what I expect"

Doing the first WITHOUT the second is how money walks out the door.
Enter fullscreen mode Exit fullscreen mode

The real bug, briefly

CVE-2026-9189, in the Contact Form 7 PayPal and Stripe Add-on (version 2.4.9 and older), authenticated PayPal's IPN correctly (it posted back with cmd=_notify-validate and required VERIFIED), then completed an order using an attacker-controlled invoice value, without checking the amount, currency, or recipient.

The invoice is attacker-controlled, so the attacker does not tamper with a signed message. They make a tiny real payment with the invoice set to a high-value pending order. PayPal genuinely verifies that payment, and the plugin marks the expensive order paid. Unauthenticated. CVSS 5.3, CWE-345.

Attacker pays $1, invoice = order #99 (worth $2,000)
   ->  PayPal sends a GENUINE IPN
   ->  plugin: "is this real?"  ->  VERIFIED   (amount never compared)
   ->  order #99 marked PAID.   $1 for a $2,000 order.
Enter fullscreen mode Exit fullscreen mode

Broken vs. fixed

Broken (authenticity checked, data ignored):

// IPN endpoint open to everyone
function cf7pp_paypal_ipn_auth() {
    return true;
}

// Handler: verifies the message is from PayPal, then trusts the payload
$response = wp_remote_post($paypal_post_url, $args);     // _notify-validate
if (strtolower($response['body']) === 'verified') {
    // attacker controls $data['invoice']; amount never checked:
    cf7pp_complete_payment($data['invoice'], 'completed', $data['txn_id']);
}
Enter fullscreen mode Exit fullscreen mode

Fixed (validate the business data against your stored order):

if (strtolower($response['body']) === 'verified') {
    $order = get_order($data['invoice']);   // load the pending order

    // 1) amount + currency must match what you charged
    if (!hash_equals((string)$order->amount,   (string)$data['mc_gross']) ||
        $order->currency !== $data['mc_currency']) {
        return bail('amount/currency mismatch');
    }
    // 2) the money must have gone to YOU
    if (strcasecmp($order->receiver_email, $data['receiver_email']) !== 0) {
        return bail('wrong recipient');
    }
    // 3) idempotency: ignore replays of an already-processed txn
    if (already_processed($data['txn_id'])) {
        return ok('duplicate ignored');
    }
    complete_payment($order->id, 'completed', $data['txn_id']);
}
Enter fullscreen mode Exit fullscreen mode

The webhook validation checklist

Whenever you handle a payment or webhook callback, do all of these, not just the first:

  • [ ] Authenticate the message (signature, provider postback, shared secret).
  • [ ] Match the amount and currency to the order you created.
  • [ ] Verify the recipient or account is you.
  • [ ] Bind to the order with a server-side value the sender cannot freely set. Do not trust a raw invoice or order_id from the payload as the only link.
  • [ ] Enforce idempotency on the transaction id to defeat replays.
  • [ ] Keep TLS verification ON for any postback (sslverify => true).
  • [ ] Fail closed. If anything does not match, do nothing.

Are you running this plugin?

If you maintain a site using this add-on at 2.4.9 or older to take PayPal payments, update past 2.4.9 now, or disable the PayPal path until you can. Every unpaid order in pending status is a valid target.

Takeaway

The plugin did the hard-looking part (provider authentication) and skipped the easy-looking part (does the money match?). The easy-looking part is the one that protects your revenue. Authenticate the messenger, then always check the message.

Full technical write-up and references: see the canonical post on my blog.
Discovered and responsibly disclosed by Muni Nitish Kumar Yaddala. CVE-2026-9189.

Top comments (2)

Collapse
 
amrakg93 profile image
kg8888

"The easy-looking part is the one that protects your revenue."

This is the exact bug pattern I see most often in AI-built payment integrations. The AI happily writes verify_webhook_signature() because that's the part with documentation examples. Then it skips amount validation, currency check, and idempotency — because those look "obvious" and the AI doesn't have a mental model of financial risk.

The Contact Form 7 bug is a perfect teaching case: one line of signature verification, zero lines of amount comparison. $1 for a $2,000 order. Authenticated, not validated.

Adding to your checklist: also check that the receiver_email matches your business account (step 2 in the fixed code). I've seen Stripe webhooks verified then processed against the wrong connected account because nobody checked where the money actually landed.

Collapse
 
nitishyaddala profile image
Muni Nitish Kumar Yaddala

This is a sharp framing, the documentation-shaped hole. The signature check has a copy-paste example, so it gets written. Amount, currency, and idempotency need a mental model of financial risk, so they get skipped. That matches what I saw here exactly: one line of verification, zero lines of comparison.

Good eye on receiver_email, that's step 2 in the fixed snippet for the same reason you mention. Your Stripe connected-account example is a better version of the problem though, because the money can verify as real and still land in the wrong account. "Authenticated" tells you the message is genuine. It says nothing about whether the funds came to you. Same root cause, higher stakes.

The idempotency point is the one I'd push hardest with people, since a verified IPN can be replayed and most handlers happily process it twice. Thanks for the thoughtful read.