Commit 88d4292d20b for woocommerce

commit 88d4292d20b6ff1c49a5b4bfb3badbf0e38c7f72
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Tue Sep 1 17:10:36 2026 +0200

    Align REST API route resolution with how WordPress matches routes (#68227)

    * Fix REST route resolution for URIs with repeated leading slashes

    WP::parse_request() trims leading slashes off the request URI, so
    '//wp-json/...' resolves to the same route as '/wp-json/...'.
    wp_parse_url() reads a leading '//' as a host instead, leaving the
    path short of the REST prefix, so collapse them before parsing.

    * Fix REST scope check for percent-encoded third-party routes

    WordPress reads rest_route through parse_str(), which decodes it, while
    the route parsed from the request URI is read raw. Compare the decoded
    form too, trimmed the same way, so an encoded character is not read as
    naming another route.

    * Potential fix for pull request finding

    Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

    * Clarify how the encoded-route provider values map to rest_route

    * Combine the changelog entries into one

    * Fold the scope-check comments into one block

    ---------

    Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-rest-route-resolution b/plugins/woocommerce/changelog/fix-rest-route-resolution
new file mode 100644
index 00000000000..efcec466fa4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-rest-route-resolution
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Resolve REST API routes the way WordPress does when the request URI has repeated leading slashes or percent-encoded characters.
diff --git a/plugins/woocommerce/includes/class-wc-rest-authentication.php b/plugins/woocommerce/includes/class-wc-rest-authentication.php
index 363b051031f..8109658fc48 100644
--- a/plugins/woocommerce/includes/class-wc-rest-authentication.php
+++ b/plugins/woocommerce/includes/class-wc-rest-authentication.php
@@ -113,7 +113,14 @@ class WC_REST_Authentication {
 		 * needed.
 		 */
 		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Parsed and compared against REST route prefixes only, never output or stored; sanitizing would diverge from the route WordPress dispatches.
-		$request_uri  = wp_unslash( $_SERVER['REQUEST_URI'] );
+		$request_uri = wp_unslash( $_SERVER['REQUEST_URI'] );
+
+		// WP::parse_request() trims leading slashes off the URI, but wp_parse_url() would read a
+		// leading '//' as a host, so collapse them first.
+		if ( str_starts_with( $request_uri, '//' ) ) {
+			$request_uri = '/' . ltrim( $request_uri, '/' );
+		}
+
 		$query_string = wp_parse_url( $request_uri, PHP_URL_QUERY );
 		$query_params = array();

@@ -275,13 +282,15 @@ class WC_REST_Authentication {

 		/*
 		 * Any other namespace is in scope only when the request URI named this exact route.
-		 * WP::parse_request() can take rest_route from more than one place, so the route finally
-		 * dispatched is not necessarily the one is_request_to_rest_api() judged scope from and ran
-		 * woocommerce_rest_is_request_to_rest_api against. We only reach this method once a key
-		 * authenticated, which means that filter approved the URI route: a resolved route equal to the
-		 * URI route inherits that approval, and anything else does not.
+		 * WP::parse_request() can take rest_route from more than one place, so the dispatched route is
+		 * not necessarily the one woocommerce_rest_is_request_to_rest_api approved. WordPress also
+		 * decodes rest_route through parse_str() while the URI route is read raw, so the decoded form
+		 * names the same route.
 		 */
-		return $resolved_route === $this->route_from_request_uri();
+		$uri_route     = $this->route_from_request_uri();
+		$decoded_route = trim( urldecode( $uri_route ), '/' );
+
+		return $resolved_route === $uri_route || $resolved_route === $decoded_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 de65033571b..85319daeaad 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
@@ -164,6 +164,10 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 			'plain permalink non-woocommerce route'       => array( '/?rest_route=/wp/v2/users&x=wp-json/wc/', false ),
 			'non-woocommerce route with query'            => array( '/wp-json/wp/v2/users?context=edit&x=wp-json/wc/', false ),
 			'non-woocommerce path with substring'         => array( '/not-wp-json/wc/v3/products', false ),
+			'woocommerce route, repeated leading slash'   => array( '//wp-json/wc/v3/products', true ),
+			'third-party route, repeated leading slash'   => array( '//wp-json/wc-custom/v1/resource', true ),
+			'many leading slashes'                        => array( '////wp-json/wc/v3/products', true ),
+			'non-woocommerce route, repeated leading slash' => array( '//wp-json/wp/v2/users', false ),
 			// A character esc_url_raw() strips must not be collapsed into a WooCommerce route prefix.
 			'path with stripped character in prefix'      => array( '/wp-json/w^c/v3/products', false ),
 			'plain route with stripped character in prefix' => array( '/?rest_route=/w^c/v3/products', false ),
@@ -208,6 +212,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 			// WP::parse_request() strips the home path case-insensitively, so this still reaches WooCommerce.
 			'woocommerce route, home path cased' => array( '/Shop/wp-json/wc/v3/products', true ),
 			'index.php permalink'                => array( '/shop/index.php/wp-json/wc/v3/products', true ),
+			'repeated slash after home path'     => array( '/shop//wp-json/wc/v3/products', true ),
 			'non-woocommerce route'              => array( '/shop/wp-json/wp/v2/users', false ),
 		);
 	}
@@ -275,6 +280,53 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should let an authenticated key through to a third-party route the URI named in percent-encoded form.
+	 *
+	 * @dataProvider provider_encoded_third_party_routes
+	 *
+	 * @param string $request_uri    Request URI.
+	 * @param string $resolved_route Route WordPress resolves from that URI.
+	 */
+	public function test_reject_out_of_scope_route_allows_encoded_third_party_route( string $request_uri, string $resolved_route ): void {
+		global $wp;
+
+		$_SERVER['REQUEST_URI']       = $request_uri;
+		$wp->query_vars['rest_route'] = $resolved_route;
+
+		$this->authenticate_as( 1 );
+
+		$this->assertNull(
+			$this->sut->reject_out_of_scope_route( null ),
+			'A third-party route the URI named must stay in scope however the URI encoded it.'
+		);
+	}
+
+	/**
+	 * Data provider pairing an encoded request URI with the route WordPress resolves from it.
+	 *
+	 * @return array[]
+	 */
+	public static function provider_encoded_third_party_routes(): array {
+		return array(
+			'encoded space'          => array( '/wp-json/myplugin/v1/items/a%20b', '/myplugin/v1/items/a b' ),
+			'encoded hyphen'         => array( '/wp-json/myplugin/v1/items/a%2Db', '/myplugin/v1/items/a-b' ),
+			'encoded slash'          => array( '/wp-json/myplugin/v1/items/a%2Fb', '/myplugin/v1/items/a/b' ),
+			'encoded ampersand'      => array( '/wp-json/myplugin/v1/items/a%26b', '/myplugin/v1/items/a&b' ),
+			'encoded multibyte'      => array( '/wp-json/myplugin/v1/items/caf%C3%A9', '/myplugin/v1/items/café' ),
+			// rest_route is stored with its leading slash and trimmed before comparison, so the decoded
+			// URI route has to be trimmed too, or a trailing '%2F' leaves a slash behind.
+			'encoded trailing slash' => array( '/wp-json/myplugin/v1/items/a%2F', '/myplugin/v1/items/a' ),
+			// A '+' survives when the server fills PATH_INFO, and parse_str() reads it as a space when
+			// WordPress falls back to the raw URI. Both readings name this route.
+			'plus kept as plus'      => array( '/wp-json/myplugin/v1/items/a+b', '/myplugin/v1/items/a+b' ),
+			'plus read as space'     => array( '/wp-json/myplugin/v1/items/a+b', '/myplugin/v1/items/a b' ),
+			// WP::parse_request() re-escapes '%' in PATH_INFO, so an encoded percent reaches the route
+			// undecoded and only the raw comparison names it.
+			'encoded percent'        => array( '/wp-json/myplugin/v1/items/a%2520b', '/myplugin/v1/items/a%2520b' ),
+		);
+	}
+
 	/**
 	 * @testdox Should treat a namespace opted in through the woocommerce_rest_is_request_to_rest_api filter as a WooCommerce request.
 	 */
@@ -316,6 +368,25 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 'woocommerce_rest_authentication_error', $result->get_error_code() );
 	}

+	/**
+	 * @testdox Should reject a resolved route in the same namespace as the one the URI names.
+	 */
+	public function test_reject_out_of_scope_route_rejects_sibling_route_in_same_namespace(): void {
+		global $wp;
+
+		// Same namespace, different resource, and the URI is encoded. Decoding must not collapse the
+		// two into a match: scope is judged per route, not per namespace.
+		$_SERVER['REQUEST_URI']       = '/wp-json/myplugin/v1/items/a%20b';
+		$wp->query_vars['rest_route'] = '/myplugin/v1/items/other';
+
+		$this->authenticate_as( 1 );
+
+		$result = $this->sut->reject_out_of_scope_route( null );
+
+		$this->assertWPError( $result, 'A sibling route the URI never named must be rejected.' );
+		$this->assertSame( 'woocommerce_rest_authentication_error', $result->get_error_code() );
+	}
+
 	/**
 	 * @testdox Should reject a route the URI does not name even when the request overrides its method with _method.
 	 */