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;