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