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