Skip to content

#1513 Fix - Reject replayed Stripe PaymentIntents on membership renewal - #1416

Merged
y000yal merged 1 commit into
developfrom
fix/1513-stripe-paymentintent-replay
Sep 9, 2026
Merged

#1513 Fix - Reject replayed Stripe PaymentIntents on membership renewal#1416
y000yal merged 1 commit into
developfrom
fix/1513-stripe-paymentintent-replay

Conversation

@MILAN88888

@MILAN88888 MILAN88888 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 Closes that will auto-close it).

This is the canonical copy — modules/ is listed in .github/sync-file-list.yml, so
StripeService.php is 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 bundle
can 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 one
order and applies completion to a different one, and nothing required the two to be the same
order:

  1. $transaction_id arrives from POST; PaymentIntent::retrieve() returns succeeded, so the
    status gate passes.
  2. $latest_order = get_order_by_transaction_id( $intent->id ) resolves to the order that
    already owns that PaymentIntent — the old, completed one. The ownership check
    ($member_id === $latest_order['user_id']) and the payment-method check both pass, because
    that old order really is the attacker's own Stripe order.
  3. The duplicate guard is does_transaction_id_exists( $transaction_id, $latest_order['ID'] ),
    i.e. "does any order other than $latest_order hold this transaction id". On a replay the
    only holder is $latest_order itself, 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.
  4. The succeeded branch then calls get_member_orders( $member_id ), which is
    ORDER BY created_at DESC LIMIT 1 — the member's newest order, i.e. the fresh pending
    renewal. That order is marked completed with the replayed transaction id and its
    subscription 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 succeeded branch, immediately after $member_order is resolved and
before anything mutates it, require that the order the PaymentIntent was verified against is the
order about to be completed:

if ( empty( $member_order ) || (int) $latest_order['ID'] !== (int) $member_order['ID'] ) {
	return $this->update_order_error( … 'PAYMENT_INTENT_ORDER_MISMATCH' … );
}

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 response
shape as the other verification failures and logs through PaymentGatewayLogging::log_error()
with an PAYMENT_INTENT_ORDER_MISMATCH error code for triage. The user-facing string is the
generic "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 repeat
confirmations 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 is
not 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):

  1. Register a subscriber against the paid plan and complete one normal Stripe payment. Confirm
    the order is completed and the restricted page is visible.
  2. Note that order's PaymentIntent id (pi_…) from My Account → Payments.
  3. Expire the term: set the subscription's expiry_date and next_billing_date to a past date
    directly in wp_urm_subscriptions. Confirm the restricted page is now blocked.
  4. Start a normal renewal (user_registration_membership_renew_membership) so a new pending
    order is created with an empty transaction id. Do not let Stripe create a new subscription.
  5. Call user_registration_membership_confirm_payment directly with the PaymentIntent id from
    step 2 as payment_result[paymentIntent][id], plus the subscriber's member_id.
  6. Before this PR: the response is success, the pending order flips to completed with the
    old transaction id, the subscription goes active, and expiry/next-billing move a full term
    out — no new payment.
    With this PR: the response is Payment verification failed., the pending order stays
    pending, the subscription stays inactive, and the restricted page stays blocked. A
    PAYMENT_INTENT_ORDER_MISMATCH entry appears in the payment gateway log.

Regression — normal paths must still work:

  1. First purchase. Register a new user against the paid plan and pay with a Stripe test card
    (4242 4242 4242 4242). The order completes, the subscription is active, the restricted page
    is visible. This is the UR-4710 flow, where order_id arrives empty — confirm it is not
    rejected.
  2. Renewal. With an expired subscription, renew through the UI and pay normally. The renewal
    order completes and expiry/next-billing advance one term.
  3. Upgrade. Upgrade from one paid plan to a more expensive one and pay normally. The upgrade
    completes and the old subscription is cancelled as before.
  4. Repeat confirmation. Immediately re-submit user_registration_membership_confirm_payment
    for the order you just completed in step 7. It still returns "Payment already verified."
    rather than the new mismatch error.
  5. Failure paths unchanged. Confirm a declined card (4000 0000 0000 0002) still reports
    the Stripe failure message, and that submitting a live PaymentIntent while in test mode still
    reports the mode mismatch.
  6. Check wp-content/debug.log is free of new warnings or fatals across all of the above.

Types of changes:

  • Bug fix (non-breaking change which fixes an issue)
  • Enhancement (modification of the currently available functionality)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you successfully ran tests with your changes locally?
  • Have you updated the documentation accordingly?

PHPCS (phpcs.xml, the ruleset pr-code-sniff.yml uses) on the changed file: no violations on
the 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

Fix - Stripe payment could be replayed to renew a membership without paying.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 verified PaymentIntent’s owning order matches the order being completed.
  • Returns a consistent verification failure response and logs a PAYMENT_INTENT_ORDER_MISMATCH code 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,
)

@y000yal y000yal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

@y000yal
y000yal merged commit ba5de3a into develop Sep 9, 2026
4 of 5 checks passed
@y000yal
y000yal deleted the fix/1513-stripe-paymentintent-replay branch September 9, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants