Commit 8b44419a0fe for woocommerce

commit 8b44419a0fe444cb087a55fedf5ab87eedb812e2
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Jun 19 15:44:21 2026 +0100

    Validate address on the Store API checkout-order endpoint before persisting changes (#65787)

    * Validate address before persisting on the Store API checkout-order endpoint

    The checkout-order endpoint (POST /wc/store/v1/checkout/{order_id}) persisted the
    request address and recalculated order totals before validating the address, unlike
    the other checkout routes which validate first. A request that failed validation still
    returned 400 but left the order and customer updated with the rejected data.

    Move address validation ahead of persistence via the new
    OrderController::validate_existing_order_before_update(): the address is set on the
    order in memory, validated, and only saved if validation passes. Adds a regression test.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Trim redundant setup from checkout-order regression test

    WC_Helper_Order::create_order() already sets the billing address the test re-stated as
    shipping, and the assertions capture the originals dynamically, so the pre-set shipping
    block is unnecessary. Also drop the total_tax assertion (the total assertion already covers
    calculate_totals) and collapse the route registration to the sibling two-line form.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Fall back to billing address when shipping address is omitted

    A checkout-order request with only a billing address left $request['shipping_address']
    null, which WC_Order::set_shipping_address() (typed array) rejected with a TypeError.
    Default the shipping address to the billing address, matching the fallback the customer
    fields already used.

    * Fix PHPCS alignment on the billing assignment

    The blank lines and comments now separating the address assignments put each in its own
    alignment group, so the leftover two-space gap before the $billing = assignment is no
    longer aligned. Use a single space.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Remove PHPStan baseline entries resolved by the instanceof guard

    The instanceof \WC_Order narrowing in CheckoutOrder::get_route_post_response() resolves
    three errors that remained baselined after rebasing onto trunk (the needs_payment() call,
    the validate_existing_order_before_payment() argument type, and the related @param doc
    type). Remove the now-unmatched entries so the baseline stays in sync.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-store-api-checkout-order-address-validation b/plugins/woocommerce/changelog/fix-store-api-checkout-order-address-validation
new file mode 100644
index 00000000000..415ead31f63
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-checkout-order-address-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: validate address data on the checkout-order endpoint before it is persisted, so a rejected request can no longer modify an existing order's address or totals.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 831cbda12d2..bb02ff523fc 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -70585,12 +70585,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/Checkout.php

-		-
-			message: '#^@param WC_Order \$order does not accept actual type of parameter\: WC_Order\|WC_Order_Refund\.$#'
-			identifier: parameter.phpDocType
-			count: 1
-			path: src/StoreApi/Routes/V1/CheckoutOrder.php
-
 		-
 			message: '#^Access to an undefined property WooCommerce\:\:\$payment_gateways\.$#'
 			identifier: property.notFound
@@ -70609,12 +70603,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/CheckoutOrder.php

-		-
-			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:needs_payment\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: src/StoreApi/Routes/V1/CheckoutOrder.php
-
 		-
 			message: '#^Cannot access offset string on 0\|0\.0\|''''\|''0''\|array\<string, array\|string\>\|false\|null\.$#'
 			identifier: offsetAccess.nonOffsetAccessible
@@ -70789,12 +70777,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/CheckoutOrder.php

-		-
-			message: '#^Parameter \#1 \$order of method Automattic\\WooCommerce\\StoreApi\\Utilities\\OrderController\:\:validate_existing_order_before_payment\(\) expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/StoreApi/Routes/V1/CheckoutOrder.php
-
 		-
 			message: '#^Parameter \#1 \$redirect_url of method Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult\:\:set_redirect_url\(\) expects array, string given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
index 2f6f57369b9..354a7280d66 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CheckoutOrder.php
@@ -109,7 +109,7 @@ class CheckoutOrder extends AbstractCartRoute {
 		$order_id    = absint( $request['id'] );
 		$this->order = wc_get_order( $order_id );

-		if ( ! $this->order || ! $this->order->needs_payment() ) {
+		if ( ! $this->order instanceof \WC_Order || ! $this->order->needs_payment() ) {
 			return new \WP_Error(
 				'invalid_order_update_status',
 				__( 'This order cannot be paid for.', 'woocommerce' )
@@ -119,8 +119,8 @@ class CheckoutOrder extends AbstractCartRoute {
 		/**
 		 * Process request data.
 		 *
-		 * Note: Customer data is persisted from the request first so that OrderController::update_addresses_from_cart
-		 * uses the up to date customer address.
+		 * The order address is validated before anything is persisted, so a rejected request
+		 * cannot mutate the existing order or the customer record.
 		 */
 		$this->update_billing_address( $request );
 		$this->update_order_from_request( $request );
@@ -186,32 +186,39 @@ class CheckoutOrder extends AbstractCartRoute {
 	protected function cart_updated( \WP_REST_Request $request ) {}

 	/**
-	 * Updates the current customer session using data from the request (e.g. address data).
+	 * Applies the billing and shipping address from the request to the order and customer.
 	 *
-	 * Address session data is synced to the order itself later on by OrderController::update_order_from_cart()
+	 * The address is set on the order and validated before anything is persisted, so a rejected
+	 * request cannot mutate the order or the customer. wc()->customer is saved on shutdown (see
+	 * WooCommerce::initialize_cart()), so its address fields are only set once validation passes.
+	 *
+	 * @throws RouteException When the order address fails validation.
 	 *
 	 * @param \WP_REST_Request $request Full details about the request.
 	 */
 	private function update_billing_address( \WP_REST_Request $request ) {
 		$customer = wc()->customer;
-		$billing  = $request['billing_address'];
-		$shipping = $request['shipping_address'];

 		// Billing address is a required field.
+		$billing = $request['billing_address'];
+
+		// If shipping address (optional field) was not provided, set it to the given billing address (required field).
+		$shipping = $request['shipping_address'] ?? $billing;
+
+		$this->order->set_billing_address( $billing );
+		$this->order->set_shipping_address( $shipping );
+		$this->order_controller->validate_existing_order_before_update( $this->order );
+
+		// Update customer object with validated order addresses.
 		foreach ( $billing as $key => $value ) {
 			if ( is_callable( [ $customer, "set_billing_$key" ] ) ) {
 				$customer->{"set_billing_$key"}( $value );
 			}
 		}

-		// If shipping address (optional field) was not provided, set it to the given billing address (required field).
-		$shipping_address_values = $shipping ?? $billing;
-
-		foreach ( $shipping_address_values as $key => $value ) {
+		foreach ( $shipping as $key => $value ) {
 			if ( is_callable( [ $customer, "set_shipping_$key" ] ) ) {
 				$customer->{"set_shipping_$key"}( $value );
-			} elseif ( 'phone' === $key ) {
-				$customer->update_meta_data( 'shipping_phone', $value );
 			}
 		}

@@ -226,9 +233,6 @@ class CheckoutOrder extends AbstractCartRoute {
 		do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, $request );

 		$customer->save();
-
-		$this->order->set_billing_address( $billing );
-		$this->order->set_shipping_address( $shipping );
 		$this->order->save();
 		$this->order->calculate_totals();
 	}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
index 349ff4a4204..e718cb690ac 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
@@ -174,6 +174,18 @@ class OrderController {
 		$this->perform_custom_order_validation( $order );
 	}

+	/**
+	 * Validate an existing order's address data before the order is updated from the request.
+	 *
+	 * Runs before any request data is persisted so a rejected address cannot mutate the order.
+	 *
+	 * @throws RouteException Exception if invalid data is detected.
+	 * @param \WC_Order $order Order object.
+	 */
+	public function validate_existing_order_before_update( \WC_Order $order ): void {
+		$this->validate_addresses( $order, $order->needs_shipping() );
+	}
+
 	/**
 	 * Perform custom order validation via WooCommerce hooks.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index a918122f908..3f4e9080b03 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
 use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use Automattic\WooCommerce\StoreApi\Routes\V1\Checkout as CheckoutRoute;
+use Automattic\WooCommerce\StoreApi\Routes\V1\CheckoutOrder as CheckoutOrderRoute;
 use Automattic\WooCommerce\StoreApi\SchemaController;
 use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
@@ -73,6 +74,8 @@ class Checkout extends MockeryTestCase {
 		$schema_controller = new SchemaController( $this->mock_extend );
 		$route             = new CheckoutRoute( $schema_controller, $schema_controller->get( 'checkout' ) );
 		register_rest_route( $route->get_namespace(), $route->get_path(), $route->get_args(), true );
+		$order_route = new CheckoutOrderRoute( $schema_controller, $schema_controller->get( 'checkout-order' ) );
+		register_rest_route( $order_route->get_namespace(), $order_route->get_path(), $order_route->get_args(), true );

 		$fixtures = new FixtureData();
 		$fixtures->payments_enable_bacs();
@@ -1557,6 +1560,60 @@ class Checkout extends MockeryTestCase {
 		$this->assertStringContainsString( 'Sorry, we do not allow orders from the provided country (France)', $response->get_data()['message'] );
 	}

+	/**
+	 * @testdox Existing order payment should not persist address data when country validation fails.
+	 */
+	public function test_checkout_order_does_not_persist_invalid_country_address() {
+		update_option( 'woocommerce_allowed_countries', 'specific' );
+		update_option( 'woocommerce_specific_allowed_countries', array( 'US' ) );
+		update_option( 'woocommerce_ship_to_countries', 'specific' );
+		update_option( 'woocommerce_specific_ship_to_countries', array( 'US' ) );
+
+		$order = \WC_Helper_Order::create_order( 0 );
+
+		$original_billing_country  = $order->get_billing_country();
+		$original_shipping_country = $order->get_shipping_country();
+		$original_total            = $order->get_total();
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout/' . $order->get_id() );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_query_params(
+			array(
+				'key'           => $order->get_order_key(),
+				'billing_email' => $order->get_billing_email(),
+			)
+		);
+		// Forbidden country (IN) on both addresses is what should trigger the rejection.
+		$invalid_address = array(
+			'first_name' => 'Test',
+			'last_name'  => 'Kumar',
+			'company'    => '',
+			'address_1'  => '1 MG Road',
+			'address_2'  => '',
+			'city'       => 'Mumbai',
+			'state'      => 'MH',
+			'postcode'   => '400001',
+			'country'    => 'IN',
+			'phone'      => '',
+		);
+		$request->set_body_params(
+			array(
+				'billing_address'  => array_merge( $invalid_address, array( 'email' => $order->get_billing_email() ) ),
+				'shipping_address' => $invalid_address,
+				'payment_method'   => WC_Gateway_BACS::ID,
+			)
+		);
+
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_address_country', $response->get_data()['code'] );
+
+		$stored_order = wc_get_order( $order->get_id() );
+		$this->assertEquals( $original_billing_country, $stored_order->get_billing_country() );
+		$this->assertEquals( $original_shipping_country, $stored_order->get_shipping_country() );
+		$this->assertEquals( $original_total, $stored_order->get_total() );
+	}
+
 	/**
 	 * Helper method to register custom order status.
 	 *