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.'
+		);
+	}
+}