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

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" Validatinga webhook ="the data in this message matches what I expect" Doing the first WITHOUT the second is how money walks out the door. 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. 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' ]); } 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' ]); } 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.

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. 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. 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']); } 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']); } 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 ororder_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 (0)

Comments

No comments yet. Start the discussion.