Commit 38f50d0d191 for woocommerce
commit 38f50d0d1913efdf2f98aa930fd9f00b9da224be
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date: Fri Oct 2 12:24:43 2026 +0200
Use prepared statements for Store API product slug filtering (#69236)
diff --git a/plugins/woocommerce/changelog/fix-store-api-product-slug-prepare b/plugins/woocommerce/changelog/fix-store-api-product-slug-prepare
new file mode 100644
index 00000000000..b2c571cdc8e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-product-slug-prepare
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Use prepared statements when filtering Store API products by slug.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 61afbc31b7a..de5852b1cfc 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -69339,7 +69339,7 @@ parameters:
-
message: '#^Parameter \#2 \$array of function implode expects array\<string\>, list\<array\|string\> given\.$#'
identifier: argument.type
- count: 2
+ count: 1
path: src/StoreApi/Utilities/ProductQuery.php
-
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
index 4536d2404ad..3a99f6b416d 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
@@ -455,9 +455,11 @@ class ProductQuery implements QueryClausesGenerator {
if ( 1 < count( $slugs ) ) {
$slugs[] = $wp_query->get( 'slug' );
}
- $args['join'] = $this->append_product_sorting_table_join( $args['join'] );
- $post_name__in = implode( '","', array_map( 'esc_sql', $slugs ) );
- $args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")";
+ $args['join'] = $this->append_product_sorting_table_join( $args['join'] );
+
+ $placeholders = implode( ', ', array_fill( 0, count( $slugs ), '%s' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a safe string of %s tokens.
+ $args['where'] .= $wpdb->prepare( " AND $wpdb->posts.post_name IN ($placeholders)", $slugs );
}
if ( $wp_query->get( 'stock_status' ) ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
index ed90509ccc6..a623b6dc5ed 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryTest.php
@@ -7,7 +7,7 @@ use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
/**
- * Unit tests for the ProductQuery::get_last_modified() caching behavior.
+ * Unit tests for the ProductQuery class.
*/
class ProductQueryTest extends \WC_Unit_Test_Case {
@@ -214,4 +214,92 @@ class ProductQueryTest extends \WC_Unit_Test_Case {
$this->assertNotNull( $second_result );
$this->assertNotFalse( wp_cache_get( 'last_modified', 'wc_products' ) );
}
+
+ /**
+ * @testdox Slug filters containing double quotes are safely prepared when NO_BACKSLASH_ESCAPES is enabled.
+ */
+ public function test_slug_filter_with_double_quote_is_prepared_with_no_backslash_escapes(): void {
+ global $wpdb;
+
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Slug filter product',
+ 'slug' => 'slug-filter-product',
+ )
+ );
+ $sql_mode = $wpdb->get_var( 'SELECT @@SESSION.sql_mode' );
+
+ try {
+ $wpdb->query( "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'" );
+ $this->assertSame( 'NO_BACKSLASH_ESCAPES', $wpdb->get_var( 'SELECT @@SESSION.sql_mode' ), 'The test requires NO_BACKSLASH_ESCAPES.' );
+ $matching_ids = $this->get_product_ids_for_slug_filter( $product->get_slug() . '"' );
+ $last_error = $wpdb->last_error;
+ } finally {
+ $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', $sql_mode ) );
+ }
+
+ $this->assertSame( '', $last_error, 'The slug filter query should remain valid SQL.' );
+ $this->assertNotContains( $product->get_id(), $matching_ids, 'The slug value must be matched literally.' );
+ }
+
+ /**
+ * @testdox Slug filters support single and comma-separated values when NO_BACKSLASH_ESCAPES is enabled.
+ */
+ public function test_slug_filter_supports_valid_values_with_no_backslash_escapes(): void {
+ global $wpdb;
+
+ $fixtures = new FixtureData();
+ $product1 = $fixtures->get_simple_product(
+ array(
+ 'name' => 'First slug filter product',
+ 'slug' => 'first-slug-filter-product',
+ )
+ );
+ $product2 = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Second slug filter product',
+ 'slug' => 'second-slug-filter-product',
+ )
+ );
+ $sql_mode = $wpdb->get_var( 'SELECT @@SESSION.sql_mode' );
+
+ try {
+ $wpdb->query( "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'" );
+ $single_slug_ids = $this->get_product_ids_for_slug_filter( $product1->get_slug() );
+ $multiple_slug_ids = $this->get_product_ids_for_slug_filter( $product1->get_slug() . ',' . $product2->get_slug() );
+ } finally {
+ $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', $sql_mode ) );
+ }
+
+ $this->assertSame( array( $product1->get_id() ), $single_slug_ids, 'A single slug should return the matching product.' );
+ $this->assertEqualsCanonicalizing(
+ array( $product1->get_id(), $product2->get_id() ),
+ $multiple_slug_ids,
+ 'Comma-separated slugs should return all matching products.'
+ );
+ }
+
+ /**
+ * Get product IDs matched by a Store API slug filter.
+ *
+ * @param string $slug_filter Slug filter value.
+ * @return int[]
+ */
+ private function get_product_ids_for_slug_filter( string $slug_filter ): array {
+ global $wpdb;
+
+ $wp_query = new \WP_Query();
+ $wp_query->set( 'slug', $slug_filter );
+ $clauses = $this->product_query->add_query_clauses(
+ array(
+ 'join' => '',
+ 'where' => '',
+ ),
+ $wp_query
+ );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- The slug query clause is prepared by ProductQuery.
+ return array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product'{$clauses['where']}" ) );
+ }
}