Commit 9d611f856df for woocommerce
commit 9d611f856df323232d43494b23cf40eb1898e590
Author: Mike Jolley <mike.jolley@me.com>
Date: Mon Nov 9 15:08:05 2026 +0000
Keep backslashes in product and coupon fields saved outside the admin (#70240)
* Fix backslashes stripped from product and coupon post fields
The product, variation and coupon data stores passed unslashed values to wp_insert_post() and wp_update_post(), which unslash their input, so backslashes were removed from names, descriptions and coupon codes. Slash the post data before saving, and drop the workarounds that relied on the old strip: the REST controllers and the product abilities now use wp_kses_post() instead of wp_filter_post_kses(), and the CSV importer no longer doubles the backslash in literal \n text.
* Add changelog entry for backslashes in product and coupon fields
* Fix backslashes stripped from coupon codes applied through the Store API
REST request params are already unslashed, so wp_unslash() removed backslashes from the code and coupons such as save\10 no longer matched once their codes are stored with the backslash. cart/remove-coupon already reads the code as-is.
* Update changelog entry for backslashes in applied coupon codes
* Add docblocks for the woocommerce_new_*_data filters
Docblocks type the filtered post data as an array, so PHPStan no longer needs an ignore on the wp_slash() call, and note that the values are unslashed.
* Keep the old behavior for post data that is already slashed
Extensions that pass already-slashed values to the setters, such as the output of wp_filter_post_kses(), relied on the data stores not slashing post data. Leave values that are already slashed as they are, so those extensions keep working while they migrate. This check is temporary.
* Align assignments in the product REST backslash tests
* Remove the resolved wp_update_post baseline entry for the product data store
diff --git a/plugins/woocommerce/changelog/fix-69755-backslashes-in-product-coupon-fields b/plugins/woocommerce/changelog/fix-69755-backslashes-in-product-coupon-fields
new file mode 100644
index 00000000000..aa04061f312
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-69755-backslashes-in-product-coupon-fields
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep backslashes in product names, descriptions and coupon codes saved through the REST API, CSV import or code, and in coupons applied at checkout.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
index c4b7f552bfe..851e114ae93 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
@@ -6,6 +6,7 @@
*/
use Automattic\WooCommerce\Internal\Caches\CouponCodeLookupInvalidator;
+use Automattic\WooCommerce\Internal\DataStores\PostDataSlashing;
if ( ! defined( 'ABSPATH' ) ) {
exit;
@@ -82,23 +83,30 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
$coupon->set_date_created( time() );
}
- $coupon_id = wp_insert_post(
- apply_filters(
- 'woocommerce_new_coupon_data',
- array(
- 'post_type' => 'shop_coupon',
- 'post_status' => $coupon->get_status( 'edit' ) ? $coupon->get_status( 'edit' ) : 'publish',
- 'post_author' => get_current_user_id(),
- 'post_title' => $coupon->get_code( 'edit' ),
- 'post_content' => '',
- 'post_excerpt' => $coupon->get_description( 'edit' ),
- 'post_date' => gmdate( 'Y-m-d H:i:s', $coupon->get_date_created()->getOffsetTimestamp() ),
- 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $coupon->get_date_created()->getTimestamp() ),
- )
- ),
- true
+ /**
+ * Filters the post data used to create a coupon.
+ *
+ * Values are unslashed. They are slashed before being passed to wp_insert_post().
+ *
+ * @since 3.0.0
+ * @param array $data Post data for wp_insert_post().
+ */
+ $post_data = apply_filters(
+ 'woocommerce_new_coupon_data',
+ array(
+ 'post_type' => 'shop_coupon',
+ 'post_status' => $coupon->get_status( 'edit' ) ? $coupon->get_status( 'edit' ) : 'publish',
+ 'post_author' => get_current_user_id(),
+ 'post_title' => $coupon->get_code( 'edit' ),
+ 'post_content' => '',
+ 'post_excerpt' => $coupon->get_description( 'edit' ),
+ 'post_date' => gmdate( 'Y-m-d H:i:s', $coupon->get_date_created()->getOffsetTimestamp() ),
+ 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $coupon->get_date_created()->getTimestamp() ),
+ )
);
+ $coupon_id = wp_insert_post( PostDataSlashing::slash( $post_data ), true );
+
if ( $coupon_id ) {
$coupon->set_id( $coupon_id );
$this->update_post_meta( $coupon );
@@ -215,7 +223,7 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
$GLOBALS['wpdb']->update( $GLOBALS['wpdb']->posts, $post_data, array( 'ID' => $coupon->get_id() ) );
clean_post_cache( $coupon->get_id() );
} else {
- wp_update_post( array_merge( array( 'ID' => $coupon->get_id() ), $post_data ) );
+ wp_update_post( PostDataSlashing::slash( array_merge( array( 'ID' => $coupon->get_id() ), $post_data ) ) );
}
$coupon->read_meta_data( true ); // Refresh internal meta data, in case things were hooked into `save_post` or another WP hook.
}
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index 509e0e34ceb..b6f22875639 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Internal\DataStores\PostDataSlashing;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Utilities\NumberUtil;
use Automattic\WooCommerce\Enums\ProductStockStatus;
@@ -215,29 +216,36 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$product->set_date_created( time() );
}
- $id = wp_insert_post(
- apply_filters(
- 'woocommerce_new_product_data',
- array(
- 'post_type' => 'product',
- 'post_status' => $product->get_status() ? $product->get_status() : ProductStatus::PUBLISH,
- 'post_author' => get_current_user_id(),
- 'post_title' => $product->get_name() ? $product->get_name() : __( 'Product', 'woocommerce' ),
- 'post_content' => $product->get_description(),
- 'post_excerpt' => $product->get_short_description(),
- 'post_parent' => $product->get_parent_id(),
- 'comment_status' => $product->get_reviews_allowed() ? 'open' : 'closed',
- 'ping_status' => 'closed',
- 'menu_order' => $product->get_menu_order(),
- 'post_password' => $product->get_post_password( 'edit' ),
- 'post_date' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getOffsetTimestamp() ),
- 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getTimestamp() ),
- 'post_name' => $product->get_slug( 'edit' ),
- )
- ),
- true
+ /**
+ * Filters the post data used to create a product.
+ *
+ * Values are unslashed. They are slashed before being passed to wp_insert_post().
+ *
+ * @since 3.0.0
+ * @param array $data Post data for wp_insert_post().
+ */
+ $post_data = apply_filters(
+ 'woocommerce_new_product_data',
+ array(
+ 'post_type' => 'product',
+ 'post_status' => $product->get_status() ? $product->get_status() : ProductStatus::PUBLISH,
+ 'post_author' => get_current_user_id(),
+ 'post_title' => $product->get_name() ? $product->get_name() : __( 'Product', 'woocommerce' ),
+ 'post_content' => $product->get_description(),
+ 'post_excerpt' => $product->get_short_description(),
+ 'post_parent' => $product->get_parent_id(),
+ 'comment_status' => $product->get_reviews_allowed() ? 'open' : 'closed',
+ 'ping_status' => 'closed',
+ 'menu_order' => $product->get_menu_order(),
+ 'post_password' => $product->get_post_password( 'edit' ),
+ 'post_date' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getOffsetTimestamp() ),
+ 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getTimestamp() ),
+ 'post_name' => $product->get_slug( 'edit' ),
+ )
);
+ $id = wp_insert_post( PostDataSlashing::slash( $post_data ), true );
+
if ( $id && ! is_wp_error( $id ) ) {
$product->set_id( $id );
$sku = $product->get_sku();
@@ -373,7 +381,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$GLOBALS['wpdb']->update( $GLOBALS['wpdb']->posts, $post_data, array( 'ID' => $product->get_id() ) );
clean_post_cache( $product->get_id() );
} else {
- wp_update_post( array_merge( array( 'ID' => $product->get_id() ), $post_data ) );
+ wp_update_post( PostDataSlashing::slash( array_merge( array( 'ID' => $product->get_id() ), $post_data ) ) );
}
$product->read_meta_data( true ); // Refresh internal meta data, in case things were hooked into `save_post` or another WP hook.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
index 0526ba33f66..570437d43cd 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
@@ -8,6 +8,7 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Internal\DataStores\PostDataSlashing;
use Automattic\WooCommerce\Enums\ProductStockStatus;
if ( ! defined( 'ABSPATH' ) ) {
@@ -132,28 +133,35 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
$product->set_parent_id( 0 );
}
- $id = wp_insert_post(
- apply_filters(
- 'woocommerce_new_product_variation_data',
- array(
- 'post_type' => 'product_variation',
- 'post_status' => $product->get_status() ? $product->get_status() : ProductStatus::PUBLISH,
- 'post_author' => get_current_user_id(),
- 'post_title' => $product->get_name( 'edit' ),
- 'post_excerpt' => $product->get_attribute_summary( 'edit' ),
- 'post_content' => '',
- 'post_parent' => $product->get_parent_id(),
- 'comment_status' => 'closed',
- 'ping_status' => 'closed',
- 'menu_order' => $product->get_menu_order(),
- 'post_date' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getOffsetTimestamp() ),
- 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getTimestamp() ),
- 'post_name' => $product->get_slug( 'edit' ),
- )
- ),
- true
+ /**
+ * Filters the post data used to create a product variation.
+ *
+ * Values are unslashed. They are slashed before being passed to wp_insert_post().
+ *
+ * @since 3.0.0
+ * @param array $data Post data for wp_insert_post().
+ */
+ $post_data = apply_filters(
+ 'woocommerce_new_product_variation_data',
+ array(
+ 'post_type' => 'product_variation',
+ 'post_status' => $product->get_status() ? $product->get_status() : ProductStatus::PUBLISH,
+ 'post_author' => get_current_user_id(),
+ 'post_title' => $product->get_name( 'edit' ),
+ 'post_excerpt' => $product->get_attribute_summary( 'edit' ),
+ 'post_content' => '',
+ 'post_parent' => $product->get_parent_id(),
+ 'comment_status' => 'closed',
+ 'ping_status' => 'closed',
+ 'menu_order' => $product->get_menu_order(),
+ 'post_date' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getOffsetTimestamp() ),
+ 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $product->get_date_created( 'edit' )->getTimestamp() ),
+ 'post_name' => $product->get_slug( 'edit' ),
+ )
);
+ $id = wp_insert_post( PostDataSlashing::slash( $post_data ), true );
+
if ( $id && ! is_wp_error( $id ) ) {
$product->set_id( $id );
@@ -246,7 +254,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
$GLOBALS['wpdb']->update( $GLOBALS['wpdb']->posts, $post_data, array( 'ID' => $product->get_id() ) );
clean_post_cache( $product->get_id() );
} else {
- wp_update_post( array_merge( array( 'ID' => $product->get_id() ), $post_data ) );
+ wp_update_post( PostDataSlashing::slash( array_merge( array( 'ID' => $product->get_id() ), $post_data ) ) );
}
$product->read_meta_data( true ); // Refresh internal meta data, in case things were hooked into `save_post` or another WP hook.
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index dcdee146341..22528500780 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -1007,7 +1007,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
$parts[ $key ] = str_replace( '\n', "\n", $part );
}
- return implode( '\\\n', $parts );
+ return implode( '\n', $parts );
}
/**
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-coupons-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-coupons-v1-controller.php
index 4295758ea7b..510d525794f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-coupons-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-coupons-v1-controller.php
@@ -275,7 +275,7 @@ class WC_REST_Coupons_V1_Controller extends WC_REST_Posts_Controller {
$coupon->set_code( $coupon_code );
break;
case 'description' :
- $coupon->set_description( wp_filter_post_kses( $value ) );
+ $coupon->set_description( wp_kses_post( $value ) );
break;
case 'expiry_date' :
$coupon->set_date_expires( $value );
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
index 36083d30c21..6c575a0f245 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
@@ -697,17 +697,17 @@ class WC_REST_Products_V1_Controller extends WC_REST_Posts_Controller {
// Post title.
if ( isset( $request['name'] ) ) {
- $product->set_name( wp_filter_post_kses( $request['name'] ) );
+ $product->set_name( wp_kses_post( $request['name'] ) );
}
// Post content.
if ( isset( $request['description'] ) ) {
- $product->set_description( wp_filter_post_kses( $request['description'] ) );
+ $product->set_description( wp_kses_post( $request['description'] ) );
}
// Post excerpt.
if ( isset( $request['short_description'] ) ) {
- $product->set_short_description( wp_filter_post_kses( $request['short_description'] ) );
+ $product->set_short_description( wp_kses_post( $request['short_description'] ) );
}
// Post status.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
index 8a77304accc..77cc557acf3 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
@@ -339,7 +339,7 @@ class WC_REST_Coupons_V2_Controller extends WC_REST_CRUD_Controller {
MetaDataUtil::update( $value, $coupon );
break;
case 'description':
- $coupon->set_description( wp_filter_post_kses( $value ) );
+ $coupon->set_description( wp_kses_post( $value ) );
break;
default:
if ( is_callable( array( $coupon, "set_{$key}" ) ) ) {
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index c7fb0151dc3..4353e8c859f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -1108,17 +1108,17 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
// Post title.
if ( isset( $request['name'] ) ) {
- $product->set_name( wp_filter_post_kses( $request['name'] ) );
+ $product->set_name( wp_kses_post( $request['name'] ) );
}
// Post content.
if ( isset( $request['description'] ) ) {
- $product->set_description( wp_filter_post_kses( $request['description'] ) );
+ $product->set_description( wp_kses_post( $request['description'] ) );
}
// Post excerpt.
if ( isset( $request['short_description'] ) ) {
- $product->set_short_description( wp_filter_post_kses( $request['short_description'] ) );
+ $product->set_short_description( wp_kses_post( $request['short_description'] ) );
}
// Post status.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 86b1d783a62..6ff6142eb1f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -818,17 +818,17 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
// Post title.
if ( isset( $request['name'] ) ) {
- $product->set_name( wp_filter_post_kses( $request['name'] ) );
+ $product->set_name( wp_kses_post( $request['name'] ) );
}
// Post content.
if ( isset( $request['description'] ) ) {
- $product->set_description( wp_filter_post_kses( $request['description'] ) );
+ $product->set_description( wp_kses_post( $request['description'] ) );
}
// Post excerpt.
if ( isset( $request['short_description'] ) ) {
- $product->set_short_description( wp_filter_post_kses( $request['short_description'] ) );
+ $product->set_short_description( wp_kses_post( $request['short_description'] ) );
}
// Post status.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index e6f3ca6c80e..8538d4aba71 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -17382,12 +17382,6 @@ parameters:
count: 4
path: includes/data-stores/class-wc-product-data-store-cpt.php
- -
- message: '#^Parameter \#1 \$postarr of function wp_update_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\.\}, array\{ID\: int, post_content\: string, post_excerpt\: string, post_title\: string, post_parent\: int, comment_status\: ''closed''\|''open'', post_status\: non\-falsy\-string, menu_order\: int, \.\.\.\} given\.$#'
- identifier: argument.type
- count: 1
- path: includes/data-stores/class-wc-product-data-store-cpt.php
-
-
message: '#^Parameter \#1 \$posts of function update_post_caches expects array\<WP_Post\>, array\<int\|WP_Post\> given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 17f684e7a90..41d1003ad61 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -1445,8 +1445,8 @@ trait ProductAbilityTrait {
* @return mixed|\WP_Error
*/
private static function prepare_product_field_value( string $field, $value ) {
- if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) ) {
- return wp_filter_post_kses( $value );
+ if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) && is_string( $value ) ) {
+ return wp_kses_post( $value );
}
if ( in_array( $field, array( 'status', 'stock_status', 'external_url' ), true ) ) {
diff --git a/plugins/woocommerce/src/Internal/DataStores/PostDataSlashing.php b/plugins/woocommerce/src/Internal/DataStores/PostDataSlashing.php
new file mode 100644
index 00000000000..4367f66d690
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/DataStores/PostDataSlashing.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * PostDataSlashing class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\DataStores;
+
+/**
+ * Slashes post data for wp_insert_post() and wp_update_post(), which unslash it.
+ *
+ * @since 11.4.0
+ */
+class PostDataSlashing {
+
+ /**
+ * Slash the string values in post data, so backslashes survive wp_insert_post() and wp_update_post().
+ *
+ * For backwards compatibility, values that are already slashed (such as the output of wp_filter_post_kses()) are
+ * passed as they are, so they're still unslashed once as before. This check is temporary and will be removed.
+ *
+ * @param mixed $data Post data.
+ * @return mixed
+ */
+ public static function slash( $data ) {
+ if ( ! is_array( $data ) ) {
+ return $data;
+ }
+
+ foreach ( $data as $key => $value ) {
+ if ( is_string( $value ) && wp_slash( wp_unslash( $value ) ) !== $value ) {
+ $data[ $key ] = wp_slash( $value );
+ }
+ }
+
+ return $data;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index bab21f2c402..a2fecb075e7 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -1029,17 +1029,17 @@ class Controller extends WC_REST_Products_V2_Controller {
// Post title.
if ( isset( $request['name'] ) ) {
- $product->set_name( wp_filter_post_kses( $request['name'] ) );
+ $product->set_name( wp_kses_post( $request['name'] ) );
}
// Post content.
if ( isset( $request['description'] ) ) {
- $product->set_description( wp_filter_post_kses( $request['description'] ) );
+ $product->set_description( wp_kses_post( $request['description'] ) );
}
// Post excerpt.
if ( isset( $request['short_description'] ) ) {
- $product->set_short_description( wp_filter_post_kses( $request['short_description'] ) );
+ $product->set_short_description( wp_kses_post( $request['short_description'] ) );
}
// Post status.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CartApplyCoupon.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CartApplyCoupon.php
index 56fffb558b2..b334d27f7f3 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CartApplyCoupon.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CartApplyCoupon.php
@@ -67,7 +67,7 @@ class CartApplyCoupon extends AbstractCartRoute {
throw new RouteException( 'woocommerce_rest_cart_coupon_disabled', esc_html__( 'Coupons are disabled.', 'woocommerce' ), 404 );
}
- $coupon_code = wc_format_coupon_code( wp_unslash( $request['code'] ) );
+ $coupon_code = wc_format_coupon_code( $request['code'] );
try {
$this->cart_controller->apply_coupon( $coupon_code );
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
index e3eefc5de7f..80faa58a46d 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
@@ -388,4 +388,38 @@ class WC_Coupon_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertNull( $reloaded_coupon->get_date_created( 'edit' ) );
$this->assertSame( 'Updated description', $reloaded_coupon->get_description() );
}
+
+ /**
+ * @testdox Creating and updating a coupon keeps backslashes and quotes in its code and description.
+ */
+ public function test_create_and_update_keep_backslashes_in_post_fields(): void {
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'save\\10' );
+ $coupon->set_description( 'Path C:\\temp, it\'s "quoted"' );
+ $coupon->save();
+
+ $post = get_post( $coupon->get_id() );
+ $this->assertSame( 'save\\10', $post->post_title, 'The created code should keep its backslash.' );
+ $this->assertSame( 'Path C:\\temp, it\'s "quoted"', $post->post_excerpt, 'The created description should keep its backslash and quotes.' );
+
+ $coupon->set_code( 'save\\20' );
+ $coupon->set_description( 'Path D:\\temp, it\'s "quoted"' );
+ $coupon->save();
+
+ $post = get_post( $coupon->get_id() );
+ $this->assertSame( 'save\\20', $post->post_title, 'The updated code should keep its backslash.' );
+ $this->assertSame( 'Path D:\\temp, it\'s "quoted"', $post->post_excerpt, 'The updated description should keep its backslash and quotes.' );
+ }
+
+ /**
+ * @testdox Saving a coupon keeps the existing behavior for a description that is already slashed.
+ */
+ public function test_save_unslashes_already_slashed_description(): void {
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'slashed-description' );
+ $coupon->set_description( wp_filter_post_kses( 'It\'s 12" off' ) );
+ $coupon->save();
+
+ $this->assertSame( 'It\'s 12" off', get_post( $coupon->get_id() )->post_excerpt );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
index 49385908f8d..fe1928e8f0d 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
@@ -893,4 +893,88 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertFalse( ObjectInstantiationProbe::$was_unserialized, 'Stored product meta must not instantiate objects' );
$this->assertSame( array( 'size' => 'small' ), $product->get_default_attributes(), 'Scalar default attributes should still load' );
}
+
+ /**
+ * @testdox Creating and updating a product keeps backslashes and quotes in its name and descriptions.
+ */
+ public function test_create_and_update_keep_backslashes_in_post_fields(): void {
+ $product = new WC_Product_Simple();
+ $product->set_name( 'Cable 1\\2" it\'s' );
+ $product->set_description( 'Path C:\\temp' );
+ $product->set_short_description( 'Short C:\\temp' );
+ $product->save();
+
+ $post = get_post( $product->get_id() );
+ $this->assertSame( 'Cable 1\\2" it\'s', $post->post_title, 'The created name should keep its backslash and quotes.' );
+ $this->assertSame( 'Path C:\\temp', $post->post_content, 'The created description should keep its backslash.' );
+ $this->assertSame( 'Short C:\\temp', $post->post_excerpt, 'The created short description should keep its backslash.' );
+
+ $product->set_name( 'Cable 3\\4" it\'s' );
+ $product->set_description( 'Path D:\\temp' );
+ $product->set_short_description( 'Short D:\\temp' );
+ $product->save();
+
+ $post = get_post( $product->get_id() );
+ $this->assertSame( 'Cable 3\\4" it\'s', $post->post_title, 'The updated name should keep its backslash and quotes.' );
+ $this->assertSame( 'Path D:\\temp', $post->post_content, 'The updated description should keep its backslash.' );
+ $this->assertSame( 'Short D:\\temp', $post->post_excerpt, 'The updated short description should keep its backslash.' );
+ }
+
+ /**
+ * Values in already-slashed form, as extensions pass them after wp_filter_post_kses() or from $_POST.
+ *
+ * @return array<string, array{0: string, 1: string}>
+ */
+ public function provider_already_slashed_post_fields(): array {
+ return array(
+ 'output of wp_filter_post_kses()' => array( 'Men\\\'s 12\\" shirt', 'Men\'s 12" shirt' ),
+ 'slashed twice' => array( 'Men\\\\\\\'s shirt', 'Men\\\'s shirt' ),
+ );
+ }
+
+ /**
+ * @testdox Saving a product keeps the existing behavior for values that are already slashed.
+ * @dataProvider provider_already_slashed_post_fields
+ *
+ * @param string $value Value passed to the setters.
+ * @param string $expected Stored value.
+ */
+ public function test_save_unslashes_already_slashed_post_fields( string $value, string $expected ): void {
+ $product = new WC_Product_Simple();
+ $product->set_name( $value );
+ $product->set_description( $value );
+ $product->save();
+
+ $post = get_post( $product->get_id() );
+ $this->assertSame( $expected, $post->post_title, 'A created name in slashed form should be unslashed.' );
+ $this->assertSame( $expected, $post->post_content, 'A created description in slashed form should be unslashed.' );
+
+ $product->set_name( 'Renamed' );
+ $product->save();
+ $product->set_name( $value );
+ $product->set_short_description( $value );
+ $product->save();
+
+ $post = get_post( $product->get_id() );
+ $this->assertSame( $expected, $post->post_title, 'An updated name in slashed form should be unslashed.' );
+ $this->assertSame( $expected, $post->post_excerpt, 'An updated short description in slashed form should be unslashed.' );
+ }
+
+ /**
+ * @testdox A woocommerce_new_product_data callback that slashes its values keeps working.
+ */
+ public function test_create_unslashes_values_slashed_by_new_product_data_filter(): void {
+ add_filter(
+ 'woocommerce_new_product_data',
+ function ( $data ) {
+ $data['post_title'] = wp_slash( 'Men\'s 1\\2 inch' );
+ return $data;
+ }
+ );
+
+ $product = new WC_Product_Simple();
+ $product->save();
+
+ $this->assertSame( 'Men\'s 1\\2 inch', get_post( $product->get_id() )->post_title );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php
index 5df7cb2cdbd..f8695a2b33a 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php
@@ -326,6 +326,36 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertEmpty( get_the_terms( $variation_id, 'product_type' ) );
}
+ /**
+ * @testdox Creating and updating a variation keeps backslashes from the parent name in its title.
+ */
+ public function test_create_and_update_keep_backslashes_in_variation_title(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_name( 'Cable 1\\2 inch' );
+ $parent->save();
+
+ // Record the title WordPress stores, before read() repairs a mismatched title.
+ $stored_titles = array();
+ add_filter(
+ 'wp_insert_post_data',
+ function ( $data ) use ( &$stored_titles ) {
+ if ( 'product_variation' === $data['post_type'] ) {
+ $stored_titles[] = wp_unslash( $data['post_title'] );
+ }
+ return $data;
+ },
+ PHP_INT_MAX
+ );
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $parent->get_id() );
+ $variation->save();
+ $variation->set_menu_order( 5 );
+ $variation->save();
+
+ $this->assertSame( array( 'Cable 1\\2 inch', 'Cable 1\\2 inch' ), $stored_titles, 'Creating and updating the variation should both store the backslash from the parent name.' );
+ }
+
/**
* Create a variable product and return one of its variations.
*
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index a9f504170a9..21de54310d7 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -3271,4 +3271,29 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
$this->assertSame( 'woocommerce_product_csv_importer_invalid_id', $result->get_error_code() );
$this->assertSame( 'page', get_post_type( $page_id ), 'The page should keep its post type' );
}
+
+ /**
+ * @testdox Importing keeps backslashes in names and descriptions, and turns only escaped newlines into line breaks.
+ */
+ public function test_import_keeps_backslashes_in_name_and_descriptions(): void {
+ $importer = new WC_Product_CSV_Importer(
+ __DIR__ . '/import-description-backslashes-69755-data.csv',
+ array(
+ 'parse' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'Name' => 'name',
+ 'Description' => 'description',
+ 'Short description' => 'short_description',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ $this->assertCount( 1, $data['imported'], 'The product should be imported.' );
+ $post = get_post( $data['imported'][0] );
+ $this->assertSame( 'Cable 1\\2 inch', $post->post_title, 'The name should keep its backslash.' );
+ $this->assertSame( 'Path C:\\temp\\nliteral', $post->post_content, 'The description should keep its backslash and the literal \\n text.' );
+ $this->assertSame( "Line one\nLine two", $post->post_excerpt, 'An escaped newline should become a line break.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/importer/import-description-backslashes-69755-data.csv b/plugins/woocommerce/tests/php/includes/importer/import-description-backslashes-69755-data.csv
new file mode 100644
index 00000000000..20ebdc2c0dd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/importer/import-description-backslashes-69755-data.csv
@@ -0,0 +1,2 @@
+"Type","Name","Description","Short description"
+"simple","Cable 1\2 inch","Path C:\temp\\nliteral","Line one\nLine two"
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
index b8d1b89218b..c53708a4e9c 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-coupons-controller-tests.php
@@ -344,4 +344,40 @@ class WC_REST_Coupons_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assert_incomplete_meta_data_handled_correctly( new \WC_Coupon( $coupon->get_id() ) );
}
+
+ /**
+ * @testdox Creating and updating a coupon keeps backslashes and quotes in its code and description.
+ */
+ public function test_create_and_update_coupon_keep_backslashes_in_post_fields(): void {
+ wp_set_current_user( $this->user );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/coupons' );
+ $request->set_body_params(
+ array(
+ 'code' => 'save\\10',
+ 'description' => 'Path C:\\temp, it\'s "quoted"',
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status() );
+ $post = get_post( $response->get_data()['id'] );
+ $this->assertSame( 'save\\10', $response->get_data()['code'], 'The response code should keep its backslash.' );
+ $this->assertSame( 'save\\10', $post->post_title, 'The stored code should keep its backslash.' );
+ $this->assertSame( 'Path C:\\temp, it\'s "quoted"', $post->post_excerpt, 'The stored description should keep its backslash and quotes.' );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/coupons/' . $post->ID );
+ $request->set_body_params(
+ array(
+ 'code' => 'save\\20',
+ 'description' => 'Path D:\\temp, it\'s "quoted"',
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $post = get_post( $post->ID );
+ $this->assertSame( 'save\\20', $post->post_title, 'The updated code should keep its backslash.' );
+ $this->assertSame( 'Path D:\\temp, it\'s "quoted"', $post->post_excerpt, 'The updated description should keep its backslash and quotes.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index 4908474e2e8..146ff34720e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -2920,4 +2920,48 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
'The featured image should be listed first and only once.'
);
}
+
+ /**
+ * @testdox Creating and updating a product keeps backslashes and quotes in its name and descriptions.
+ */
+ public function test_create_and_update_product_keep_backslashes_in_post_fields(): void {
+ $fields = array(
+ 'name' => 'Cable 1\\2" it\'s',
+ 'description' => 'Path C:\\temp',
+ 'short_description' => 'Short C:\\temp',
+ );
+ $updated_fields = array(
+ 'name' => 'Cable 3\\4" it\'s',
+ 'description' => 'Path D:\\temp',
+ 'short_description' => 'Short D:\\temp',
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products' );
+ $request->set_body_params( $fields );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status() );
+ $this->assert_post_fields_keep_backslashes( $response->get_data(), $fields );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $response->get_data()['id'] );
+ $request->set_body_params( $updated_fields );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assert_post_fields_keep_backslashes( $response->get_data(), $updated_fields );
+ }
+
+ /**
+ * Assert a product response and its stored post keep the given name and descriptions.
+ *
+ * @param array $data Product response data.
+ * @param array $fields Expected name, description and short description.
+ */
+ private function assert_post_fields_keep_backslashes( array $data, array $fields ): void {
+ $post = get_post( $data['id'] );
+ $this->assertSame( $fields['name'], $data['name'], 'The response name should keep its backslash and quotes.' );
+ $this->assertSame( $fields['name'], $post->post_title, 'The stored name should keep its backslash and quotes.' );
+ $this->assertSame( $fields['description'], $post->post_content, 'The stored description should keep its backslash.' );
+ $this->assertSame( $fields['short_description'], $post->post_excerpt, 'The stored short description should keep its backslash.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php
index fec8c48448a..96a2d0feacc 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php
@@ -277,6 +277,35 @@ class CartApplyCoupon extends ControllerTestCase {
update_option( 'woocommerce_enable_coupons', 'yes' );
}
+ /**
+ * @testdox Applying a coupon whose code contains a backslash keeps the backslash.
+ */
+ public function test_apply_coupon_with_backslash_in_code(): void {
+ wc()->cart->remove_coupons();
+
+ $fixtures = new FixtureData();
+ $fixtures->get_coupon(
+ array(
+ 'code' => 'save\\10',
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 1,
+ )
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/apply-coupon' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'code' => 'save\\10',
+ )
+ );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), 'The coupon should be applied.' );
+ $this->assertSame( 'save\\10', $response->get_data()['coupons'][0]['code'], 'The applied code should keep its backslash.' );
+ }
+
/**
* Test applying invalid coupon.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 6dece73966a..02d0a420bbd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -2371,6 +2371,25 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 'Happy birthday', $updated['order']['extensions']['test-ext']['gift_message'] );
}
+ /**
+ * @testdox Should keep backslashes and quotes in the name and descriptions of a created product.
+ */
+ public function test_product_create_keeps_backslashes_in_post_fields(): void {
+ $created = $this->run_ability(
+ 'woocommerce/product-create',
+ array(
+ 'name' => 'Cable 1\\2" it\'s',
+ 'description' => 'Path C:\\temp',
+ 'short_description' => 'Short C:\\temp',
+ )
+ );
+ $post = get_post( $created['product']['id'] );
+
+ $this->assertSame( 'Cable 1\\2" it\'s', $post->post_title, 'The stored name should keep its backslash and quotes.' );
+ $this->assertSame( 'Path C:\\temp', $post->post_content, 'The stored description should keep its backslash.' );
+ $this->assertSame( 'Short C:\\temp', $post->post_excerpt, 'The stored short description should keep its backslash.' );
+ }
+
/**
* @testdox Should save nothing when a value is rejected, does not match its schema, or belongs to a read-only field.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/PostDataSlashingTest.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/PostDataSlashingTest.php
new file mode 100644
index 00000000000..1f2f236bf2e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/PostDataSlashingTest.php
@@ -0,0 +1,54 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\DataStores;
+
+use Automattic\WooCommerce\Internal\DataStores\PostDataSlashing;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the PostDataSlashing class.
+ */
+class PostDataSlashingTest extends WC_Unit_Test_Case {
+
+ /**
+ * Values and the result of slashing them.
+ *
+ * @return array<string, array{0: string, 1: string}>
+ */
+ public function provider_values(): array {
+ return array(
+ 'backslash' => array( 'Cable 1\\2 inch', 'Cable 1\\\\2 inch' ),
+ 'quotes' => array( 'Men\'s 12" shirt', 'Men\\\'s 12\\" shirt' ),
+ 'backslash and quote' => array( 'C:\\temp, it\'s', 'C:\\\\temp, it\\\'s' ),
+ 'output of wp_filter_post_kses()' => array( 'Men\\\'s 12\\" shirt', 'Men\\\'s 12\\" shirt' ),
+ 'no special characters' => array( 'Plain name', 'Plain name' ),
+ );
+ }
+
+ /**
+ * @testdox Should slash string values unless they are already slashed.
+ * @dataProvider provider_values
+ *
+ * @param string $value Value to slash.
+ * @param string $expected Expected result.
+ */
+ public function test_slashes_values_unless_already_slashed( string $value, string $expected ): void {
+ $result = PostDataSlashing::slash( array( 'post_title' => $value ) );
+
+ $this->assertSame( $expected, $result['post_title'] );
+ }
+
+ /**
+ * @testdox Should leave non-string values and non-array data unchanged.
+ */
+ public function test_leaves_non_string_values_and_non_array_data_unchanged(): void {
+ $data = array(
+ 'menu_order' => 3,
+ 'meta_input' => array( '_note' => 'it\'s' ),
+ );
+
+ $this->assertSame( $data, PostDataSlashing::slash( $data ), 'Non-string values should be unchanged.' );
+ $this->assertSame( 'post_title=it\'s', PostDataSlashing::slash( 'post_title=it\'s' ), 'Non-array data should be unchanged.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index 18f209f2831..89b609d693f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -2955,4 +2955,48 @@ class ProductsControllerTest extends WC_Unit_Test_Case {
$this->assertArrayHasKey( 'cost_of_goods_sold', $data, 'Shop manager should see COGS data' );
$this->assertArrayHasKey( 'meta_data', $data, 'Shop manager should see meta data' );
}
+
+ /**
+ * @testdox Creating and updating a product keeps backslashes and quotes in its name and descriptions.
+ */
+ public function test_create_and_update_product_keep_backslashes_in_post_fields(): void {
+ $fields = array(
+ 'name' => 'Cable 1\\2" it\'s',
+ 'description' => 'Path C:\\temp',
+ 'short_description' => 'Short C:\\temp',
+ );
+ $updated_fields = array(
+ 'name' => 'Cable 3\\4" it\'s',
+ 'description' => 'Path D:\\temp',
+ 'short_description' => 'Short D:\\temp',
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/products' );
+ $request->set_body_params( $fields );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status() );
+ $this->assert_post_fields_keep_backslashes( $response->get_data(), $fields );
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v4/products/' . $response->get_data()['id'] );
+ $request->set_body_params( $updated_fields );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assert_post_fields_keep_backslashes( $response->get_data(), $updated_fields );
+ }
+
+ /**
+ * Assert a product response and its stored post keep the given name and descriptions.
+ *
+ * @param array $data Product response data.
+ * @param array $fields Expected name, description and short description.
+ */
+ private function assert_post_fields_keep_backslashes( array $data, array $fields ): void {
+ $post = get_post( $data['id'] );
+ $this->assertSame( $fields['name'], $data['name'], 'The response name should keep its backslash and quotes.' );
+ $this->assertSame( $fields['name'], $post->post_title, 'The stored name should keep its backslash and quotes.' );
+ $this->assertSame( $fields['description'], $post->post_content, 'The stored description should keep its backslash.' );
+ $this->assertSame( $fields['short_description'], $post->post_excerpt, 'The stored short description should keep its backslash.' );
+ }
}