Commit b975e26c80d for woocommerce

commit b975e26c80d9cbbe31b15dafd125b5ac1eb2910c
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Wed Oct 7 17:33:17 2026 +0100

    Fix Store API address entity encoding (#69161)

    * Fix Store API address entity encoding

    * Add changelog entry for Store API address fix

    * Limit address fix to the Store API

    * Simplify Store API address regression tests

    * Add Pay for Order address response coverage

    * Fix Pay for Order address regression coverage

diff --git a/plugins/woocommerce/changelog/wooplug-7636-store-api-address-roundtrip b/plugins/woocommerce/changelog/wooplug-7636-store-api-address-roundtrip
new file mode 100644
index 00000000000..4042d0980ec
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7636-store-api-address-roundtrip
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve plain-text address values across Store API checkout round trips.
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index 4b1595cd17a..58a462410f8 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -125,7 +125,7 @@ abstract class AbstractAddressSchema extends AbstractSchema {
 		$address = array_intersect_key( $address, $schema );
 		$address = array_reduce(
 			array_keys( $address ),
-			function ( $carry, $key ) use ( $address, $validation_util, $schema ) {
+			function ( $carry, $key ) use ( $address, $validation_util, $sanitization_util, $schema ) {
 				switch ( $key ) {
 					case 'country':
 						$carry[ $key ] = wc_strtoupper( sanitize_text_field( $address[ $key ] ) );
@@ -147,6 +147,7 @@ abstract class AbstractAddressSchema extends AbstractSchema {
 				}
 				if ( $this->additional_fields_controller->is_field( $key ) ) {
 					$carry[ $key ] = $this->additional_fields_controller->sanitize_field( $key, $carry[ $key ] );
+					$carry[ $key ] = $sanitization_util->wp_kses_array( [ $key => $carry[ $key ] ] )[ $key ];
 				}
 				return $carry;
 			},
@@ -159,7 +160,7 @@ abstract class AbstractAddressSchema extends AbstractSchema {
 			$address['phone'] = wc_remove_non_displayable_chars( $address['phone'] );
 		}

-		return $sanitization_util->wp_kses_array( $address );
+		return $address;
 	}

 	/**
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php
index d6c4b262857..774977e5fc0 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php
@@ -133,8 +133,10 @@ class BillingAddressSchema extends AbstractAddressSchema {
 					$address_object[ $key ] = (bool) $value;
 				} elseif ( 'email' === $key ) {
 					$address_object[ $key ] = sanitize_email( $value );
-				} else {
+				} elseif ( $this->additional_fields_controller->is_field( $key ) ) {
 					$address_object[ $key ] = $this->prepare_html_response( $value );
+				} else {
+					$address_object[ $key ] = sanitize_text_field( $value );
 				}
 			}
 			return $address_object;
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php
index 678e9c60a24..a67f7cd47da 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php
@@ -240,7 +240,9 @@ class CartShippingRateSchema extends AbstractSchema {
 	protected function prepare_package_destination_response( $package ) {
 		// If address_1 fails check address for back compatibility.
 		$address = isset( $package['destination']['address_1'] ) ? $package['destination']['address_1'] : $package['destination']['address'];
-		return (object) $this->prepare_html_response(
+
+		return (object) array_map(
+			'sanitize_text_field',
 			[
 				'address_1' => $address,
 				'address_2' => $package['destination']['address_2'],
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShippingAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShippingAddressSchema.php
index 26c0da47f36..e1bde21b939 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShippingAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShippingAddressSchema.php
@@ -71,8 +71,10 @@ class ShippingAddressSchema extends AbstractAddressSchema {
 			foreach ( $address_object as $key => $value ) {
 				if ( isset( $this->get_properties()[ $key ]['type'] ) && 'boolean' === $this->get_properties()[ $key ]['type'] ) {
 					$address_object[ $key ] = (bool) $value;
-				} else {
+				} elseif ( $this->additional_fields_controller->is_field( $key ) ) {
 					$address_object[ $key ] = $this->prepare_html_response( $value );
+				} else {
+					$address_object[ $key ] = sanitize_text_field( $value );
 				}
 			}
 			return $address_object;
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 0cd9cd36ded..fde487acd5b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -929,6 +929,53 @@ class Checkout extends \WP_Test_REST_TestCase {
 		);
 	}

+	/**
+	 * @testdox Address values should round-trip through the cart response and checkout without HTML encoding.
+	 */
+	public function test_address_values_round_trip_without_html_encoding(): void {
+		$billing_address             = $this->get_fallback_billing_address();
+		$billing_address['company']  = 'AT&T';
+		$shipping_address            = $this->get_fallback_shipping_address();
+		$shipping_address['company'] = "St John's";
+
+		$update_request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/update-customer' );
+		$update_request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$update_request->set_body_params(
+			array(
+				'billing_address'  => (object) $billing_address,
+				'shipping_address' => (object) $shipping_address,
+			)
+		);
+
+		$update_response = rest_get_server()->dispatch( $update_request );
+		$this->assertSame( 200, $update_response->get_status(), print_r( $update_response->get_data(), true ) );
+		$cart_data                 = $update_response->get_data();
+		$response_billing_address  = (array) $cart_data['billing_address'];
+		$response_shipping_address = (array) $cart_data['shipping_address'];
+
+		$this->assertSame( 'AT&T', $response_billing_address['company'] );
+		$this->assertSame( "St John's", $response_shipping_address['company'] );
+
+		$checkout_request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$checkout_request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$checkout_request->set_body_params(
+			array(
+				'billing_address'  => (object) $response_billing_address,
+				'shipping_address' => (object) $response_shipping_address,
+				'payment_method'   => WC_Gateway_BACS::ID,
+				'expected_total'   => '3000',
+			)
+		);
+
+		$checkout_response = rest_get_server()->dispatch( $checkout_request );
+		$this->assertSame( 200, $checkout_response->get_status(), print_r( $checkout_response->get_data(), true ) );
+
+		$order = wc_get_order( $checkout_response->get_data()['order_id'] );
+		$this->assertInstanceOf( \WC_Order::class, $order );
+		$this->assertSame( 'AT&T', $order->get_billing_company( 'edit' ) );
+		$this->assertSame( "St John's", $order->get_shipping_company( 'edit' ) );
+	}
+
 	/**
 	 * When the cart needs shipping and the request omits the shipping address, the billing address is used as the
 	 * shipping address.
@@ -2392,6 +2439,58 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertStringContainsString( 'Sorry, we do not allow orders from the provided country (France)', $response->get_data()['message'] );
 	}

+	/**
+	 * @testdox Pay for Order should store and return address fields as plain text without HTML encoding.
+	 */
+	public function test_checkout_order_address_values_round_trip_without_html_encoding(): void {
+		$order                       = \WC_Helper_Order::create_order( 0 );
+		$billing_address             = array(
+			'first_name' => 'Test',
+			'last_name'  => 'User',
+			'company'    => 'AT&T',
+			'address_1'  => '123 Test St',
+			'address_2'  => '',
+			'city'       => 'Test City',
+			'state'      => 'CA',
+			'postcode'   => '90210',
+			'country'    => 'US',
+			'email'      => $order->get_billing_email(),
+			'phone'      => '555-32123',
+		);
+		$shipping_address            = $billing_address;
+		$shipping_address['company'] = "St John's";
+		unset( $shipping_address['email'] );
+
+		$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(),
+			)
+		);
+		$request->set_body_params(
+			array(
+				'billing_address'  => $billing_address,
+				'shipping_address' => $shipping_address,
+				'payment_method'   => WC_Gateway_BACS::ID,
+			)
+		);
+
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+		$this->assertSame( 200, $response->get_status(), print_r( $data, true ) );
+
+		$response_billing_address  = (array) $data['billing_address'];
+		$response_shipping_address = (array) $data['shipping_address'];
+		$this->assertSame( 'AT&T', $response_billing_address['company'] );
+		$this->assertSame( "St John's", $response_shipping_address['company'] );
+
+		$stored_order = wc_get_order( $order->get_id() );
+		$this->assertSame( 'AT&T', $stored_order->get_billing_company( 'edit' ) );
+		$this->assertSame( "St John's", $stored_order->get_shipping_company( 'edit' ) );
+	}
+
 	/**
 	 * @testdox Existing order payment should not persist address data when country validation fails.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
index 896b7ce014d..e4eb267fe61 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
@@ -13,15 +13,12 @@ use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
 use WC_Unit_Test_Case;

 /**
- * Tests that AbstractAddressSchema::sanitize_callback() does not strip
- * backslashes from address fields.
+ * Tests Store API address sanitization and response formatting.
  *
- * The Store API reads a JSON request body via json_decode(), which is never
- * subject to WordPress "magic quotes". Calling wp_unslash() on that data used
- * to silently drop real backslashes the user typed (e.g. "apt 4\"). These
- * tests guard against a regression of that behaviour.
+ * JSON address values are not magic-quoted, so literal backslashes must remain intact. Address fields are plain text and must not be HTML encoded during a cart or checkout round trip.
  *
  * @see https://github.com/woocommerce/woocommerce/issues/58214
+ * @see https://github.com/woocommerce/woocommerce/issues/68162
  */
 class AbstractAddressSchemaTest extends WC_Unit_Test_Case {

@@ -186,6 +183,29 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Should sanitize address fields as plain text without encoding punctuation.
+	 */
+	public function test_sanitizes_address_fields_as_plain_text(): void {
+		$address = $this->make_address( array( 'company' => 'AT&T <b>Marketing</b>' ) );
+
+		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+		$this->assertSame( 'AT&T Marketing', $result['company'] );
+	}
+
+	/**
+	 * @testdox Should return address fields as sanitized plain text without applying typography.
+	 */
+	public function test_get_item_response_returns_address_fields_as_plain_text(): void {
+		$customer = new \WC_Customer();
+		$customer->set_billing_company( 'AT&T <b>Marketing</b>' );
+
+		$result = $this->sut->get_item_response( $customer );
+
+		$this->assertSame( 'AT&T Marketing', $result['company'] );
+	}
+
 	/**
 	 * @testdox Should not texturize billing email addresses in API responses.
 	 */
@@ -271,19 +291,14 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should not run an additional address field through sanitize_text_field.
-	 *
-	 * Additional fields are sanitized by their own field type (sanitize_field()), not by the
-	 * core-field sanitization added to the default case of the switch above. A text field's
-	 * default sanitize() is a no-op, so a percent-encoded run untouched by wp_kses() is
-	 * evidence the new sanitize_text_field() call was skipped for this key.
+	 * @testdox Should preserve the existing sanitization of additional address fields.
 	 */
-	public function test_does_not_sanitize_additional_address_field_like_a_core_field(): void {
-		$address = $this->make_address( array( $this->field_id => 'Suite%20100' ) );
+	public function test_preserves_additional_address_field_sanitization(): void {
+		$address = $this->make_address( array( $this->field_id => 'Suite%20100 & <b>note</b>' ) );

 		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );

-		$this->assertSame( 'Suite%20100', $result[ $this->field_id ] );
+		$this->assertSame( 'Suite%20100 &amp; note', $result[ $this->field_id ] );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/CartShippingRateSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/CartShippingRateSchemaTest.php
index c40f669e983..e130b7924a5 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/CartShippingRateSchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/CartShippingRateSchemaTest.php
@@ -73,6 +73,28 @@ class CartShippingRateSchemaTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should return package destination addresses as sanitized plain text.
+	 */
+	public function test_package_destination_uses_plain_text_values(): void {
+		$package = array(
+			'destination' => array(
+				'address_1' => '1 Rock & Roll <script>alert("x")</script>',
+				'address_2' => '',
+				'city'      => 'Beverly Hills',
+				'state'     => 'CA',
+				'postcode'  => '90210',
+				'country'   => 'US',
+			),
+		);
+
+		$method = ( new ReflectionClass( $this->sut ) )->getMethod( 'prepare_package_destination_response' );
+		$method->setAccessible( true );
+		$response = $method->invoke( $this->sut, $package );
+
+		$this->assertSame( '1 Rock & Roll', $response->address_1 );
+	}
+
 	/**
 	 * Invoke the protected get_rate_response method.
 	 *