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. 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. CVE-2026-9189, in the Contact F
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
invoiceororder_idfrom 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.


