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.
*