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