Commit e95019b674d for woocommerce

commit e95019b674deec53669845718797350a3a5617e3
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Fri Sep 25 10:02:54 2026 +0200

    Fix fatal error on URLs with an array rest_route parameter (#69015)

    * Fix fatal error on array rest_route in WooCommerce.com site auth

    WC_WCCOM_Site::is_request_to_wccom_site_rest_api() passed rest_route
    straight into strpos(), so a URL with ?rest_route[]=x returned a 500 on
    any page. It now reads the route WordPress resolved once it is
    dispatching REST, and the URL path before that. Requests that carry
    rest_route in the query or body, which covers plain permalinks, are
    authenticated by a new rest_authentication_errors fallback once the
    route is resolved.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Only run the WooCommerce.com auth fallback for rest_route requests

    authenticate_wccom() already handles requests without rest_route during
    determine_current_user, so the fallback no longer checks a failed
    pretty-permalink request a second time. Document the two steps in the
    docblocks.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-wccom-site-rest-route-check b/plugins/woocommerce/changelog/fix-wccom-site-rest-route-check
new file mode 100644
index 00000000000..9056e16462b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wccom-site-rest-route-check
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix a server error on pages whose URL carries a malformed rest_route parameter.
diff --git a/plugins/woocommerce/includes/wccom-site/class-wc-wccom-site.php b/plugins/woocommerce/includes/wccom-site/class-wc-wccom-site.php
index 4acd894ec41..63b47249f88 100644
--- a/plugins/woocommerce/includes/wccom-site/class-wc-wccom-site.php
+++ b/plugins/woocommerce/includes/wccom-site/class-wc-wccom-site.php
@@ -30,6 +30,7 @@ class WC_WCCOM_Site {

 		add_action( 'woocommerce_wccom_install_products', array( 'WC_WCCOM_Site_Installer', 'install' ) );
 		add_filter( 'determine_current_user', array( __CLASS__, 'authenticate_wccom' ), 14 );
+		add_filter( 'rest_authentication_errors', array( __CLASS__, 'authentication_fallback' ) );
 		add_action( 'woocommerce_rest_api_get_rest_namespaces', array( __CLASS__, 'register_rest_namespace' ) );
 	}

@@ -48,7 +49,7 @@ class WC_WCCOM_Site {
 	 *
 	 * @since 3.7.0
 	 * @param int|false $user_id User ID.
-	 * @return int|false
+	 * @return int|WP_User|false
 	 */
 	public static function authenticate_wccom( $user_id ) {
 		if ( ! empty( $user_id ) || ! self::is_request_to_wccom_site_rest_api() ) {
@@ -134,6 +135,31 @@ class WC_WCCOM_Site {
 		return $user;
 	}

+	/**
+	 * Authenticate a WooCommerce.com request that carries `rest_route`.
+	 *
+	 * WordPress determines the current user before it parses the request, so `authenticate_wccom()` leaves
+	 * these requests alone during `determine_current_user` and they are authenticated here instead.
+	 *
+	 * @since 11.3.0
+	 * @param WP_Error|null|bool $error Error from another authentication handler, null if none.
+	 * @return WP_Error|null|bool
+	 */
+	public static function authentication_fallback( $error ) {
+		if ( ! empty( $error ) || 0 !== get_current_user_id() || ! isset( $_REQUEST['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			return $error;
+		}
+
+		$user = self::authenticate_wccom( false );
+
+		if ( ! $user instanceof WP_User ) {
+			return $error;
+		}
+
+		wp_set_current_user( $user->ID );
+		return true;
+	}
+
 	/**
 	 * Get the authorization header.
 	 *
@@ -166,20 +192,28 @@ class WC_WCCOM_Site {
 	/**
 	 * Check if this is a request to WCCOM Site REST API.
 	 *
+	 * Before WordPress parses the request only the URL path is checked. Requests carrying `rest_route`
+	 * return false until then, and `authentication_fallback()` authenticates them once the route is resolved.
+	 *
 	 * @since 3.7.0
 	 * @return bool
 	 */
 	protected static function is_request_to_wccom_site_rest_api() {
+		global $wp;

+		// Once WordPress is dispatching REST, the route it resolved is the one that counts.
+		if ( wp_is_rest_endpoint() ) {
+			$route = $wp instanceof WP ? ( $wp->query_vars['rest_route'] ?? null ) : null;
+			return is_string( $route ) && str_starts_with( ltrim( $route, '/' ), 'wccom-site/' );
+		}
+
+		// Before that, a rest_route in the query or body can override the path, so leave those requests to authentication_fallback().
 		if ( isset( $_REQUEST['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
-			$route       = wp_unslash( $_REQUEST['rest_route'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
-			$rest_prefix = '';
-		} else {
-			$route       = wp_unslash( add_query_arg( array() ) );
-			$rest_prefix = trailingslashit( rest_get_url_prefix() );
+			return false;
 		}

-		return false !== strpos( $route, $rest_prefix . 'wccom-site/' );
+		$path = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_PATH ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only compared against the REST prefix.
+		return is_string( $path ) && str_contains( $path, trailingslashit( rest_get_url_prefix() ) . 'wccom-site/' );
 	}

 	/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 2d95727e16e..afa4b58aaaf 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -35770,12 +35770,6 @@ parameters:
 			count: 1
 			path: includes/wccom-site/class-wc-wccom-site.php

-		-
-			message: '#^Method WC_WCCOM_Site\:\:authenticate_wccom\(\) should return int\|false but returns WP_User\.$#'
-			identifier: return.type
-			count: 1
-			path: includes/wccom-site/class-wc-wccom-site.php
-
 		-
 			message: '#^Method WC_WCCOM_Site\:\:includes\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/tests/php/includes/wccom-site/class-wc-wccom-site-test.php b/plugins/woocommerce/tests/php/includes/wccom-site/class-wc-wccom-site-test.php
new file mode 100644
index 00000000000..a54a2f4b542
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/wccom-site/class-wc-wccom-site-test.php
@@ -0,0 +1,237 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for how WC_WCCOM_Site recognizes and authenticates WooCommerce.com REST requests.
+ */
+class WC_WCCOM_Site_Test extends WC_Unit_Test_Case {
+
+	private const ACCESS_TOKEN = 'test-access-token';
+
+	private const ACCESS_TOKEN_SECRET = 'test-access-token-secret';
+
+	private const SERVER_KEYS = array( 'REQUEST_URI', 'REQUEST_METHOD', 'HTTP_HOST', 'HTTP_AUTHORIZATION', 'HTTP_X_WOO_SIGNATURE' );
+
+	/**
+	 * $_SERVER values the test overwrites, as they were before it ran.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private $original_server = array();
+
+	/**
+	 * $_GET and $_REQUEST before the test ran.
+	 *
+	 * @var array<string, array>
+	 */
+	private $original_request_globals = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		foreach ( self::SERVER_KEYS as $key ) {
+			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Restored verbatim in tearDown.
+			$this->original_server[ $key ] = $_SERVER[ $key ] ?? null;
+		}
+
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restored verbatim in tearDown.
+		$this->original_request_globals = array( $_GET, $_REQUEST );
+
+		wp_set_current_user( 0 );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		foreach ( $this->original_server as $key => $value ) {
+			if ( null === $value ) {
+				unset( $_SERVER[ $key ] );
+			} else {
+				$_SERVER[ $key ] = $value;
+			}
+		}
+
+		list( $_GET, $_REQUEST ) = $this->original_request_globals;
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Set up a request the way PHP and WordPress would see it.
+	 *
+	 * @param string $request_uri Request URI.
+	 * @param bool   $dispatching Whether WordPress is dispatching the request as REST.
+	 */
+	private function simulate_request( string $request_uri, bool $dispatching ): void {
+		$_SERVER['REQUEST_URI'] = $request_uri;
+
+		$query_params = array();
+		parse_str( (string) wp_parse_url( $request_uri, PHP_URL_QUERY ), $query_params );
+		$_GET     = $query_params;
+		$_REQUEST = $query_params;
+
+		add_filter( 'wp_is_rest_endpoint', $dispatching ? '__return_true' : '__return_false' );
+	}
+
+	/**
+	 * Connect the site to WooCommerce.com and sign the current request with its credentials.
+	 *
+	 * @param int    $user_id     User the connection belongs to.
+	 * @param string $request_uri Request URI to sign.
+	 */
+	private function sign_request_for_connected_site( int $user_id, string $request_uri ): void {
+		WC_Helper_Options::update(
+			'auth',
+			array(
+				'access_token'        => self::ACCESS_TOKEN,
+				'access_token_secret' => self::ACCESS_TOKEN_SECRET,
+				'user_id'             => $user_id,
+			)
+		);
+
+		$_SERVER['HTTP_HOST']          = 'example.org';
+		$_SERVER['REQUEST_METHOD']     = 'GET';
+		$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . self::ACCESS_TOKEN;
+
+		$signed_data = array(
+			'host'        => 'example.org',
+			'request_uri' => urldecode( remove_query_arg( array( 'token', 'signature' ), $request_uri ) ),
+			'method'      => 'GET',
+		);
+
+		$_SERVER['HTTP_X_WOO_SIGNATURE'] = hash_hmac( 'sha256', wp_json_encode( $signed_data ), self::ACCESS_TOKEN_SECRET );
+	}
+
+	/**
+	 * @testdox Should recognize a WooCommerce.com REST request only by the route WordPress dispatches.
+	 *
+	 * @testWith ["/wp-json/wccom-site/v3/status", false, null, true]
+	 *           ["/blog/wp-json/wccom-site/v3/status", false, null, true]
+	 *           ["/index.php/wp-json/wccom-site/v3/status", false, null, true]
+	 *           ["/wp-json/wccom-site/v3/status", true, "/wccom-site/v3/status", true]
+	 *           ["/wp-json/wccom-site/v3/status?rest_route=/wp/v2/users", false, null, false]
+	 *           ["/wp-json/wc/v3/products", false, null, false]
+	 *           ["/shop/?/wp-json/wccom-site/=1", false, null, false]
+	 *           ["/wp-json/wccom-site/v3/status", true, "/wp/v2/users", false]
+	 *           ["/wp-admin/admin-ajax.php?rest_route=/wccom-site/v3/status", false, null, false]
+	 *           ["/wp-admin/admin-ajax.php?rest_route=/wccom-site/v3/status", false, "/wccom-site/v3/status", false]
+	 *           ["/?rest_route=/wccom-site/v3/status", true, "/wccom-site/v3/status", true]
+	 *           ["/?rest_route[]=/wccom-site/v3/status", true, null, false]
+	 *
+	 * @param string      $request_uri    Request URI.
+	 * @param bool        $dispatching    Whether WordPress is dispatching the request as REST.
+	 * @param string|null $resolved_route Route WordPress resolved.
+	 * @param bool        $expected       Expected result.
+	 */
+	public function test_is_request_to_wccom_site_rest_api( string $request_uri, bool $dispatching, ?string $resolved_route, bool $expected ): void {
+		$this->simulate_request( $request_uri, $dispatching );
+
+		$method = new ReflectionMethod( WC_WCCOM_Site::class, 'is_request_to_wccom_site_rest_api' );
+		$method->setAccessible( true );
+
+		$this->assertSame( $expected, self::with_rest_route_context( $resolved_route, fn() => $method->invoke( null ) ) );
+	}
+
+	/**
+	 * @testdox Should leave the current user alone on a request with an array rest_route parameter.
+	 */
+	public function test_authenticate_wccom_ignores_array_rest_route(): void {
+		$this->simulate_request( '/?rest_route[]=x', false );
+
+		$this->assertFalse( self::with_rest_route_context( null, fn() => WC_WCCOM_Site::authenticate_wccom( false ) ) );
+	}
+
+	/**
+	 * @testdox Should authenticate a signed pretty-permalink request as the connected user before the route is resolved.
+	 */
+	public function test_authenticate_wccom_accepts_signed_pretty_permalink_request(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->simulate_request( '/wp-json/wccom-site/v3/status', false );
+		$this->sign_request_for_connected_site( $user_id, '/wp-json/wccom-site/v3/status' );
+
+		$user = self::with_rest_route_context( null, fn() => WC_WCCOM_Site::authenticate_wccom( false ) );
+
+		$this->assertInstanceOf( WP_User::class, $user );
+		$this->assertSame( $user_id, $user->ID );
+	}
+
+	/**
+	 * @testdox Should authenticate a signed plain-permalink request as the connected user once the route is resolved.
+	 */
+	public function test_authentication_fallback_sets_connected_user(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->simulate_request( '/?rest_route=/wccom-site/v3/status', true );
+		$this->sign_request_for_connected_site( $user_id, '/?rest_route=/wccom-site/v3/status' );
+
+		$this->assertTrue( self::with_rest_route_context( '/wccom-site/v3/status', fn() => WC_WCCOM_Site::authentication_fallback( null ) ) );
+		$this->assertSame( $user_id, get_current_user_id() );
+	}
+
+	/**
+	 * @testdox Should pass the previous result through for a signed request to a route outside wccom-site.
+	 */
+	public function test_authentication_fallback_ignores_other_routes(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->simulate_request( '/?rest_route=/wc/v3/products', true );
+		$this->sign_request_for_connected_site( $user_id, '/?rest_route=/wc/v3/products' );
+
+		$this->assertNull( self::with_rest_route_context( '/wc/v3/products', fn() => WC_WCCOM_Site::authentication_fallback( null ) ) );
+		$this->assertSame( 0, get_current_user_id() );
+	}
+
+	/**
+	 * @testdox Should leave a pretty-permalink request to determine_current_user.
+	 */
+	public function test_authentication_fallback_skips_requests_without_rest_route(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->simulate_request( '/wp-json/wccom-site/v3/status', true );
+		$this->sign_request_for_connected_site( $user_id, '/wp-json/wccom-site/v3/status' );
+
+		$this->assertNull( self::with_rest_route_context( '/wccom-site/v3/status', fn() => WC_WCCOM_Site::authentication_fallback( null ) ) );
+		$this->assertSame( 0, get_current_user_id() );
+	}
+
+	/**
+	 * @testdox Should not authenticate a plain-permalink request with an invalid signature.
+	 */
+	public function test_authentication_fallback_rejects_invalid_signature(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->simulate_request( '/?rest_route=/wccom-site/v3/status', true );
+		$this->sign_request_for_connected_site( $user_id, '/?rest_route=/wccom-site/v3/status' );
+		$_SERVER['HTTP_X_WOO_SIGNATURE'] = 'invalid';
+
+		$this->assertNull( self::with_rest_route_context( '/wccom-site/v3/status', fn() => WC_WCCOM_Site::authentication_fallback( null ) ) );
+		$this->assertSame( 0, get_current_user_id() );
+	}
+
+	/**
+	 * @testdox Should pass through a result another authentication handler already returned.
+	 */
+	public function test_authentication_fallback_keeps_earlier_result(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->simulate_request( '/?rest_route=/wccom-site/v3/status', true );
+		$this->sign_request_for_connected_site( $user_id, '/?rest_route=/wccom-site/v3/status' );
+		$error = new WP_Error( 'other_handler', 'Rejected elsewhere.' );
+
+		$this->assertSame( $error, self::with_rest_route_context( '/wccom-site/v3/status', fn() => WC_WCCOM_Site::authentication_fallback( $error ) ) );
+		$this->assertSame( 0, get_current_user_id() );
+	}
+
+	/**
+	 * @testdox Should keep the current user when one is already set.
+	 */
+	public function test_authentication_fallback_keeps_existing_user(): void {
+		$connected_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$current_user_id   = self::factory()->user->create( array( 'role' => 'customer' ) );
+		$this->simulate_request( '/?rest_route=/wccom-site/v3/status', true );
+		$this->sign_request_for_connected_site( $connected_user_id, '/?rest_route=/wccom-site/v3/status' );
+		wp_set_current_user( $current_user_id );
+
+		$this->assertNull( self::with_rest_route_context( '/wccom-site/v3/status', fn() => WC_WCCOM_Site::authentication_fallback( null ) ) );
+		$this->assertSame( $current_user_id, get_current_user_id() );
+	}
+}