• dev

    (@devksec)


    Hello,

    We have 2 stores, each with incremental order IDs. A incident has occured where an order, which was not paid, was marked via webhook it was paid and order was shipped out.

    It has been isolated to due 2 orders having the same order number on 2 different stores within the same stripe account causing the issue.

    Order 18201 on store 1 – Unpaid due to failed klarna pament

    Order 18201 on store 2 – Paid by card succesfully

    When order 18201 on store 2 was paid, a webhook marked the order on store 1 as paid after it was a failed payment. The webhook left the stripe transaction/payment details of the other stores payment.

    The common denominator was the order number was the same out of chance within the same stripe account but different stores.

    • This topic was modified 1 week ago by dev.
Viewing 6 replies - 1 through 6 (of 6 total)
  • Plugin Support WebToffee Support

    (@webtoffeesupport)

    Hi @devksec,


    Could you share the Stripe dashboard logs from Developers -> Logs of the affected order? Sharing the webhook event logs and the plugin version number will also help us understand the issue better.

    Thread Starter dev

    (@devksec)

    Can you provide the best email to send these as they contain sensitive information?

    Thread Starter dev

    (@devksec)

    Bits to review in the meantime:

    Order 1 (Failed) was in July 2024
    Order 2 (Success) 15th Nov 2024

    Order 1 had this added and the order went from failed to processing due to the matching order ID. The txn_ is for order 2.

    Payment Status : Succeeded [ 2024-11-15 20:33:04 ] . Source : card. Charge Status :Captured. Transaction ID : txn_3QLWCLI<<redacted>>. via webhook

    Payment Status : Succeeded [ 2024-11-15 20:33:04 ] . Source : mastercard( credit ). Charge Status :Captured.
    Transaction ID : txn_3QLWCLI<<redacted>>

    There’s only logs to the order 2 and not to the orginal failed payment in July.

    Plugin Support WebToffee Support

    (@webtoffeesupport)

    Hi @devksec,

    You can share the logs privately using this link. We will review the logs and provide you with an update.

    Thread Starter dev

    (@devksec)

    Pretty sure its down to only using orderIDs for validation, which if within the same stripe account and matching would cause a webhook to trigger another orders payment status.

    I dont think this is limited to stripe checkout, as a few payment methods have the same potential issue.

    class-stripe-checkout.php

    /**
    * creates order after checkout session is completed.
    * @since 3.3.4
    */
    public function eh_spg_stripe_checkout_order_callback() {

        if(!EH_Helper_Class::verify_nonce(EH_STRIPE_PLUGIN_NAME, 'eh_checkout_nonce'))
        {
            die(_e('Access Denied', 'payment-gateway-stripe-and-woocommerce-integration'));
        }
        $order_id = intval( $_GET['order_id'] );
        $order = wc_get_order($order_id);
    
        if(isset($_REQUEST['action']) && 'cancel_checkout' === sanitize_text_field($_REQUEST['action'])){
            wc_add_notice(__('You have cancelled Stripe Checkout Session. Please try to process your order again.', 'payment-gateway-stripe-and-woocommerce-integration'), 'notice');
            wp_redirect(wc_get_checkout_url());
            exit;
        }
        else{
            $session_id = sanitize_text_field( $_GET['sessionid'] );
    
            $obj  = new EH_Stripe_Payment();
    
            $order_time = date('Y-m-d H:i:s', time() + get_option('gmt_offset') * 3600);
    
            $session = \Stripe\Checkout\Session::retrieve($session_id);
            $payment_intent_id = $session->payment_intent;
    
             EH_Helper_Class::wt_stripe_order_db_operations($order_id,  $order, 'add', '_eh_stripe_payment_intent', $payment_intent_id, false);  
    
            $payment_intent = \Stripe\PaymentIntent::retrieve($payment_intent_id);
            $charge_details = $payment_intent->charges['data'];
    
            foreach($charge_details as $charge){
    
                $charge_response = $charge;  
            }
    
            $data = $obj->make_charge_params($charge_response, $order_id);
    
            if ($charge_response->paid == true) {
    
                if($charge_response->captured == true){
                    $order->payment_complete($data['id']);
                }
    
                if (!$charge_response->captured) {
                    $order->update_status('on-hold');
                }
    
                $order->set_transaction_id( $data['transaction_id'] );
    
                $order->add_order_note(__('Payment Status : ', 'payment-gateway-stripe-and-woocommerce-integration') . ucfirst($data['status']) . ' [ ' . $order_time . ' ] . ' . __('Source : ', 'payment-gateway-stripe-and-woocommerce-integration') . $data['source_type'] . '. ' . __('Charge Status :', 'payment-gateway-stripe-and-woocommerce-integration') . $data['captured'] . (is_null($data['transaction_id']) ? '' : '.'.__('Transaction ID : ','payment-gateway-stripe-and-woocommerce-integration') . $data['transaction_id']));
                WC()->cart->empty_cart();
    
                EH_Helper_Class::wt_stripe_order_db_operations($order_id, $order, 'add', '_eh_stripe_payment_charge', $data, false); 
                EH_Stripe_Log::log_update('live', $data, get_bloginfo('blogname') . ' - Charge - Order #' . $order_id);
    
                // Return thank you page redirect.
                $result =  array(
                    'result'    => 'success',
                    'redirect'  => $obj->get_return_url($order),
                );
    
                wp_safe_redirect($result['redirect']);
                exit;
    
            } else {
                wc_add_notice($data['status'], $notice_type = 'error');
                EH_Stripe_Log::log_update('dead', $charge_response, get_bloginfo('blogname') . ' - Charge - Order #' . $order_id);
            }
       }
    }

    A few concerns raised from initial checks:

    1. Order ID Handling – The code relies on $_GET[‘order_id’] to retrieve the order ID and assumes it uniquely identifies an order. If two stores accidentally share the same order_id (e.g., both stores start order numbers at 1 or have overlapping ranges), a webhook sent to one store could inadvertently match an order in another store when sharing the same stripe account.
    2. The session ID ($_GET[‘sessionid’]) is retrieved and used to fetch the Stripe Checkout session: php. Stripe webhooks should include additional validation, such as verifying the metadata or client_reference_id to ensure the webhook corresponds to the correct store and order
    3. No Cross-Store Verification – The code does not validate whether the order_id or session ID belongs to the current store. It directly assumes the provided order_id is valid and related to the webhook payload. This could allow a Stripe webhook from another store (with a matching order_id) to mark an unrelated order as paid.
    4. The code marks the order as paid when $charge_response->paid and $charge_response->captured are true. Without additional checks to validate that the order_id and payment intent belong to the current store, this could inadvertently mark an unrelated order as paid.
    5. Stripe webhooks require the validation of their signature (Stripe-Signature header) to ensure the request came from Stripe and corresponds to the correct environment. The code snippet does not show any validation of this header, which might leave the endpoint vulnerable to spoofed requests.

    Go to with GBTs review/recommendations:

    Recommendations for Mitigation

    1. Verify Order Ownership: Add additional checks to ensure the order ID and session ID belong to the current store. For example:
      • Compare the store’s URL or a unique identifier in the order metadata or webhook payload.
      • Use metadata fields in Stripe sessions to include the store ID or environment details.
    2. Validate Stripe Webhook Signature: Use the \Stripe\Webhook::constructEvent() method to validate the Stripe-Signature header and ensure the payload belongs to the current store.
    3. Check Payment Intent Metadata: Use metadata in the Stripe session or payment intent to uniquely link the payment to the specific store and order. This allows you to validate the incoming webhook against the store’s data.

    One thing we’ve done as a temp measure is to have custom prefixes in for each stores order IDs, so they won’t clash anymore.

    Plugin Support WebToffee Support

    (@webtoffeesupport)

    Hi @devksec,


    Let us thoroughly review the details that have been provided. Once we achieve a comprehensive understanding of the underlying cause of this issue, we will inform you of a proposed solution.

Viewing 6 replies - 1 through 6 (of 6 total)
  • You must be logged in to reply to this topic.