Commit e411ddb8b17 for woocommerce

commit e411ddb8b175a98cf9154f3807dff9e2545d013f
Author: Mike Jolley <mike.jolley@me.com>
Date:   Mon Jun 29 10:42:59 2026 +0100

    Fix backslashes stripped from Agentic Checkout buyer and address fields (#65900)

    * Fix backslashes stripped from Agentic Checkout and Store API field validation

    * Add changelog entry for agentic checkout backslash fix

    * Add Store API field-validation backslash regression test and clarify test messages

    * Fix backslashes stripped when reading customer data from session

diff --git a/plugins/woocommerce/changelog/wooplug-6866-fix-backslash-agentic-checkout b/plugins/woocommerce/changelog/wooplug-6866-fix-backslash-agentic-checkout
new file mode 100644
index 00000000000..ff21677562f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6866-fix-backslash-agentic-checkout
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix silent backslash corruption in Agentic Checkout buyer/address fields and Store API checkout field validation. Calling wp_unslash() on JSON request body data that is never magic-quoted was stripping real backslashes typed by the buyer.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php b/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php
index 1821aaa2aef..e6ab8928453 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-customer-data-store-session.php
@@ -132,7 +132,7 @@ class WC_Customer_Data_Store_Session extends WC_Data_Store_WP implements WC_Cust
 							}
 						}
 					} else {
-						$customer->{"set_{$function_key}"}( wp_unslash( $data[ $session_key ] ) );
+						$customer->{"set_{$function_key}"}( $data[ $session_key ] );
 					}
 				}
 			}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index 575a73d1dbe..3a07b7605cb 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -277,8 +277,8 @@ class Checkout extends AbstractCartRoute {
 					continue;
 				}

-				// Clean the field value to trim whitespace.
-				$field_value = wc_clean( wp_unslash( $field_values[ $field_key ] ?? '' ) );
+				// Clean the field value to trim whitespace. Request body is JSON-decoded and never magic-quoted, so no wp_unslash().
+				$field_value = wc_clean( $field_values[ $field_key ] ?? '' );

 				if ( empty( $field_value ) ) {
 					if ( true === $field['required'] ) {
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/AgenticCheckoutUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/AgenticCheckoutUtils.php
index 496db75af86..b51d064fa02 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/AgenticCheckoutUtils.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/AgenticCheckoutUtils.php
@@ -168,26 +168,26 @@ class AgenticCheckoutUtils {
 	 */
 	public static function set_buyer_data( $buyer, $customer ) {
 		if ( isset( $buyer['first_name'] ) ) {
-			$first_name = wc_clean( wp_unslash( $buyer['first_name'] ) );
+			$first_name = wc_clean( $buyer['first_name'] );
 			$customer->set_billing_first_name( $first_name );
 			$customer->set_shipping_first_name( $first_name );
 		}

 		if ( isset( $buyer['last_name'] ) ) {
-			$last_name = wc_clean( wp_unslash( $buyer['last_name'] ) );
+			$last_name = wc_clean( $buyer['last_name'] );
 			$customer->set_billing_last_name( $last_name );
 			$customer->set_shipping_last_name( $last_name );
 		}

 		if ( isset( $buyer['email'] ) ) {
-			$email = sanitize_email( wp_unslash( $buyer['email'] ) );
+			$email = sanitize_email( $buyer['email'] );
 			if ( is_email( $email ) ) {
 				$customer->set_billing_email( $email );
 			}
 		}

 		if ( isset( $buyer['phone_number'] ) ) {
-			$phone = wc_clean( wp_unslash( $buyer['phone_number'] ) );
+			$phone = wc_clean( $buyer['phone_number'] );
 			$customer->set_billing_phone( $phone );
 		}

@@ -203,7 +203,7 @@ class AgenticCheckoutUtils {
 	public static function set_fulfillment_address( $address, $customer ) {
 		// Only parse and set name if provided and non-empty.
 		if ( ! empty( $address['name'] ) ) {
-			$name       = wc_clean( wp_unslash( $address['name'] ) );
+			$name       = wc_clean( $address['name'] );
 			$name_parts = explode( ' ', $name, 2 );
 			$first_name = $name_parts[0];
 			$last_name  = isset( $name_parts[1] ) ? $name_parts[1] : '';
@@ -218,12 +218,12 @@ class AgenticCheckoutUtils {
 		}

 		// Sanitize all address fields.
-		$line_one    = wc_clean( wp_unslash( $address['line_one'] ?? '' ) );
-		$line_two    = wc_clean( wp_unslash( $address['line_two'] ?? '' ) );
-		$city        = wc_clean( wp_unslash( $address['city'] ?? '' ) );
-		$state       = wc_clean( wp_unslash( $address['state'] ?? '' ) );
-		$postal_code = wc_clean( wp_unslash( $address['postal_code'] ?? '' ) );
-		$country     = wc_clean( wp_unslash( $address['country'] ?? '' ) );
+		$line_one    = wc_clean( $address['line_one'] ?? '' );
+		$line_two    = wc_clean( $address['line_two'] ?? '' );
+		$city        = wc_clean( $address['city'] ?? '' );
+		$state       = wc_clean( $address['state'] ?? '' );
+		$postal_code = wc_clean( $address['postal_code'] ?? '' );
+		$country     = wc_clean( $address['country'] ?? '' );

 		// Set shipping address fields.
 		$customer->set_shipping_address_1( $line_one );
@@ -279,7 +279,7 @@ class AgenticCheckoutUtils {
 	public static function set_billing_address( $address, $customer ) {
 		// Only parse and set name if provided and non-empty.
 		if ( ! empty( $address['name'] ) ) {
-			$name       = wc_clean( wp_unslash( $address['name'] ) );
+			$name       = wc_clean( $address['name'] );
 			$name_parts = explode( ' ', $name, 2 );
 			$first_name = $name_parts[0];
 			$last_name  = isset( $name_parts[1] ) ? $name_parts[1] : '';
@@ -290,12 +290,12 @@ class AgenticCheckoutUtils {
 		}

 		// Sanitize all address fields.
-		$line_one    = wc_clean( wp_unslash( $address['line_one'] ?? '' ) );
-		$line_two    = wc_clean( wp_unslash( $address['line_two'] ?? '' ) );
-		$city        = wc_clean( wp_unslash( $address['city'] ?? '' ) );
-		$state       = wc_clean( wp_unslash( $address['state'] ?? '' ) );
-		$postal_code = wc_clean( wp_unslash( $address['postal_code'] ?? '' ) );
-		$country     = wc_clean( wp_unslash( $address['country'] ?? '' ) );
+		$line_one    = wc_clean( $address['line_one'] ?? '' );
+		$line_two    = wc_clean( $address['line_two'] ?? '' );
+		$city        = wc_clean( $address['city'] ?? '' );
+		$state       = wc_clean( $address['state'] ?? '' );
+		$postal_code = wc_clean( $address['postal_code'] ?? '' );
+		$country     = wc_clean( $address['country'] ?? '' );

 		// Set billing address fields.
 		$customer->set_billing_address_1( $line_one );
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php
index 31f94e7f0cd..7f0b0eec713 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-customer-data-store-session-test.php
@@ -199,4 +199,34 @@ class WC_Customer_Data_Store_Session_Test extends WC_Unit_Test_Case {

 		$this->assertSame( '', $customer->get_shipping_address_2() );
 	}
+
+	/**
+	 * Ensure backslashes in customer fields survive a session save/read round-trip.
+	 *
+	 * Reproduces the Agentic Checkout flow where one request stores the address in the session and a
+	 * follow-up request reads it back: the session store must not wp_unslash() the data on read, or
+	 * real backslashes the buyer typed get stripped (session data is stored raw, never magic-quoted).
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/pull/65900
+	 */
+	public function test_backslashes_survive_session_round_trip() {
+		WC()->session->init();
+
+		$customer = new WC_Customer();
+		$customer->set_email( 'backslash@example.com' );
+		$customer->set_shipping_address_1( 'Apt 4\\B' );
+		$customer->set_shipping_city( 'C:\\Users' );
+		$customer->save();
+
+		$data_store = new WC_Customer_Data_Store_Session();
+		$data_store->save_to_session( $customer );
+
+		// Overwrite in memory, then read back from the session as the next request would.
+		$customer->set_shipping_address_1( 'overwritten' );
+		$customer->set_shipping_city( 'overwritten' );
+		$data_store->read( $customer );
+
+		$this->assertSame( 'Apt 4\\B', $customer->get_shipping_address_1(), 'Backslashes in the shipping address should survive the session round-trip.' );
+		$this->assertSame( 'C:\\Users', $customer->get_shipping_city(), 'Backslashes in the shipping city should survive the session round-trip.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
index e75464924df..b5cc8ed1dae 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
@@ -1567,6 +1567,71 @@ class AdditionalFields extends MockeryTestCase {
 		$this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) );
 	}

+	/**
+	 * Ensures backslashes survive field validation. The request body is JSON-decoded and never
+	 * magic-quoted, so Checkout::validate_callback() must not wp_unslash() the value before validating.
+	 */
+	public function test_placing_order_preserves_backslashes_in_validation() {
+		$id = 'plugin-namespace/backslash-validate';
+		\woocommerce_register_additional_checkout_field(
+			array(
+				'id'                => $id,
+				'label'             => 'Backslash Validate',
+				'location'          => 'order',
+				'type'              => 'text',
+				'validate_callback' => function ( $value ) {
+					// Passes only if the real backslash survived; wp_unslash() would have stripped it.
+					return false !== strpos( $value, '\\' );
+				},
+			)
+		);
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'   => (object) array(
+					'first_name'              => 'test',
+					'last_name'               => 'test',
+					'company'                 => '',
+					'address_1'               => 'test',
+					'address_2'               => '',
+					'city'                    => 'test',
+					'state'                   => '',
+					'postcode'                => 'cb241ab',
+					'country'                 => 'GB',
+					'phone'                   => '5555551234',
+					'email'                   => 'testaccount@test.com',
+					'plugin-namespace/gov-id' => 'my-gov-id',
+				),
+				'shipping_address'  => (object) array(
+					'first_name'              => 'test',
+					'last_name'               => 'test',
+					'company'                 => '',
+					'address_1'               => 'test',
+					'address_2'               => '',
+					'city'                    => 'test',
+					'state'                   => '',
+					'postcode'                => 'cb241ab',
+					'country'                 => 'GB',
+					'phone'                   => '5555551234',
+					'plugin-namespace/gov-id' => 'my-gov-id',
+				),
+				'payment_method'    => WC_Gateway_BACS::ID,
+				'additional_fields' => array(
+					'plugin-namespace/job-function' => 'engineering',
+					$id                             => 'C:\\Users',
+				),
+			)
+		);
+
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status(), print_r( $data, true ) );
+
+		\__internal_woocommerce_blocks_deregister_checkout_field( $id );
+	}
+
 	/**
 	 * Ensures that the provided validate callback works and prevents an order.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/StoreApi/Utilities/AgenticCheckoutUtilsTest.php b/plugins/woocommerce/tests/php/src/StoreApi/Utilities/AgenticCheckoutUtilsTest.php
index e71f60549c9..98ffb196733 100644
--- a/plugins/woocommerce/tests/php/src/StoreApi/Utilities/AgenticCheckoutUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/StoreApi/Utilities/AgenticCheckoutUtilsTest.php
@@ -195,4 +195,62 @@ class AgenticCheckoutUtilsTest extends \WC_Unit_Test_Case {
 			$result->get_error_message()
 		);
 	}
+
+	/**
+	 * @testdox Should preserve backslashes in buyer data, which arrives JSON-decoded and is never magic-quoted.
+	 */
+	public function test_set_buyer_data_preserves_backslashes() {
+		$customer = WC()->customer;
+
+		AgenticCheckoutUtils::set_buyer_data(
+			array(
+				'first_name' => 'C:\\Users',
+				'last_name'  => 'O\\Brien',
+			),
+			$customer
+		);
+
+		$this->assertSame( 'C:\\Users', $customer->get_billing_first_name(), 'Backslashes in first name should be preserved.' );
+		$this->assertSame( 'O\\Brien', $customer->get_billing_last_name(), 'Backslashes in last name should be preserved.' );
+	}
+
+	/**
+	 * @testdox Should preserve backslashes in fulfillment address fields.
+	 */
+	public function test_set_fulfillment_address_preserves_backslashes() {
+		$customer = WC()->customer;
+
+		AgenticCheckoutUtils::set_fulfillment_address(
+			array(
+				'name'        => 'O\\Brien Family',
+				'line_one'    => 'Apt 4\\B',
+				'city'        => 'Düsseldorf\\Nord',
+				'country'     => 'US',
+				'postal_code' => '90210',
+			),
+			$customer
+		);
+
+		$this->assertSame( 'O\\Brien', $customer->get_shipping_first_name(), 'Backslashes in the parsed first name should be preserved.' );
+		$this->assertSame( 'Apt 4\\B', $customer->get_shipping_address_1(), 'Backslashes in address line should be preserved.' );
+		$this->assertSame( 'Düsseldorf\\Nord', $customer->get_shipping_city(), 'Backslashes in city should be preserved.' );
+	}
+
+	/**
+	 * @testdox Should preserve backslashes in billing address fields.
+	 */
+	public function test_set_billing_address_preserves_backslashes() {
+		$customer = WC()->customer;
+
+		AgenticCheckoutUtils::set_billing_address(
+			array(
+				'name'     => 'O\\Brien Family',
+				'line_one' => 'Apt 4\\B',
+			),
+			$customer
+		);
+
+		$this->assertSame( 'O\\Brien', $customer->get_billing_first_name(), 'Backslashes in the parsed first name should be preserved.' );
+		$this->assertSame( 'Apt 4\\B', $customer->get_billing_address_1(), 'Backslashes in address line should be preserved.' );
+	}
 }