Commit a5d1577a9a9 for woocommerce

commit a5d1577a9a9f4999bde58ccab9478bbfc8a0c111
Author: Bhavik Tank <53536925+bhavz-10@users.noreply.github.com>
Date:   Tue Mar 24 13:33:52 2026 +0530

    Fatal error: urldecode() receives array in wc_product_canonical_redirect() (#63723)

    * fix: ensure product_cat query var is safely validated before use
    * test: add tests for the wc_product_canonical_redirect()
    * feat: add changelog
    * fix: alter the logic for data type check before urldecode
    * test: optimize the tests functionality
    * fix: adjust the changelog entry for readability
    * phpstan: run composer phpstan:baseline to regenerate the baseline
    * test: fix linting issues
    * lint: resolve linting issues

    ---------

    Co-authored-by: Vladimir Reznichenko <kalessil@gmail.com>

diff --git a/plugins/woocommerce/changelog/fix-63582-urldecode()-receives-array-in-wc_product_canonical_redirect() b/plugins/woocommerce/changelog/fix-63582-urldecode()-receives-array-in-wc_product_canonical_redirect()
new file mode 100644
index 00000000000..b18d179ca8a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-63582-urldecode()-receives-array-in-wc_product_canonical_redirect()
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fixed a fatal error where urldecode() received an array in wc_product_canonical_redirect() when running on PHP 8 or higher.
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index 6ea0630cdf6..06a370b49ac 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -398,9 +398,9 @@ function wc_product_canonical_redirect(): void {

 	// In the event we are dealing with ugly permalinks, this will be empty.
 	$specified_category_slug = get_query_var( 'product_cat' );
-	$specified_category_slug = urldecode( $specified_category_slug );
+	$specified_category_slug = is_array( $specified_category_slug ) ? '' : urldecode( (string) $specified_category_slug );

-	if ( ! is_string( $specified_category_slug ) || strlen( $specified_category_slug ) < 1 ) {
+	if ( '' === $specified_category_slug ) {
 		return;
 	}

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index a9a9655d185..3d8dd1750f2 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -5899,19 +5899,19 @@ parameters:
 			path: includes/admin/meta-boxes/views/html-order-fee.php

 		-
-			message: '#^Variable \$order might not be defined\.$#'
+			message: '#^Variable \$cogs_is_enabled might not be defined\.$#'
 			identifier: variable.undefined
-			count: 7
+			count: 1
 			path: includes/admin/meta-boxes/views/html-order-fee.php

 		-
-			message: '#^Variable \$order_taxes might not be defined\.$#'
+			message: '#^Variable \$order might not be defined\.$#'
 			identifier: variable.undefined
-			count: 1
+			count: 7
 			path: includes/admin/meta-boxes/views/html-order-fee.php

 		-
-			message: '#^Variable \$cogs_is_enabled might not be defined\.$#'
+			message: '#^Variable \$order_taxes might not be defined\.$#'
 			identifier: variable.undefined
 			count: 1
 			path: includes/admin/meta-boxes/views/html-order-fee.php
@@ -6085,13 +6085,13 @@ parameters:
 			path: includes/admin/meta-boxes/views/html-order-refund.php

 		-
-			message: '#^Variable \$order_taxes might not be defined\.$#'
+			message: '#^Variable \$cogs_is_enabled might not be defined\.$#'
 			identifier: variable.undefined
 			count: 1
 			path: includes/admin/meta-boxes/views/html-order-refund.php

 		-
-			message: '#^Variable \$cogs_is_enabled might not be defined\.$#'
+			message: '#^Variable \$order_taxes might not be defined\.$#'
 			identifier: variable.undefined
 			count: 1
 			path: includes/admin/meta-boxes/views/html-order-refund.php
@@ -35655,12 +35655,6 @@ parameters:
 			count: 1
 			path: includes/wc-product-functions.php

-		-
-			message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
-			identifier: function.alreadyNarrowedType
-			count: 1
-			path: includes/wc-product-functions.php
-
 		-
 			message: '#^Cannot access property \$slug on WP_Error\|WP_Term\|null\.$#'
 			identifier: property.nonObject
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index d7d60b9ff76..299212412a4 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -650,4 +650,145 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 			wp_delete_term( $category1_term['term_id'], 'product_cat' );
 		}
 	}
+
+	/**
+	 * Helper to run wc_product_canonical_redirect() under wp_redirect guard.
+	 *
+	 * @param callable $callback The callback that triggers wc_product_canonical_redirect() when executed.
+	 */
+	private function with_wc_product_canonical_redirect_guard( callable $callback ) {
+		$redirect_attempted = false;
+		$redirected_to      = '';
+		$redirect_status    = 0;
+
+		$redirect_callback = function ( $location = '', $status = 302 ) use ( &$redirect_attempted, &$redirected_to, &$redirect_status ) {
+			$redirect_attempted = true;
+			$redirected_to      = $location;
+			$redirect_status    = $status;
+			throw new \WPAjaxDieContinueException();
+		};
+
+		add_filter( 'wp_redirect', $redirect_callback, 10, 2 );
+
+		try {
+			$callback();
+		} catch ( \WPAjaxDieContinueException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+			// Expected for redirects, or failure path to be asserted.
+		} finally {
+			remove_filter( 'wp_redirect', $redirect_callback, 10, 2 );
+		}
+
+		return array( $redirect_attempted, $redirected_to, $redirect_status );
+	}
+
+	/**
+	 * @testdox Product canonical redirect is skipped for non-product requests.
+	 */
+	public function test_wc_product_canonical_redirect_skips_non_product_requests() {
+		$this->go_to( home_url( '/' ) );
+
+		list( $redirect_attempted ) = $this->with_wc_product_canonical_redirect_guard( 'wc_product_canonical_redirect' );
+
+		$this->assertFalse( $redirect_attempted );
+	}
+
+	/**
+	 * @testdox Product canonical redirect ignores invalid non-string product_cat query var.
+	 */
+	public function test_wc_product_canonical_redirect_ignores_invalid_product_cat_query_var() {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->go_to( get_permalink( $product->get_id() ) );
+
+		// Force non-string query var to cover the guard condition.
+		set_query_var( 'product_cat', array() );
+
+		list( $redirect_attempted ) = $this->with_wc_product_canonical_redirect_guard( 'wc_product_canonical_redirect' );
+
+		$this->assertFalse( $redirect_attempted );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Product canonical redirect skips redirect when requested product_cat equals expected slug.
+	 */
+	public function test_wc_product_canonical_redirect_ignores_matching_category_slug() {
+		$category = wp_insert_term( 'Matching Category', 'product_cat' );
+		$product  = WC_Helper_Product::create_simple_product();
+		wp_set_object_terms( $product->get_id(), (int) $category['term_id'], 'product_cat' );
+		$product->save();
+
+		$this->go_to( add_query_arg( 'product_cat', get_term( $category['term_id'], 'product_cat' )->slug, get_permalink( $product->get_id() ) ) );
+
+		list( $redirect_attempted ) = $this->with_wc_product_canonical_redirect_guard( 'wc_product_canonical_redirect' );
+
+		$this->assertFalse( $redirect_attempted );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		wp_delete_term( $category['term_id'], 'product_cat' );
+	}
+
+	/**
+	 * @testdox Product canonical redirect sends 301 when requested category slug differs from expected.
+	 */
+	public function test_wc_product_canonical_redirect_redirects_when_category_slug_mismatch() {
+		$category = wp_insert_term( 'Redirect Category', 'product_cat' );
+		$product  = WC_Helper_Product::create_simple_product();
+		wp_set_object_terms( $product->get_id(), (int) $category['term_id'], 'product_cat' );
+		$product->save();
+
+		$query_args = array(
+			'product_cat' => 'wrong-slug',
+			'foo'         => 'bar',
+		);
+
+		$this->go_to( add_query_arg( $query_args, get_permalink( $product->get_id() ) ) );
+
+		list( $redirect_attempted, $redirected_to, $redirected_code ) = $this->with_wc_product_canonical_redirect_guard( 'wc_product_canonical_redirect' );
+
+		$this->assertTrue( $redirect_attempted );
+		$this->assertSame( 301, $redirected_code );
+		$this->assertStringContainsString( wc_get_product( $product->get_id() )->get_permalink(), $redirected_to );
+		$this->assertStringContainsString( 'foo=bar', $redirected_to );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		wp_delete_term( $category['term_id'], 'product_cat' );
+	}
+
+	/**
+	 * @testdox Product canonical redirect ignores empty product_cat query value.
+	 */
+	public function test_wc_product_canonical_redirect_ignores_empty_product_cat_slug() {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->go_to( add_query_arg( 'product_cat', '', get_permalink( $product->get_id() ) ) );
+
+		list( $redirect_attempted ) = $this->with_wc_product_canonical_redirect_guard( 'wc_product_canonical_redirect' );
+
+		$this->assertFalse( $redirect_attempted );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Product canonical redirect skips when global wp_rewrite is not WP_Rewrite.
+	 */
+	public function test_wc_product_canonical_redirect_skips_when_wp_rewrite_not_valid() {
+		global $wp_rewrite;
+
+		$product = WC_Helper_Product::create_simple_product();
+		$this->go_to( get_permalink( $product->get_id() ) );
+
+		$old_wp_rewrite = $wp_rewrite;
+		$wp_rewrite     = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		try {
+			list( $redirect_attempted ) = $this->with_wc_product_canonical_redirect_guard( 'wc_product_canonical_redirect' );
+
+			$this->assertFalse( $redirect_attempted );
+		} finally {
+			$wp_rewrite = $old_wp_rewrite; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		}
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
 }