Commit 5088eccbdb for woocommerce

commit 5088eccbdbd6673b413d60679ab7c8f40b19d79a
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date:   Thu Feb 5 22:51:40 2026 +0600

    PayPal Standard : Add additional checks in shipping callback (#62990)

    * add shipping callback related constants

    * add a token param in shipping callback url

    * validate the token in request

    * add more validation checks

    * fix lint

    * fix phpstan

    * use add_query_arg

    * ignore incorrect type hint issue

    * use hash_equals

    * fix reverse logic

    * update comment

    * add cache class

    * update constant name

    * set and validate cached token

    * fix phpstan

    * add changelog

    * increase expiration time

    * fix lint

    * store token in order meta

    * validate token from order meta

    * remove cache implementation

    * clean up the key when order is approved

    * check boolean instead of empty

    * add @since

    * remove unused constant

    * add details in comment

    * update version number in docblock

diff --git a/plugins/woocommerce/changelog/62990-add-additional-checks-in-paypal-standard-shipping-callback b/plugins/woocommerce/changelog/62990-add-additional-checks-in-paypal-standard-shipping-callback
new file mode 100644
index 0000000000..f94b4d035a
--- /dev/null
+++ b/plugins/woocommerce/changelog/62990-add-additional-checks-in-paypal-standard-shipping-callback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Add additional validation in the PayPal Standard shipping callback method.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
index 1aca48e526..40738f27f3 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
@@ -15,6 +15,8 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Gateways\PayPal\Helper as PayPalHelper;
 use Automattic\WooCommerce\Gateways\PayPal\Request as PayPalRequest;
+use Automattic\WooCommerce\Gateways\PayPal\Constants as PayPalConstants;
+use Automattic\WooCommerce\Enums\OrderStatus;

 if ( ! class_exists( 'WC_Gateway_Paypal' ) ) {
 	require_once WC_ABSPATH . 'includes/gateways/paypal/class-wc-gateway-paypal.php';
@@ -60,11 +62,50 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {
 			array(
 				'methods'             => WP_REST_Server::CREATABLE,
 				'callback'            => array( $this, 'process_shipping_callback' ),
-				'permission_callback' => '__return_true',
+				'permission_callback' => array( $this, 'validate_shipping_callback_request' ),
 			)
 		);
 	}

+	/**
+	 * Validate the shipping callback request.
+	 *
+	 * @since 10.6.0
+	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
+	 * @return bool True if the request is valid, false otherwise.
+	 */
+	public function validate_shipping_callback_request( WP_REST_Request $request ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		$token = $request->get_param( 'token' );
+		if ( empty( $token ) ) {
+			return false;
+		}
+
+		$purchase_units = $request->get_param( 'purchase_units' );
+		if ( empty( $purchase_units ) || empty( $purchase_units[0]['custom_id'] ) ) {
+			return false;
+		}
+
+		$order = PayPalHelper::get_wc_order_from_paypal_custom_id( $purchase_units[0]['custom_id'] );
+		if ( ! $order ) {
+			return false;
+		}
+
+		// If shipping callback token is not stored in order meta, return true for this order as the token is not generated for the original order.
+		// We will not validate the token if the order did not generate a token in the create order request.
+		// This is done to prevent orders created before the shipping callback token feature was introduced from being blocked from updating their shipping details.
+		if ( ! $order->meta_exists( PayPalConstants::PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN ) ) {
+			return true;
+		}
+
+		$shipping_callback_token = $order->get_meta( PayPalConstants::PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN, true );
+
+		if ( empty( $shipping_callback_token ) || ! hash_equals( $token, $shipping_callback_token ) ) {
+			return false;
+		}
+
+		return true;
+	}
+
 	/**
 	 * Callback for when the customer updates their shipping details in PayPal.
 	 * https://developer.paypal.com/docs/checkout/standard/customize/shipping-module/#server-side-shipping-callbacks
@@ -95,7 +136,7 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {

 		// Compare PayPal order IDs.
 		$paypal_order_id_from_order_meta = $order->get_meta( '_paypal_order_id', true );
-		if ( $paypal_order_id !== $paypal_order_id_from_order_meta ) {
+		if ( empty( $paypal_order_id_from_order_meta ) || $paypal_order_id !== $paypal_order_id_from_order_meta ) {
 			WC_Gateway_Paypal::log(
 				'PayPal order ID mismatch. Order ID: ' . $order->get_id() .
 				'. PayPal order ID (request): ' . $paypal_order_id .
@@ -105,6 +146,28 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {
 			return new WP_REST_Response( $response, 422 );
 		}

+		// Validate that the order is in a valid state for shipping updates.
+		// Only draft or pending orders should accept shipping updates.
+		if ( ! in_array( $order->get_status(), array( OrderStatus::CHECKOUT_DRAFT, OrderStatus::PENDING ), true ) ) {
+			WC_Gateway_Paypal::log(
+				'Order is not in a valid state for shipping updates. Order ID: ' . $order->get_id() .
+				'. Order status: ' . $order->get_status()
+			);
+			$response = $this->get_update_shipping_error_response();
+			return new WP_REST_Response( $response, 422 );
+		}
+
+		// If the order has a PayPal transaction ID, a charge has already occurred, so we shouldn't change the shipping address.
+		$transaction_id = $order->get_transaction_id();
+		if ( ! empty( $transaction_id ) ) {
+			WC_Gateway_Paypal::log(
+				'Order already has a transaction ID, cannot update shipping. Order ID: ' . $order->get_id() .
+				'. Transaction ID: ' . $transaction_id
+			);
+			$response = $this->get_update_shipping_error_response();
+			return new WP_REST_Response( $response, 422 );
+		}
+
 		if ( ! WC()->session ) {
 			WC()->session = new WC_Session_Handler();
 		}
diff --git a/plugins/woocommerce/src/Gateways/PayPal/Constants.php b/plugins/woocommerce/src/Gateways/PayPal/Constants.php
index 2e86c9d1f5..ef27a2d442 100644
--- a/plugins/woocommerce/src/Gateways/PayPal/Constants.php
+++ b/plugins/woocommerce/src/Gateways/PayPal/Constants.php
@@ -206,4 +206,13 @@ class Constants {
 	 * @since 10.5.0
 	 */
 	public const PAYPAL_ORDER_META_PAYMENT_SOURCE = '_paypal_payment_source';
+
+	/**
+	 * Meta key for storing PayPal shipping callback token in order meta.
+	 *
+	 * @var string
+	 *
+	 * @since 10.5.0
+	 */
+	public const PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN = '_paypal_shipping_callback_token';
 }
diff --git a/plugins/woocommerce/src/Gateways/PayPal/Request.php b/plugins/woocommerce/src/Gateways/PayPal/Request.php
index afa0512d0f..f341aea74f 100644
--- a/plugins/woocommerce/src/Gateways/PayPal/Request.php
+++ b/plugins/woocommerce/src/Gateways/PayPal/Request.php
@@ -636,9 +636,16 @@ class Request {
 			),
 			true
 		) ) {
+			$shipping_callback_token = $this->generate_shipping_callback_token( $order );
+			$callback_url            = add_query_arg(
+				'token',
+				$shipping_callback_token,
+				rest_url( 'wc/v3/paypal-standard/update-shipping' )
+			);
+
 			$params['payment_source'][ $payment_source ]['experience_context']['order_update_callback_config'] = array(
 				'callback_events' => array( 'SHIPPING_ADDRESS', 'SHIPPING_OPTIONS' ),
-				'callback_url'    => $this->normalize_url_for_paypal( rest_url( 'wc/v3/paypal-standard/update-shipping' ) ),
+				'callback_url'    => $this->normalize_url_for_paypal( $callback_url ),
 			);
 		}

@@ -907,6 +914,23 @@ class Request {
 		);
 	}

+	/**
+	 * Generate and store a shipping callback token for the order.
+	 * The token is stored in the database cache and can be validated later.
+	 *
+	 * @param WC_Order $order The order object.
+	 * @return string The generated token.
+	 */
+	private function generate_shipping_callback_token( WC_Order $order ): string {
+		$token = bin2hex( random_bytes( 32 ) );
+
+		// Store the token in order meta for validation.
+		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN, $token );
+		$order->save();
+
+		return $token;
+	}
+
 	/**
 	 * Normalize PayPal order shipping country code.
 	 *
diff --git a/plugins/woocommerce/src/Gateways/PayPal/WebhookHandler.php b/plugins/woocommerce/src/Gateways/PayPal/WebhookHandler.php
index ad30eeeceb..f04e02ecf9 100644
--- a/plugins/woocommerce/src/Gateways/PayPal/WebhookHandler.php
+++ b/plugins/woocommerce/src/Gateways/PayPal/WebhookHandler.php
@@ -85,6 +85,11 @@ class WebhookHandler {
 		if ( PayPalConstants::STATUS_APPROVED === $status ) {
 			\WC_Gateway_Paypal::log( 'PayPal payment approved. Order ID: ' . $order->get_id() );
 			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, $status );
+			// Clear the shipping callback token by setting it to an empty string.
+			// This is done to prevent the token from being used again for the same order.
+			// We are not deleting the meta key as we use the existence of the meta key to determine if the token was ever generated for this order.
+			$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_SHIPPING_CALLBACK_TOKEN, '' );
+			$order->save();
 			$order->add_order_note(
 				sprintf(
 					/* translators: %1$s: PayPal order ID */