Commit 4946e2fc028 for woocommerce
commit 4946e2fc028db74ae485c795fbc56ef6b51cca45
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date: Sat Jul 11 21:30:46 2026 +0600
fix: prevent Brand Nav widget from poisoning shared subcategory cache (#65947)
* fix: prevent Brand Nav widget from poisoning subcategory cache
WC_Widget_Brand_Nav::filter_out_cats() returns empty taxonomy in
woocommerce_product_subcategories_args when filter_product_brand is
in the URL. The resulting empty result was cached unconditionally,
poisoning the shared cache for subsequent non-brand-filtered visitors.
Fix: only cache the result when taxonomy is non-empty in the filtered
args. An empty taxonomy query never produces a meaningful result,
so there is no value in caching it.
Also adds the missing hook docblock for the filter now that it is
extracted to a standalone statement.
Fixes #65832
* Add changelog and unit test for Brand Nav cache poisoning fix
* fix: guard cache check against non-array filter returns
- Add is_array() guard before $args['taxonomy'] access
- Fix test filter cleanup (store closure ref, add remove_filter)
- Add WP_Error checks for wp_insert_term
Addresses coderabbit review feedback.
Refs #65947
* fix: correct @since tag to match milestone version
Update @since 11.0.0 to @since 11.1.0 for the woocommerce_product_subcategories_args
filter docblock, matching the PR's target milestone.
Addresses PR feedback.
Refs #65947
diff --git a/plugins/woocommerce/changelog/fix-65832-brand-nav-cache-poisoning b/plugins/woocommerce/changelog/fix-65832-brand-nav-cache-poisoning
new file mode 100644
index 00000000000..60775823843
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-65832-brand-nav-cache-poisoning
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent Brand Nav widget from poisoning shared product subcategory cache.
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index f088e6ba65b..b826a01f76c 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -3115,20 +3115,27 @@ if ( ! function_exists( 'woocommerce_get_product_subcategories' ) ) {
if ( false === $product_categories ) {
// NOTE: using child_of instead of parent - this is not ideal but due to a WP bug ( https://core.trac.wordpress.org/ticket/15626 ) pad_counts won't work.
- $product_categories = get_categories(
- apply_filters(
- 'woocommerce_product_subcategories_args',
- array(
- 'parent' => $parent_id,
- 'hide_empty' => 0,
- 'hierarchical' => 1,
- 'taxonomy' => 'product_cat',
- 'pad_counts' => 1,
- )
+ /**
+ * Filters the arguments used to retrieve product subcategories.
+ *
+ * @since 11.1.0
+ *
+ * @param array $args Array of arguments for get_categories().
+ */
+ $args = apply_filters(
+ 'woocommerce_product_subcategories_args',
+ array(
+ 'parent' => $parent_id,
+ 'hide_empty' => 0,
+ 'hierarchical' => 1,
+ 'taxonomy' => 'product_cat',
+ 'pad_counts' => 1,
)
);
- if ( $cache_key ) {
+ $product_categories = get_categories( $args );
+
+ if ( $cache_key && is_array( $args ) && ! empty( $args['taxonomy'] ) ) {
wp_cache_set( $cache_key, $product_categories, 'product_cat' );
}
}
diff --git a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
new file mode 100644
index 00000000000..a2c0ffec1b9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
@@ -0,0 +1,148 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for wc-template-functions.php.
+ *
+ * @package WooCommerce\Tests\Includes
+ */
+class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
+
+ /**
+ * Helper: create a parent product category with child categories and products.
+ *
+ * @return int Parent category term ID.
+ */
+ private function create_category_tree(): int {
+ $parent = wp_insert_term( 'Test Parent', 'product_cat' );
+ if ( is_wp_error( $parent ) ) {
+ throw new \RuntimeException( esc_html( $parent->get_error_message() ) );
+ }
+ $parent_id = $parent['term_id'];
+
+ update_term_meta( $parent_id, 'display_type', 'both' );
+
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $child = wp_insert_term(
+ "Test Child $i",
+ 'product_cat',
+ array( 'parent' => $parent_id )
+ );
+ if ( is_wp_error( $child ) ) {
+ throw new \RuntimeException( esc_html( $child->get_error_message() ) );
+ }
+
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_category_ids( array( $child['term_id'] ) );
+ $product->save();
+ }
+
+ wp_update_term_count_now(
+ get_terms(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'fields' => 'ids',
+ 'hide_empty' => 0,
+ )
+ ),
+ 'product_cat'
+ );
+
+ return $parent_id;
+ }
+
+ /**
+ * Clean up cache between tests.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_cache_flush();
+ }
+
+ /**
+ * @testdox woocommerce_get_product_subcategories caches results under the expected key.
+ */
+ public function test_subcategories_are_cached_under_expected_key(): void {
+ $parent_id = $this->create_category_tree();
+ $cache_key = 'product-category-hierarchy-' . $parent_id;
+
+ // Cache should be empty before the call.
+ $this->assertFalse( wp_cache_get( $cache_key, 'product_cat' ) );
+
+ $result = woocommerce_get_product_subcategories( $parent_id );
+
+ // Cache should be populated after the call.
+ $cached = wp_cache_get( $cache_key, 'product_cat' );
+ $this->assertNotFalse( $cached );
+ $this->assertCount( 3, $cached );
+ $this->assertSame( $result, $cached );
+ }
+
+ /**
+ * @testdox woocommerce_get_product_subcategories does not cache when taxonomy is cleared by filter.
+ */
+ public function test_cache_is_skipped_when_taxonomy_is_cleared(): void {
+ $parent_id = $this->create_category_tree();
+ $cache_key = 'product-category-hierarchy-' . $parent_id;
+
+ $filter = function ( $args ) {
+ $args['taxonomy'] = '';
+ return $args;
+ };
+ add_filter( 'woocommerce_product_subcategories_args', $filter );
+
+ $result = woocommerce_get_product_subcategories( $parent_id );
+
+ // Cache should remain empty because taxonomy was cleared.
+ $this->assertFalse( wp_cache_get( $cache_key, 'product_cat' ) );
+ // Result should be empty too (query with empty taxonomy returns nothing).
+ $this->assertEmpty( $result );
+
+ remove_filter( 'woocommerce_product_subcategories_args', $filter );
+ }
+
+ /**
+ * @testdox woocommerce_get_product_subcategories does not cache when taxonomy is missing after filter.
+ */
+ public function test_cache_is_skipped_when_taxonomy_is_missing(): void {
+ $parent_id = $this->create_category_tree();
+ $cache_key = 'product-category-hierarchy-' . $parent_id;
+
+ $filter = function ( $args ) {
+ unset( $args['taxonomy'] );
+ return $args;
+ };
+ add_filter( 'woocommerce_product_subcategories_args', $filter );
+
+ woocommerce_get_product_subcategories( $parent_id );
+
+ // Cache should remain empty because taxonomy was removed.
+ $this->assertFalse( wp_cache_get( $cache_key, 'product_cat' ) );
+
+ remove_filter( 'woocommerce_product_subcategories_args', $filter );
+ }
+
+ /**
+ * @testdox woocommerce_get_product_subcategories caches normally after filter is removed.
+ */
+ public function test_cache_works_normally_after_filter_removed(): void {
+ $parent_id = $this->create_category_tree();
+ $cache_key = 'product-category-hierarchy-' . $parent_id;
+
+ // First call with filter that clears taxonomy.
+ $filter = function ( $args ) {
+ $args['taxonomy'] = '';
+ return $args;
+ };
+ add_filter( 'woocommerce_product_subcategories_args', $filter );
+ woocommerce_get_product_subcategories( $parent_id );
+ $this->assertFalse( wp_cache_get( $cache_key, 'product_cat' ) );
+ remove_filter( 'woocommerce_product_subcategories_args', $filter );
+
+ // Second call without filter should cache normally.
+ $result = woocommerce_get_product_subcategories( $parent_id );
+ $cached = wp_cache_get( $cache_key, 'product_cat' );
+ $this->assertNotFalse( $cached );
+ $this->assertCount( 3, $cached );
+ }
+}