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