#1513 Fix - Reject replayed Stripe PaymentIntents on membership renewal - #1416
Merged
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new mismatch error path can trigger a PHP warning due to array-offset access when get_member_orders() returns false, which should be fixed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Hardens Stripe membership renewal confirmation to prevent replaying an old PaymentIntent to complete a newer pending order, closing a membership-renewal payment bypass.
Changes:
- Adds a guard in
StripeService::update_order()to ensure the verifiedPaymentIntent’s owning order matches the order being completed. - Returns a consistent verification failure response and logs a
PAYMENT_INTENT_ORDER_MISMATCHcode for triage.
File summaries
| File | Description |
|---|---|
| modules/membership/includes/Admin/Services/Stripe/StripeService.php | Adds an order-identity guard to prevent replayed PaymentIntents from being applied to a different (newer) order. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+1027
to
+1033
| array( | ||
| 'error_code' => 'PAYMENT_INTENT_ORDER_MISMATCH', | ||
| 'member_id' => $member_id, | ||
| 'payment_intent_id' => $pi_id, | ||
| 'verified_order_id' => $latest_order['ID'], | ||
| 'target_order_id' => $member_order['ID'] ?? 0, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
All Submissions:
Changes proposed in this Pull Request:
Closes wpeverest/user-registration-pro#1513 (the issue is filed on the Pro repo; the paired Pro PR
wpeverest/user-registration-pro#1547 carries the
Closesthat will auto-close it).This is the canonical copy —
modules/is listed in.github/sync-file-list.yml, soStripeService.phpis byte-identical in both trees and Pro normally receives it through the🔄 Synced file(s)automation. The paired Pro PR carries the same commit directly so the Pro bundlecan ship without waiting on a sync.
A Subscriber could renew or extend a paid membership without paying, by replaying a
PaymentIntent from one of their own earlier orders (Patchstack, CVSS 5.4, confirmed in 5.2.7).
Root cause.
StripeService::update_order()verifies the submitted PaymentIntent against oneorder and applies completion to a different one, and nothing required the two to be the same
order:
$transaction_idarrives from POST;PaymentIntent::retrieve()returnssucceeded, so thestatus gate passes.
$latest_order = get_order_by_transaction_id( $intent->id )resolves to the order thatalready owns that PaymentIntent — the old, completed one. The ownership check
(
$member_id === $latest_order['user_id']) and the payment-method check both pass, becausethat old order really is the attacker's own Stripe order.
does_transaction_id_exists( $transaction_id, $latest_order['ID'] ),i.e. "does any order other than
$latest_orderhold this transaction id". On a replay theonly holder is
$latest_orderitself, so the guard sees nothing and reports no duplicate.That exclusion is the UR-4710 first-purchase fix and is correct for its own case — it is
simply blind to this one.
succeededbranch then callsget_member_orders( $member_id ), which isORDER BY created_at DESC LIMIT 1— the member's newest order, i.e. the fresh pendingrenewal. That order is marked
completedwith the replayed transaction id and itssubscription activated.
So every individual check passes while verification and completion are looking at two different
orders. The result is an active subscription with expiry and next-billing pushed out a full term,
restricted content unlocked, and no new PaymentIntent ever created.
The change. In the
succeededbranch, immediately after$member_orderis resolved andbefore anything mutates it, require that the order the PaymentIntent was verified against is the
order about to be completed:
Why this approach. It closes the gap at the point where the two identities diverge, rather
than adding a fourth heuristic on top of the three checks that already pass. It also keeps the
UR-4710 exclusion intact instead of tightening the duplicate guard, which would re-break
first-purchase and renewal flows where the order legitimately owns its own transaction.
The rejection reuses the existing
update_order_error()helper, so it returns the same responseshape as the other verification failures and logs through
PaymentGatewayLogging::log_error()with an
PAYMENT_INTENT_ORDER_MISMATCHerror code for triage. The user-facing string is thegeneric "Payment verification failed." already used by the sibling failures — it deliberately
does not say which order mismatched.
Backward compatibility. Legitimate flows are unaffected: on a normal first purchase or
renewal the PaymentIntent is created for the order being completed, so
$latest_order['ID'] === $member_order['ID']holds. The already-completed short-circuit(
'completed' === $member_order['status']) still runs after the guard, so genuine repeatconfirmations of the same order continue to return "Payment already verified."
Not included. The issue also suggests matching the PaymentIntent amount against
$member_order. That is a separate defence — it guards underpayment rather than replay — and isnot in this PR.
How to test the changes in this Pull Request:
Preconditions: User Registration & Membership with the Stripe gateway configured in test
mode, one paid monthly membership plan, and a page restricted to that plan.
The vulnerability (fails before this PR, rejected after):
the order is
completedand the restricted page is visible.pi_…) from My Account → Payments.expiry_dateandnext_billing_dateto a past datedirectly in
wp_urm_subscriptions. Confirm the restricted page is now blocked.user_registration_membership_renew_membership) so a new pendingorder is created with an empty transaction id. Do not let Stripe create a new subscription.
user_registration_membership_confirm_paymentdirectly with the PaymentIntent id fromstep 2 as
payment_result[paymentIntent][id], plus the subscriber'smember_id.success, the pending order flips tocompletedwith theold transaction id, the subscription goes
active, and expiry/next-billing move a full termout — no new payment.
With this PR: the response is
Payment verification failed., the pending order stayspending, the subscription stays inactive, and the restricted page stays blocked. APAYMENT_INTENT_ORDER_MISMATCHentry appears in the payment gateway log.Regression — normal paths must still work:
(
4242 4242 4242 4242). The order completes, the subscription is active, the restricted pageis visible. This is the UR-4710 flow, where
order_idarrives empty — confirm it is notrejected.
order completes and expiry/next-billing advance one term.
completes and the old subscription is cancelled as before.
user_registration_membership_confirm_paymentfor the order you just completed in step 7. It still returns "Payment already verified."
rather than the new mismatch error.
4000 0000 0000 0002) still reportsthe Stripe failure message, and that submitting a live PaymentIntent while in test mode still
reports the mode mismatch.
wp-content/debug.logis free of new warnings or fatals across all of the above.Types of changes:
Other information:
PHPCS (
phpcs.xml, the rulesetpr-code-sniff.ymluses) on the changed file: no violations onthe added lines, and the file's total is unchanged at 62 pre-existing violations — none of them
touched by this diff.
The tests checkbox is left unticked: the reasoning above is traced from the code and the reporter's
steps, but the numbered Stripe flows have not been executed end to end against a live test-mode
account. Documentation is unticked because a security fix with no settings or API change needs none.
Changelog entry