Commit 9b1751842b2 for woocommerce
commit 9b1751842b213c20d4a2271ad16369a1a6548575
Author: Mike Jolley <mike.jolley@me.com>
Date: Mon Jun 15 10:39:46 2026 +0100
Fix backslashes stripped from Store API address fields (#58214) (#65643)
* Fix backslashes stripped from Store API address fields
AbstractAddressSchema::sanitize_callback() (and BillingAddressSchema for the
email field) wrapped each value in wp_unslash(), an alias of stripslashes()
whose only job is to undo WordPress magic quotes. Magic quotes are applied
only to the $_POST/$_GET/$_REQUEST/$_COOKIE superglobals; the Store API reads
its request body via json_decode(), which is never magic-quoted, so
wp_unslash() stripped real backslashes the customer typed (e.g. "apt 4\" was
saved as "apt 4"). The legacy shortcut checkout reads magic-quoted $_POST data
and so unslashed correctly, hence the form-to-form inconsistency in the issue.
Remove wp_unslash() from the country, state, postcode and default cases of
AbstractAddressSchema, and from the email case of BillingAddressSchema. All
other sanitization (sanitize_text_field, wc_strtoupper, format_state,
wc_format_postcode, rest_sanitize_value_from_schema, wp_kses_array,
sanitize_email) is unchanged.
Closes #58214
* Fix customer and checkout field backslash handling
diff --git a/plugins/woocommerce/changelog/58214-fix-backslash-address-store-api b/plugins/woocommerce/changelog/58214-fix-backslash-address-store-api
new file mode 100644
index 00000000000..694cb3b002d
--- /dev/null
+++ b/plugins/woocommerce/changelog/58214-fix-backslash-address-store-api
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix silent backslash corruption in Store API (block checkout) address fields. Calling wp_unslash() on JSON body data that is never magic-quoted was stripping real backslashes from address_1, address_2, and other address fields.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store.php b/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store.php
index 4bebfbe1564..b1a120e0f9f 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store.php
@@ -271,7 +271,10 @@ class WC_Customer_Data_Store extends WC_Data_Store_WP implements WC_Customer_Dat
continue;
}
- if ( update_user_meta( $customer->get_id(), $meta_key, $customer->{"get_$prop"}( 'edit' ) ) ) {
+ $value = $customer->{"get_$prop"}( 'edit' );
+ $value = is_string( $value ) ? wp_slash( $value ) : $value;
+
+ if ( update_user_meta( $customer->get_id(), $meta_key, $value ) ) {
$updated_props[] = $prop;
}
}
@@ -297,7 +300,10 @@ class WC_Customer_Data_Store extends WC_Data_Store_WP implements WC_Customer_Dat
continue;
}
- if ( update_user_meta( $customer->get_id(), $meta_key, $customer->{"get_$prop"}( 'edit' ) ) ) {
+ $value = $customer->{"get_$prop"}( 'edit' );
+ $value = is_string( $value ) ? wp_slash( $value ) : $value;
+
+ if ( update_user_meta( $customer->get_id(), $meta_key, $value ) ) {
$updated_props[] = $prop;
}
}
@@ -322,7 +328,10 @@ class WC_Customer_Data_Store extends WC_Data_Store_WP implements WC_Customer_Dat
continue;
}
- if ( update_user_meta( $customer->get_id(), $meta_key, $customer->{"get_$prop"}( 'edit' ) ) ) {
+ $value = $customer->{"get_$prop"}( 'edit' );
+ $value = is_string( $value ) ? wp_slash( $value ) : $value;
+
+ if ( update_user_meta( $customer->get_id(), $meta_key, $value ) ) {
$updated_props[] = $prop;
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index fe3c8db4bed..419e2c0a8a5 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -127,16 +127,16 @@ abstract class AbstractAddressSchema extends AbstractSchema {
function ( $carry, $key ) use ( $address, $validation_util, $schema ) {
switch ( $key ) {
case 'country':
- $carry[ $key ] = wc_strtoupper( sanitize_text_field( wp_unslash( $address[ $key ] ) ) );
+ $carry[ $key ] = wc_strtoupper( sanitize_text_field( $address[ $key ] ) );
break;
case 'state':
- $carry[ $key ] = $validation_util->format_state( sanitize_text_field( wp_unslash( $address[ $key ] ) ), $address['country'] );
+ $carry[ $key ] = $validation_util->format_state( sanitize_text_field( $address[ $key ] ), $address['country'] );
break;
case 'postcode':
- $carry[ $key ] = $address['postcode'] ? wc_format_postcode( sanitize_text_field( wp_unslash( $address['postcode'] ) ), $address['country'] ) : '';
+ $carry[ $key ] = $address['postcode'] ? wc_format_postcode( sanitize_text_field( $address['postcode'] ), $address['country'] ) : '';
break;
default:
- $carry[ $key ] = rest_sanitize_value_from_schema( wp_unslash( $address[ $key ] ), $schema[ $key ], $key );
+ $carry[ $key ] = rest_sanitize_value_from_schema( $address[ $key ], $schema[ $key ], $key );
break;
}
if ( $this->additional_fields_controller->is_field( $key ) ) {
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php
index 793153d436c..0b73b95faa9 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/BillingAddressSchema.php
@@ -55,7 +55,7 @@ class BillingAddressSchema extends AbstractAddressSchema {
public function sanitize_callback( $address, $request, $param ) {
$address = parent::sanitize_callback( $address, $request, $param );
if ( isset( $address['email'] ) ) {
- $address['email'] = sanitize_email( wp_unslash( $address['email'] ) );
+ $address['email'] = sanitize_email( $address['email'] );
}
return $address;
}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
index 9b8b85c4b9b..b78eefee637 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
@@ -428,7 +428,7 @@ class CheckoutSchema extends AbstractSchema {
return $carry;
}
$field_schema = $properties[ $key ];
- $rest_sanitized = rest_sanitize_value_from_schema( wp_unslash( $fields[ $key ] ), $field_schema, $key );
+ $rest_sanitized = rest_sanitize_value_from_schema( $fields[ $key ], $field_schema, $key );
$rest_sanitized = $this->additional_fields_controller->sanitize_field( $key, $rest_sanitized );
$carry[ $key ] = $rest_sanitized;
return $carry;
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-test.php
index 34dbeb0d6f8..31f24555ed1 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-test.php
@@ -60,6 +60,33 @@ class WC_Customer_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertEquals( $username, $customer->get_username() );
}
+ /**
+ * @testdox A backslash in a customer address field survives a save/read round-trip.
+ *
+ * Addresses entered through the Store API (block checkout) are not magic-quoted, so the value
+ * reaches the customer object unslashed (e.g. "apt 4\"). When the data store persists it via
+ * WP's update_user_meta(), update_metadata() runs wp_unslash() on the value before writing,
+ * stripping the backslash. The customer fix in PR #65643 only stops the Store API schema from
+ * unslashing; the meta-persistence layer still corrupts the value for logged-in users.
+ *
+ * @link https://github.com/woocommerce/woocommerce/issues/58214
+ * @link https://github.com/woocommerce/woocommerce/pull/65643#pullrequestreview-4485832478
+ */
+ public function test_backslash_in_address_survives_save_and_read(): void {
+ $customer = WC_Helper_Customer::create_customer();
+
+ $customer->set_billing_address_2( 'apt 4\\' );
+ $customer->save();
+
+ $read_customer = new WC_Customer( $customer->get_id() );
+
+ $this->assertSame(
+ 'apt 4\\',
+ $read_customer->get_billing_address_2(),
+ 'The trailing backslash should be preserved when the customer address is persisted and read back.'
+ );
+ }
+
/**
* Handler for the wc_order_statuses filter, returns just 'pending" as the valid order statuses list.
*
diff --git a/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php b/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
new file mode 100644
index 00000000000..8d7b053e0ad
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
@@ -0,0 +1,115 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\StoreApi\Schemas\V1\BillingAddressSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests that AbstractAddressSchema::sanitize_callback() does not strip
+ * backslashes from address fields.
+ *
+ * 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.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/58214
+ */
+class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
+
+ /**
+ * The system under test. BillingAddressSchema is the concrete child of
+ * AbstractAddressSchema and exercises the shared sanitize_callback().
+ *
+ * @var BillingAddressSchema
+ */
+ private $sut;
+
+ /**
+ * Set up before test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $formatters = new Formatters();
+ $formatters->register( 'money', MoneyFormatter::class );
+ $formatters->register( 'html', HtmlFormatter::class );
+ $formatters->register( 'currency', CurrencyFormatter::class );
+
+ $extend = new ExtendSchema( $formatters );
+ $schema_controller = new SchemaController( $extend );
+ $this->sut = $schema_controller->get( BillingAddressSchema::IDENTIFIER );
+ }
+
+ /**
+ * Build a minimal valid address with the given overrides.
+ *
+ * @param array $overrides Fields to override on the base address.
+ * @return array
+ */
+ private function make_address( array $overrides = array() ): array {
+ return array_merge(
+ array(
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'company' => '',
+ 'address_1' => '123 Main Street',
+ 'address_2' => '',
+ 'city' => 'New York',
+ 'state' => 'NY',
+ 'postcode' => '10001',
+ 'country' => 'US',
+ 'phone' => '',
+ 'email' => 'jane@example.com',
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * @testdox Should preserve a trailing backslash in address_2.
+ */
+ public function test_preserves_trailing_backslash_in_address_2(): void {
+ $address = $this->make_address( array( 'address_2' => 'apt 4\\' ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( 'apt 4\\', $result['address_2'], 'A trailing backslash should not be stripped.' );
+ }
+
+ /**
+ * @testdox Should preserve a mid-string backslash in address_1.
+ */
+ public function test_preserves_mid_string_backslash_in_address_1(): void {
+ $address = $this->make_address( array( 'address_1' => 'a\\b Street' ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( 'a\\b Street', $result['address_1'], 'A mid-string backslash should not be stripped.' );
+ }
+
+ /**
+ * @testdox Should not corrupt plain backslash-free fields.
+ */
+ public function test_does_not_corrupt_backslash_free_fields(): void {
+ $address = $this->make_address(
+ array(
+ 'address_1' => '123 Main Street',
+ 'address_2' => 'Suite 100',
+ )
+ );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( '123 Main Street', $result['address_1'], 'A plain field should be unchanged.' );
+ $this->assertSame( 'Suite 100', $result['address_2'], 'A plain field should be unchanged.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/CheckoutSchemaTest.php b/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/CheckoutSchemaTest.php
new file mode 100644
index 00000000000..8e4f8375054
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/CheckoutSchemaTest.php
@@ -0,0 +1,119 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests that CheckoutSchema::sanitize_additional_fields() does not strip
+ * backslashes from additional checkout field values.
+ *
+ * Additional fields are read from the same JSON request body as the address
+ * fields, so json_decode() (never magic-quoted) delivers them already
+ * unslashed. Calling wp_unslash() on that data silently drops real
+ * backslashes the user typed (e.g. "apt 4\").
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/58214
+ * @see https://github.com/woocommerce/woocommerce/pull/65643#pullrequestreview-4485832478
+ */
+class CheckoutSchemaTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var CheckoutSchema
+ */
+ private $sut;
+
+ /**
+ * The id of the additional text field registered for these tests.
+ *
+ * @var string
+ */
+ private $field_id = 'plugin-namespace/gov-id';
+
+ /**
+ * Set up before test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+
+ woocommerce_register_additional_checkout_field(
+ array(
+ 'id' => $this->field_id,
+ 'label' => 'Government ID',
+ 'location' => 'contact',
+ 'type' => 'text',
+ 'required' => false,
+ )
+ );
+
+ $formatters = new Formatters();
+ $formatters->register( 'money', MoneyFormatter::class );
+ $formatters->register( 'html', HtmlFormatter::class );
+ $formatters->register( 'currency', CurrencyFormatter::class );
+
+ $extend = new ExtendSchema( $formatters );
+ $schema_controller = new SchemaController( $extend );
+ $this->sut = $schema_controller->get( CheckoutSchema::IDENTIFIER );
+ }
+
+ /**
+ * Tear down after test.
+ */
+ public function tearDown(): void {
+ __internal_woocommerce_blocks_deregister_checkout_field( $this->field_id );
+ remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should preserve a trailing backslash in an additional text field.
+ */
+ public function test_preserves_trailing_backslash_in_additional_field(): void {
+ $result = $this->sut->sanitize_additional_fields( array( $this->field_id => 'apt 4\\' ) );
+
+ $this->assertSame(
+ 'apt 4\\',
+ $result[ $this->field_id ],
+ 'A trailing backslash should not be stripped from an additional checkout field.'
+ );
+ }
+
+ /**
+ * @testdox Should preserve a mid-string backslash in an additional text field.
+ */
+ public function test_preserves_mid_string_backslash_in_additional_field(): void {
+ $result = $this->sut->sanitize_additional_fields( array( $this->field_id => 'a\\b' ) );
+
+ $this->assertSame(
+ 'a\\b',
+ $result[ $this->field_id ],
+ 'A mid-string backslash should not be stripped from an additional checkout field.'
+ );
+ }
+
+ /**
+ * @testdox Should not corrupt a backslash-free additional text field.
+ */
+ public function test_does_not_corrupt_backslash_free_additional_field(): void {
+ $result = $this->sut->sanitize_additional_fields( array( $this->field_id => 'AB12345' ) );
+
+ $this->assertSame(
+ 'AB12345',
+ $result[ $this->field_id ],
+ 'A plain additional checkout field should be unchanged.'
+ );
+ }
+}