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