Commit ec4803cb0da for woocommerce

commit ec4803cb0da188f37f4fefd2e1a9aa8e7297c584
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Mon Apr 20 20:33:34 2026 +0700

    Add product filter cache entry cap to prevent bot-driven cache flooding (#64039)

diff --git a/plugins/woocommerce/changelog/add-product-filter-cache-entry-cap b/plugins/woocommerce/changelog/add-product-filter-cache-entry-cap
new file mode 100644
index 00000000000..a4488858e9d
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-filter-cache-entry-cap
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Cap cached filter combinations to limit transient growth from bot enumeration.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index e095f7441bc..9605070622c 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66822,18 +66822,6 @@ parameters:
 			count: 2
 			path: src/Internal/ProductFilters/FilterData.php

-		-
-			message: '#^Parameter \#2 \$value of method Automattic\\WooCommerce\\Internal\\ProductFilters\\FilterData\:\:set_cache\(\) expects Automattic\\WooCommerce\\Internal\\ProductFilters\\mix, array given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/ProductFilters/FilterData.php
-
-		-
-			message: '#^Parameter \$value of method Automattic\\WooCommerce\\Internal\\ProductFilters\\FilterData\:\:set_cache\(\) has invalid type Automattic\\WooCommerce\\Internal\\ProductFilters\\mix\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Internal/ProductFilters/FilterData.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\ProductFilters\\FilterDataProvider\:\:with\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php b/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php
index 48b39e17d5c..dc5e5c62e1b 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/CacheController.php
@@ -17,6 +17,13 @@ defined( 'ABSPATH' ) || exit;
 class CacheController implements RegisterHooksInterface {
 	const CACHE_GROUP = 'filter_data';

+	/**
+	 * Transient key for the integer counter used to enforce the cache-entry cap.
+	 *
+	 * @since 10.8.0
+	 */
+	const CACHE_ENTRY_COUNT_TRANSIENT = 'wc_filter_data_entry_count';
+
 	/**
 	 * Instance of TaxonomyHierarchyData.
 	 *
@@ -59,10 +66,15 @@ class CacheController implements RegisterHooksInterface {

 	/**
 	 * Invalidate all cache under filter data group.
+	 *
+	 * Also resets the entry-count counter so the cap starts fresh.
+	 *
+	 * @since 10.8.0 Resets CACHE_ENTRY_COUNT_TRANSIENT on invalidation.
 	 */
 	public function invalidate_filter_data_cache(): void {
 		WC_Cache_Helper::get_transient_version( self::CACHE_GROUP, true );
 		WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP );
+		delete_transient( self::CACHE_ENTRY_COUNT_TRANSIENT );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
index 828d696193e..433b341dd14 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
@@ -561,8 +561,17 @@ class FilterData {
 	/**
 	 * Set the cache with transient version to invalidate all at once when needed.
 	 *
+	 * When the number of cached filter combinations reaches the configured
+	 * maximum (default 1000), new combinations are silently skipped rather than
+	 * stored, preventing unbounded transient growth from bot enumeration.
+	 * The counter resets whenever the filter-data cache is invalidated.
+	 * The limit can be adjusted via the `woocommerce_product_filter_cache_max_entries`
+	 * filter. Set it to 0 to disable the cap entirely.
+	 *
+	 * @since 10.8.0 Cache-entry cap added.
+	 *
 	 * @param string $key   Transient key.
-	 * @param mix    $value Value to set.
+	 * @param mixed  $value Value to set.
 	 *
 	 * @return bool True if the cache was set, false otherwise.
 	 */
@@ -571,15 +580,46 @@ class FilterData {
 			return false;
 		}

+		/**
+		 * Maximum number of cache entries (not unique filter combos).
+		 *
+		 * Each unique query-vars combo can produce up to 5 entries (price,
+		 * stock, rating, attribute, taxonomy), so the effective cap on
+		 * unique combos is roughly max_entries / 5.
+		 *
+		 * When the limit is reached, new entries are skipped until the
+		 * cache is next invalidated.  Set to 0 to disable the cap.
+		 *
+		 * @hook woocommerce_product_filter_cache_max_entries
+		 * @since 10.8.0
+		 *
+		 * @param int $max_entries Maximum number of cache entries. Default 1000.
+		 * @return int
+		 */
+		$max_entries = (int) apply_filters( 'woocommerce_product_filter_cache_max_entries', 1000 );
+
+		if ( $max_entries > 0 ) {
+			$count = (int) get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT );
+
+			if ( $count >= $max_entries ) {
+				return false;
+			}
+
+			// The counter only increments — it does not decrement when entries
+			// expire naturally. The effective cap may therefore be reached
+			// before $max_entries live transients exist, making the limit
+			// slightly conservative. This is intentional: accuracy here is
+			// not worth the cost of tracking individual expirations.
+			set_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT, $count + 1, DAY_IN_SECONDS );
+		}
+
 		$transient_version = WC_Cache_Helper::get_transient_version( CacheController::CACHE_GROUP );
 		$transient_value   = array(
 			'version' => $transient_version,
 			'value'   => $value,
 		);

-		$result = set_transient( $key, $transient_value, DAY_IN_SECONDS );
-
-		return $result;
+		return set_transient( $key, $transient_value, DAY_IN_SECONDS );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataCacheCapTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataCacheCapTest.php
new file mode 100644
index 00000000000..4f0f8347817
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataCacheCapTest.php
@@ -0,0 +1,166 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFilters;
+
+use Automattic\WooCommerce\Internal\ProductFilters\CacheController;
+use Automattic\WooCommerce\Internal\ProductFilters\FilterDataProvider;
+use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
+
+/**
+ * Tests for the filter-combination cache entry cap in FilterData.
+ */
+class FilterDataCacheCapTest extends AbstractProductFiltersTest {
+
+	/**
+	 * System under test.
+	 *
+	 * @var \Automattic\WooCommerce\Internal\ProductFilters\FilterData
+	 */
+	private $sut;
+
+	/**
+	 * Runs before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$container = wc_get_container();
+		$this->sut = $container->get( FilterDataProvider::class )->with( $container->get( QueryClauses::class ) );
+	}
+
+	/**
+	 * Runs after each test.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_product_filter_cache_max_entries' );
+		delete_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox When the cache cap is reached, new combinations are skipped.
+	 */
+	public function test_cache_cap_skips_new_entries_when_limit_reached() {
+		add_filter( 'woocommerce_product_filter_cache_max_entries', fn() => 2 );
+
+		$vars_1 = array_filter(
+			( new \WP_Query(
+				array(
+					'post_type' => 'product',
+					'max_price' => 10,
+				)
+			) )->query_vars
+		);
+		$vars_2 = array_filter(
+			( new \WP_Query(
+				array(
+					'post_type' => 'product',
+					'max_price' => 20,
+				)
+			) )->query_vars
+		);
+		$vars_3 = array_filter(
+			( new \WP_Query(
+				array(
+					'post_type' => 'product',
+					'max_price' => 30,
+				)
+			) )->query_vars
+		);
+
+		$this->sut->get_stock_status_counts( $vars_1, array( 'instock', 'outofstock', 'onbackorder' ) );
+		$this->sut->get_stock_status_counts( $vars_2, array( 'instock', 'outofstock', 'onbackorder' ) );
+
+		$this->assertSame( 2, (int) get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+
+		// Third call should be skipped — counter stays at 2.
+		$this->sut->get_stock_status_counts( $vars_3, array( 'instock', 'outofstock', 'onbackorder' ) );
+
+		$this->assertSame( 2, (int) get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+	}
+
+	/**
+	 * @testdox When the cap is 0 (disabled), no counter transient is written.
+	 */
+	public function test_cache_cap_disabled_when_max_entries_is_zero() {
+		add_filter( 'woocommerce_product_filter_cache_max_entries', '__return_zero' );
+
+		$vars = array_filter( ( new \WP_Query( array( 'post_type' => 'product' ) ) )->query_vars );
+		$this->sut->get_stock_status_counts( $vars, array( 'instock', 'outofstock', 'onbackorder' ) );
+
+		$this->assertFalse( get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+	}
+
+	/**
+	 * @testdox Cache invalidation resets the entry counter.
+	 */
+	public function test_invalidation_resets_entry_counter() {
+		add_filter( 'woocommerce_product_filter_cache_max_entries', fn() => 100 );
+
+		$vars = array_filter( ( new \WP_Query( array( 'post_type' => 'product' ) ) )->query_vars );
+		$this->sut->get_stock_status_counts( $vars, array( 'instock', 'outofstock', 'onbackorder' ) );
+
+		$this->assertGreaterThan( 0, (int) get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+
+		wc_get_container()->get( CacheController::class )->invalidate_filter_data_cache();
+
+		$this->assertFalse( get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+	}
+
+	/**
+	 * @testdox Counter increments for each filter type, not just each unique combo.
+	 */
+	public function test_counter_increments_per_filter_type_per_combo() {
+		add_filter( 'woocommerce_product_filter_cache_max_entries', fn() => 100 );
+
+		$vars = array_filter( ( new \WP_Query( array( 'post_type' => 'product' ) ) )->query_vars );
+
+		$this->sut->get_filtered_price( $vars );
+		$this->sut->get_stock_status_counts( $vars, array( 'instock', 'outofstock', 'onbackorder' ) );
+		$this->sut->get_rating_counts( $vars );
+
+		// 3 filter types on the same query vars = 3 cache entries.
+		$this->assertSame( 3, (int) get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+	}
+
+	/**
+	 * @testdox Cap is reached after fewer unique combos when multiple filter types are used.
+	 */
+	public function test_cap_reached_faster_with_multiple_filter_types() {
+		add_filter( 'woocommerce_product_filter_cache_max_entries', fn() => 5 );
+
+		$vars_1 = array_filter(
+			( new \WP_Query(
+				array(
+					'post_type' => 'product',
+					'max_price' => 10,
+				)
+			) )->query_vars
+		);
+		$vars_2 = array_filter(
+			( new \WP_Query(
+				array(
+					'post_type' => 'product',
+					'max_price' => 20,
+				)
+			) )->query_vars
+		);
+
+		// First combo: 3 filter types = 3 entries.
+		$this->sut->get_filtered_price( $vars_1 );
+		$this->sut->get_stock_status_counts( $vars_1, array( 'instock', 'outofstock', 'onbackorder' ) );
+		$this->sut->get_rating_counts( $vars_1 );
+
+		$this->assertSame( 3, (int) get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+
+		// Second combo: 2 more entries hits the cap (5), third is skipped.
+		$this->sut->get_filtered_price( $vars_2 );
+		$this->sut->get_stock_status_counts( $vars_2, array( 'instock', 'outofstock', 'onbackorder' ) );
+		$this->sut->get_rating_counts( $vars_2 );
+
+		// Counter stops at 5, not 6.
+		$this->assertSame( 5, (int) get_transient( CacheController::CACHE_ENTRY_COUNT_TRANSIENT ) );
+	}
+}