Commit 98a8ba80aa1 for woocommerce

commit 98a8ba80aa1ebe19ee0e95b5476c979825ea414c
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Thu Sep 17 16:04:16 2026 +0200

    Add a dispatch check to REST API key authentication (#68822)

diff --git a/plugins/woocommerce/changelog/fix-woo6-168-rest-key-auth-outside-dispatch b/plugins/woocommerce/changelog/fix-woo6-168-rest-key-auth-outside-dispatch
new file mode 100644
index 00000000000..c20d4cad04c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-woo6-168-rest-key-auth-outside-dispatch
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Add a stricter dispatch check to REST API key authentication.
diff --git a/plugins/woocommerce/includes/class-wc-rest-authentication.php b/plugins/woocommerce/includes/class-wc-rest-authentication.php
index afa8580f858..4eb2e91027f 100644
--- a/plugins/woocommerce/includes/class-wc-rest-authentication.php
+++ b/plugins/woocommerce/includes/class-wc-rest-authentication.php
@@ -74,7 +74,7 @@ class WC_REST_Authentication {

 		$resolved_route = $this->resolved_route();
 		$is_wc_route    = $this->is_wc_namespace( $this->route_from_request_uri() )
-			|| ( null !== $resolved_route && $this->is_wc_namespace( $resolved_route ) );
+			|| ( wp_is_rest_endpoint() && null !== $resolved_route && $this->is_wc_namespace( $resolved_route ) );

 		/**
 		 * Filters whether the current request is a request to the WooCommerce REST API.
@@ -118,10 +118,9 @@ class WC_REST_Authentication {
 	/**
 	 * The REST route the request URI points to, normalized the way WordPress matches it.
 	 *
-	 * Returns the route without the REST prefix or surrounding slashes, e.g. 'wc/v3/products', or
-	 * an empty string when the URI is not a REST request. This reads the URI and nothing else, so the
-	 * route it returns is always the one the URI names. That is what is_resolved_route_in_scope()
-	 * compares the route WordPress ends up resolving against.
+	 * Returns the route without the REST prefix or surrounding slashes, e.g. 'wc/v3/products'. The
+	 * path is read unconditionally; a route named in the query string is only trusted once
+	 * wp_is_rest_endpoint() confirms genuine REST dispatch, and returns an empty string until then.
 	 *
 	 * @since 11.1.0
 	 *
@@ -157,8 +156,13 @@ class WC_REST_Authentication {
 			parse_str( $query_string, $query_params );
 		}

-		// Plain permalinks carry the route in the query string.
-		if ( isset( $query_params['rest_route'] ) && is_string( $query_params['rest_route'] ) ) {
+		// Plain permalinks can carry the route in the query string.
+		if ( isset( $query_params['rest_route'] ) ) {
+			// Only trust and read it once dispatch is confirmed and it's a single value.
+			if ( ! wp_is_rest_endpoint() || ! is_string( $query_params['rest_route'] ) ) {
+				return '';
+			}
+
 			return trim( $query_params['rest_route'], '/' );
 		}

diff --git a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php
index a5a42f32384..e78534ef91f 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php
@@ -114,6 +114,18 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 		return $method->invoke( $this->sut );
 	}

+	/**
+	 * Simulate the value wp_is_rest_endpoint() should report for the current test.
+	 *
+	 * @param bool $dispatching Whether wp_is_rest_endpoint() should return true.
+	 * @return void
+	 */
+	private function simulate_rest_dispatching( bool $dispatching ): void {
+		remove_filter( 'wp_is_rest_endpoint', '__return_true' );
+		remove_filter( 'wp_is_rest_endpoint', '__return_false' );
+		add_filter( 'wp_is_rest_endpoint', $dispatching ? '__return_true' : '__return_false' );
+	}
+
 	/**
 	 * Put WC_REST_Authentication into the state it reaches after an API key authenticates a request.
 	 *
@@ -141,6 +153,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 	 * @param bool   $expected    Expected result.
 	 */
 	public function test_is_request_to_rest_api_checks_path_only( string $request_uri, bool $expected ): void {
+		$this->simulate_rest_dispatching( true );
 		$_SERVER['REQUEST_URI'] = $request_uri;

 		$this->assertSame( $expected, $this->is_request_to_rest_api() );
@@ -190,6 +203,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 	public function test_is_request_to_rest_api_checks_resolved_route( string $request_uri, string $resolved_route, bool $expected ): void {
 		global $wp;

+		$this->simulate_rest_dispatching( true );
 		$_SERVER['REQUEST_URI']       = $request_uri;
 		$wp->query_vars['rest_route'] = $resolved_route;

@@ -210,6 +224,108 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should not trust a rest_route query parameter unless WordPress is dispatching the request as REST.
+	 *
+	 * @dataProvider provider_request_uris_with_rest_route_query_param
+	 *
+	 * @param string $request_uri Request URI.
+	 */
+	public function test_is_request_to_rest_api_trusts_query_string_route_only_when_dispatching( string $request_uri ): void {
+		$_SERVER['REQUEST_URI'] = $request_uri;
+
+		$this->simulate_rest_dispatching( false );
+		$this->assertFalse(
+			$this->is_request_to_rest_api(),
+			'A rest_route query parameter on a request WordPress is not dispatching as REST must not authenticate a key.'
+		);
+
+		$this->simulate_rest_dispatching( true );
+		$this->assertTrue(
+			$this->is_request_to_rest_api(),
+			'The same route must still be recognized once WordPress is genuinely dispatching it as REST.'
+		);
+	}
+
+	/**
+	 * Data provider for URIs that carry a route only in their query string.
+	 *
+	 * @return array[]
+	 */
+	public static function provider_request_uris_with_rest_route_query_param(): array {
+		return array(
+			'plain permalink route'                  => array( '/?rest_route=/wc/v3/products' ),
+			'unrelated endpoint, query string route' => array( '/wp-admin/admin-ajax.php?action=whoami&rest_route=/wc/v3' ),
+			'unrelated endpoint, rewritten path'     => array( '/wc-auth/v1/authorize?rest_route=/wc/v3/products' ),
+		);
+	}
+
+	/**
+	 * @testdox Should not fall back to the path route when a query string names a different route.
+	 *
+	 * @dataProvider provider_conflicting_path_and_query_string_routes
+	 *
+	 * @param string $request_uri Request URI.
+	 * @param bool   $expected    Expected result once WordPress is genuinely dispatching.
+	 */
+	public function test_is_request_to_rest_api_ignores_path_when_query_string_names_another_route( string $request_uri, bool $expected ): void {
+		$_SERVER['REQUEST_URI'] = $request_uri;
+
+		$this->simulate_rest_dispatching( false );
+		$this->assertFalse(
+			$this->is_request_to_rest_api(),
+			'A path naming a WooCommerce route must not authenticate a key while a query string names a different route and dispatch is not yet confirmed.'
+		);
+
+		$this->simulate_rest_dispatching( true );
+		$this->assertSame( $expected, $this->is_request_to_rest_api() );
+	}
+
+	/**
+	 * @return array[]
+	 */
+	public static function provider_conflicting_path_and_query_string_routes(): array {
+		return array(
+			'query string names a foreign route'           => array( '/wp-json/wc/v3/products?rest_route=/wp/v2/users', false ),
+			'query string names another woocommerce route' => array( '/wp-json/wc/v3/products?rest_route=/wc/v3/orders', true ),
+		);
+	}
+
+	/**
+	 * @testdox Should not fall back to the path route when a query string route is not a string.
+	 */
+	public function test_is_request_to_rest_api_ignores_path_when_query_string_route_is_not_a_string(): void {
+		$_SERVER['REQUEST_URI'] = '/wp-json/wc/v3/products?rest_route[]=/wp/v2/users';
+
+		$this->simulate_rest_dispatching( false );
+		$this->assertFalse( $this->is_request_to_rest_api() );
+
+		$this->simulate_rest_dispatching( true );
+		$this->assertFalse( $this->is_request_to_rest_api() );
+	}
+
+	/**
+	 * @testdox Should not trust the route WordPress resolved unless WordPress is dispatching the request as REST.
+	 */
+	public function test_is_request_to_rest_api_trusts_resolved_route_only_when_dispatching(): void {
+		global $wp;
+
+		$_SERVER['REQUEST_URI']       = '/wc-auth/v1/authorize';
+		$wp->query_vars['rest_route'] = '/wc/v3/products';
+
+		$this->simulate_rest_dispatching( false );
+		$this->assertFalse(
+			$this->is_request_to_rest_api(),
+			'A resolved route on a request WordPress is not dispatching as REST must not authenticate a key.'
+		);
+
+		$this->simulate_rest_dispatching( true );
+		$this->assertTrue(
+			$this->is_request_to_rest_api(),
+			'The same resolved route must still be recognized once WordPress is genuinely dispatching it as REST.'
+		);
+	}
+
 	/**
 	 * @testdox Should detect WooCommerce routes on a subdirectory install, matching how WordPress strips the home path.
 	 *
@@ -523,6 +639,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 			)
 		);

+		$this->simulate_rest_dispatching( true );
 		$_SERVER['HTTPS']             = 'on';
 		$_SERVER['PHP_AUTH_USER']     = $consumer_key;
 		$_SERVER['PHP_AUTH_PW']       = $consumer_secret;