Commit b774fb84d54 for woocommerce
commit b774fb84d543274b385ba06b8838fa81e75b877e
Author: Raluca Stan <ralucastn@gmail.com>
Date: Mon Aug 31 17:30:09 2026 +0200
Improve REST API key authentication route resolution (#68193)
Resolve the REST route from the request URI the way WordPress matches it,
and confirm the route WordPress ultimately resolves is one a WooCommerce
API key may authenticate.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/claude-fix-woo6-83 b/plugins/woocommerce/changelog/claude-fix-woo6-83
new file mode 100644
index 00000000000..9e85c7f8b76
--- /dev/null
+++ b/plugins/woocommerce/changelog/claude-fix-woo6-83
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Harden REST API key authentication route handling.
diff --git a/plugins/woocommerce/includes/class-wc-rest-authentication.php b/plugins/woocommerce/includes/class-wc-rest-authentication.php
index 209a7b48798..363b051031f 100644
--- a/plugins/woocommerce/includes/class-wc-rest-authentication.php
+++ b/plugins/woocommerce/includes/class-wc-rest-authentication.php
@@ -57,6 +57,7 @@ class WC_REST_Authentication {
add_filter( 'determine_current_user', array( $this, 'authenticate' ), 15 );
add_filter( 'rest_authentication_errors', array( $this, 'authentication_fallback' ) );
add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ), 15 );
+ add_filter( 'rest_authentication_errors', array( $this, 'reject_out_of_scope_route' ), 20 );
add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 );
add_filter( 'rest_pre_dispatch', array( $this, 'check_user_permissions' ), 10, 3 );
}
@@ -71,16 +72,89 @@ class WC_REST_Authentication {
return false;
}
+ // 'wc/' is WooCommerce; 'wc-' lets third party plugins use our authentication methods.
+ $route = $this->route_from_request_uri();
+ $is_wc_route = str_starts_with( $route, 'wc/' ) || str_starts_with( $route, 'wc-' );
+
+ /**
+ * Filters whether the current request is a request to the WooCommerce REST API.
+ *
+ * @since 2.6.0
+ *
+ * @param bool $is_request_to_rest_api Whether the request is to a WooCommerce REST API endpoint.
+ */
+ return apply_filters( 'woocommerce_rest_is_request_to_rest_api', $is_wc_route );
+ }
+
+ /**
+ * 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.
+ *
+ * @since 11.1.0
+ *
+ * @return string
+ */
+ private function route_from_request_uri() {
+ if ( empty( $_SERVER['REQUEST_URI'] ) ) {
+ return '';
+ }
+
$rest_prefix = trailingslashit( rest_get_url_prefix() );
- $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
- // Check if the request is to the WC API endpoints.
- $woocommerce = ( false !== strpos( $request_uri, $rest_prefix . 'wc/' ) );
+ /*
+ * Parse the raw URI, not an esc_url_raw() copy. This decides whether an API key may
+ * authenticate the request, so it has to read the URI the way WordPress routes on it.
+ * esc_url_raw() rewrites its input, so the route derived from an escaped copy is not always
+ * the route that gets served. Only parsed and compared here, never output, so no escaping is
+ * 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'] );
+ $query_string = wp_parse_url( $request_uri, PHP_URL_QUERY );
+ $query_params = array();
+
+ if ( is_string( $query_string ) ) {
+ 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'] ) ) {
+ return trim( $query_params['rest_route'], '/' );
+ }
+
+ $request_path = wp_parse_url( $request_uri, PHP_URL_PATH );
+
+ if ( ! is_string( $request_path ) ) {
+ return '';
+ }
+
+ // Strip the site's home path the way WP::parse_request() does, so subdirectory installs
+ // resolve to the same path WordPress matches its rewrite rules against.
+ $request_path = trim( $request_path, '/' );
+ $home_path = wp_parse_url( home_url(), PHP_URL_PATH );
- // Allow third party plugins use our authentication methods.
- $third_party = ( false !== strpos( $request_uri, $rest_prefix . 'wc-' ) );
+ if ( is_string( $home_path ) && '' !== $home_path ) {
+ $home_path = trim( $home_path, '/' );
+
+ if ( 0 === stripos( $request_path, $home_path ) ) {
+ $request_path = trim( substr( $request_path, strlen( $home_path ) ), '/' );
+ }
+ }
+
+ if ( str_starts_with( $request_path, 'index.php/' ) ) {
+ $request_path = substr( $request_path, strlen( 'index.php/' ) );
+ }
- return apply_filters( 'woocommerce_rest_is_request_to_rest_api', $woocommerce || $third_party );
+ // Pretty permalinks carry the route after the REST prefix; anything else is not a REST request.
+ if ( ! str_starts_with( $request_path, $rest_prefix ) ) {
+ return '';
+ }
+
+ return trim( substr( $request_path, strlen( $rest_prefix ) ), '/' );
}
/**
@@ -148,6 +222,68 @@ class WC_REST_Authentication {
return $this->get_error();
}
+ /**
+ * Reject a request when a WooCommerce API key authenticated a route outside our namespaces.
+ *
+ * Scope is first judged from REQUEST_URI, during 'determine_current_user', before WordPress has
+ * parsed the request, so at that point the route the request will be dispatched to is not settled
+ * yet. This runs later on rest_authentication_errors, after authentication_fallback() and
+ * check_authentication_error(), so $this->user reflects the final authentication decision and
+ * WordPress has resolved the route. It acts only on a key we authenticated ourselves; a failed or
+ * absent key keeps the result the earlier callbacks produced.
+ *
+ * @since 11.1.0
+ *
+ * @param WP_Error|null|bool $result Authentication result from earlier callbacks.
+ * @return WP_Error|null|bool
+ */
+ public function reject_out_of_scope_route( $result ) {
+ if ( null !== $this->user && ! $this->is_resolved_route_in_scope() ) {
+ return new WP_Error(
+ 'woocommerce_rest_authentication_error',
+ __( 'The provided API key cannot be used to access this endpoint.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Check that the route WordPress resolved is one an API key may authenticate.
+ *
+ * @since 11.1.0
+ *
+ * @return bool False only when the resolved route is not ours and the request URI never named it.
+ */
+ private function is_resolved_route_in_scope() {
+ global $wp;
+
+ // Has WordPress picked a route yet? If not, there is nothing to compare.
+ if ( ! $wp instanceof WP || ! isset( $wp->query_vars['rest_route'] ) || ! is_string( $wp->query_vars['rest_route'] ) ) {
+ return true;
+ }
+
+ // Our own namespaces are always in scope for a WooCommerce key: 'wc/' is WooCommerce, 'wc-'
+ // is a third party using our auth. The read/write permission check in check_user_permissions()
+ // still bounds what the key can do there.
+ $resolved_route = trim( $wp->query_vars['rest_route'], '/' );
+
+ if ( str_starts_with( $resolved_route, 'wc/' ) || str_starts_with( $resolved_route, 'wc-' ) ) {
+ return true;
+ }
+
+ /*
+ * 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.
+ */
+ return $resolved_route === $this->route_from_request_uri();
+ }
+
/**
* Set authentication error.
*
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 e85deacfe88..de65033571b 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
@@ -4,6 +4,450 @@
* Tests relating to our REST authentication logic.
*/
class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
+ /**
+ * The System Under Test.
+ *
+ * @var WC_REST_Authentication
+ */
+ private $sut;
+
+ /**
+ * The route WordPress had resolved before the test ran, if any.
+ *
+ * @var string|null
+ */
+ private $original_resolved_route;
+
+ /**
+ * Whether REQUEST_URI was set before the test ran.
+ *
+ * @var bool
+ */
+ private $had_request_uri;
+
+ /**
+ * The request URI the test ran with, verbatim.
+ *
+ * @var string|null
+ */
+ private $original_request_uri;
+
+ /**
+ * The authentication state WC_REST_Authentication held before the test ran.
+ *
+ * @var array<string, mixed>
+ */
+ private $original_authentication_state = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ global $wp;
+
+ $this->sut = WC_REST_Authentication::instance();
+
+ $this->original_resolved_route = $wp->query_vars['rest_route'] ?? null;
+ unset( $wp->query_vars['rest_route'] );
+
+ $this->had_request_uri = array_key_exists( 'REQUEST_URI', $_SERVER );
+ // Stored and restored verbatim, so tearDown leaves $_SERVER exactly as it found it. Sanitizing
+ // would drop the characters these tests use to tell a WooCommerce route from one that only looks
+ // like it, and unslashing would undo what wp_magic_quotes() put there.
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Round-tripped between $_SERVER and $_SERVER, never output or stored.
+ $this->original_request_uri = $this->had_request_uri ? $_SERVER['REQUEST_URI'] : null;
+
+ foreach ( array( 'user', 'error', 'auth_method' ) as $name ) {
+ $this->original_authentication_state[ $name ] = $this->authentication_property( $name )->getValue( $this->sut );
+ }
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ global $wp;
+
+ if ( null === $this->original_resolved_route ) {
+ unset( $wp->query_vars['rest_route'] );
+ } else {
+ $wp->query_vars['rest_route'] = $this->original_resolved_route;
+ }
+
+ if ( $this->had_request_uri ) {
+ $_SERVER['REQUEST_URI'] = $this->original_request_uri;
+ } else {
+ unset( $_SERVER['REQUEST_URI'] );
+ }
+
+ foreach ( $this->original_authentication_state as $name => $value ) {
+ $this->authentication_property( $name )->setValue( $this->sut, $value );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Accessor for one of WC_REST_Authentication's protected authentication properties.
+ *
+ * @param string $name Property name.
+ * @return ReflectionProperty
+ */
+ private function authentication_property( string $name ): ReflectionProperty {
+ $property = new ReflectionProperty( WC_REST_Authentication::class, $name );
+ $property->setAccessible( true );
+
+ return $property;
+ }
+
+ /**
+ * Call the protected is_request_to_rest_api() for the request the test has set up.
+ *
+ * @return bool
+ */
+ private function is_request_to_rest_api(): bool {
+ $method = new ReflectionMethod( $this->sut, 'is_request_to_rest_api' );
+ $method->setAccessible( true );
+
+ return $method->invoke( $this->sut );
+ }
+
+ /**
+ * Put WC_REST_Authentication into the state it reaches after an API key authenticates a request.
+ *
+ * @param int $user_id User the key belongs to.
+ * @param string $permissions Key permissions.
+ * @return void
+ */
+ private function authenticate_as( int $user_id, string $permissions = 'read_write' ): void {
+ $this->authentication_property( 'user' )->setValue(
+ $this->sut,
+ (object) array(
+ 'key_id' => 1,
+ 'user_id' => $user_id,
+ 'permissions' => $permissions,
+ )
+ );
+ }
+
+ /**
+ * @testdox Should identify WooCommerce REST requests by route only.
+ *
+ * @dataProvider provider_request_uris_for_rest_api_detection
+ *
+ * @param string $request_uri Request URI.
+ * @param bool $expected Expected result.
+ */
+ public function test_is_request_to_rest_api_checks_path_only( string $request_uri, bool $expected ): void {
+ $_SERVER['REQUEST_URI'] = $request_uri;
+
+ $this->assertSame( $expected, $this->is_request_to_rest_api() );
+ }
+
+ /**
+ * Data provider for REST API request detection.
+ *
+ * @return array[]
+ */
+ public static function provider_request_uris_for_rest_api_detection(): array {
+ return array(
+ 'woocommerce route' => array( '/wp-json/wc/v3/products', true ),
+ 'third-party woocommerce route' => array( '/wp-json/wc-custom/v1/resource', true ),
+ 'plain permalink woocommerce route' => array( '/?rest_route=/wc/v3/products', true ),
+ 'plain permalink third-party route' => array( '/?rest_route=/wc-custom/v1/resource', true ),
+ 'index.php plain permalink woocommerce route' => array( '/index.php?rest_route=/wc/v3/products', true ),
+ 'index.php path permalink woocommerce route' => array( '/index.php/wp-json/wc/v3/products', true ),
+ 'rest route overrides woocommerce-looking path' => array( '/wp-json/wc/v3/products?rest_route=/wp/v2/users', false ),
+ 'rest route overrides non-woocommerce path' => array( '/wp-json/wp/v2/users?rest_route=/wc/v3/products', true ),
+ '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 ),
+ // 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 ),
+ // PHP populates $_GET from the raw query string, so 'rest_rou^te' is a distinct parameter and
+ // WordPress routes this request to /wp/v2/users. Sanitizing the URI first would drop the '^'
+ // and change which parameter wins, so the route has to be read from the raw URI.
+ 'decoy parameter that sanitizing would merge' => array( '/?rest_route=/wp/v2/users&rest_rou^te=/wc/v3/products', false ),
+ );
+ }
+
+ /**
+ * @testdox Should detect WooCommerce routes on a subdirectory install, matching how WordPress strips the home path.
+ *
+ * @dataProvider provider_subdirectory_request_uris
+ *
+ * @param string $request_uri Request URI.
+ * @param bool $expected Expected result.
+ */
+ public function test_is_request_to_rest_api_strips_home_path( string $request_uri, bool $expected ): void {
+ $home_filter = function () {
+ return 'http://example.org/shop';
+ };
+
+ add_filter( 'option_home', $home_filter );
+ $_SERVER['REQUEST_URI'] = $request_uri;
+
+ try {
+ $this->assertSame( $expected, $this->is_request_to_rest_api() );
+ } finally {
+ remove_filter( 'option_home', $home_filter );
+ }
+ }
+
+ /**
+ * Data provider for subdirectory install detection, with the site's home path at '/shop'.
+ *
+ * @return array[]
+ */
+ public static function provider_subdirectory_request_uris(): array {
+ return array(
+ 'woocommerce route' => array( '/shop/wp-json/wc/v3/products', true ),
+ // 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 ),
+ 'non-woocommerce route' => array( '/shop/wp-json/wp/v2/users', false ),
+ );
+ }
+
+ /**
+ * @testdox Should only let an authenticated key through when the route WordPress resolved is a WooCommerce one.
+ *
+ * @dataProvider provider_resolved_routes
+ *
+ * @param string $resolved_route Route WordPress resolved for the request.
+ * @param bool $in_scope Whether a WooCommerce API key may authenticate it.
+ */
+ public function test_reject_out_of_scope_route_checks_resolved_route( string $resolved_route, bool $in_scope ): void {
+ global $wp;
+
+ // The request URI stays a WooCommerce one throughout, since the route WordPress resolves does
+ // not have to be the one the URI names. The scope check is what has to notice the difference.
+ $_SERVER['REQUEST_URI'] = '/wp-json/wc/v3/products';
+ $wp->query_vars['rest_route'] = $resolved_route;
+
+ $this->authenticate_as( 1 );
+
+ $result = $this->sut->reject_out_of_scope_route( null );
+
+ if ( $in_scope ) {
+ $this->assertNull( $result, 'A WooCommerce route must be left for the endpoint to serve.' );
+ return;
+ }
+
+ $this->assertWPError( $result, 'A WooCommerce API key must not authenticate a route outside our namespaces.' );
+ $this->assertSame( 'woocommerce_rest_authentication_error', $result->get_error_code() );
+ }
+
+ /**
+ * Data provider for the routes WordPress may resolve a request to.
+ *
+ * @return array[]
+ */
+ public static function provider_resolved_routes(): array {
+ return array(
+ 'woocommerce route' => array( '/wc/v3/products', true ),
+ 'third-party woocommerce route' => array( '/wc-custom/v1/resource', true ),
+ 'no leading slash' => array( 'wc/v3/products', true ),
+ 'core users route' => array( '/wp/v2/users', false ),
+ 'route index' => array( '/', false ),
+ 'woocommerce route as a suffix' => array( '/wp/v2/users?x=wc/v3/products', false ),
+ );
+ }
+
+ /**
+ * @testdox Should let an authenticated key through to a third-party route the request URI named.
+ */
+ public function test_reject_out_of_scope_route_allows_matching_third_party_route(): void {
+ global $wp;
+
+ // A genuine request to a third-party namespace: the URI names the route WordPress resolved.
+ $_SERVER['REQUEST_URI'] = '/wp-json/myplugin/v1/resource';
+ $wp->query_vars['rest_route'] = '/myplugin/v1/resource';
+
+ $this->authenticate_as( 1 );
+
+ $this->assertNull(
+ $this->sut->reject_out_of_scope_route( null ),
+ 'A third-party route the URI named must be left for the endpoint to serve.'
+ );
+ }
+
+ /**
+ * @testdox Should treat a namespace opted in through the woocommerce_rest_is_request_to_rest_api filter as a WooCommerce request.
+ */
+ public function test_is_request_to_rest_api_honours_scope_filter(): void {
+ $_SERVER['REQUEST_URI'] = '/wp-json/myplugin/v1/resource';
+
+ $this->assertFalse(
+ $this->is_request_to_rest_api(),
+ 'A third-party namespace is not a WooCommerce request until it opts in.'
+ );
+
+ add_filter( 'woocommerce_rest_is_request_to_rest_api', '__return_true' );
+
+ try {
+ $this->assertTrue(
+ $this->is_request_to_rest_api(),
+ 'A namespace opted in through the filter must be treated as a WooCommerce request.'
+ );
+ } finally {
+ remove_filter( 'woocommerce_rest_is_request_to_rest_api', '__return_true' );
+ }
+ }
+
+ /**
+ * @testdox Should reject a request whose resolved route is not the one the URI names.
+ */
+ public function test_reject_out_of_scope_route_rejects_overridden_third_party_route(): void {
+ global $wp;
+
+ // Two different non-WooCommerce namespaces: one named by the URI, one resolved. Rejected.
+ $_SERVER['REQUEST_URI'] = '/wp-json/myplugin/v1/resource';
+ $wp->query_vars['rest_route'] = '/wp/v2/users';
+
+ $this->authenticate_as( 1 );
+
+ $result = $this->sut->reject_out_of_scope_route( null );
+
+ $this->assertWPError( $result, 'A resolved route the URI never named must be rejected even when the URI names an opted-in namespace.' );
+ $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.
+ */
+ public function test_reject_out_of_scope_route_rejects_method_override_to_foreign_route(): void {
+ global $wp;
+
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Saved and restored verbatim, never output or stored.
+ $original_method = $_SERVER['REQUEST_METHOD'] ?? null;
+
+ // The scope check ignores the HTTP method, including a '_method' override in the query string,
+ // and only compares the resolved route to the one the URI names. A resolved route the URI never
+ // named is rejected either way.
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['REQUEST_URI'] = '/wp-json/wc/v3/products?_method=POST';
+ $wp->query_vars['rest_route'] = '/wp/v2/users';
+
+ $this->authenticate_as( 1 );
+
+ try {
+ $result = $this->sut->reject_out_of_scope_route( null );
+
+ $this->assertWPError( $result, 'A method override must not let a key reach a route the URI never named.' );
+ $this->assertSame( 'woocommerce_rest_authentication_error', $result->get_error_code() );
+ } finally {
+ if ( null === $original_method ) {
+ unset( $_SERVER['REQUEST_METHOD'] );
+ } else {
+ $_SERVER['REQUEST_METHOD'] = $original_method;
+ }
+ }
+ }
+
+ /**
+ * @testdox Should reject an out-of-scope route when the authentication fallback is what authenticated the key.
+ */
+ public function test_rest_authentication_errors_rejects_out_of_scope_route_from_fallback(): void {
+ global $wp, $wpdb;
+
+ $consumer_key = 'ck_' . wp_generate_password( 32, false );
+ $consumer_secret = 'cs_' . wp_generate_password( 32, false );
+
+ $wpdb->insert(
+ $wpdb->prefix . 'woocommerce_api_keys',
+ array(
+ 'user_id' => 1,
+ 'description' => 'Route scope test key',
+ 'permissions' => 'read_write',
+ 'consumer_key' => wc_api_hash( $consumer_key ),
+ 'consumer_secret' => $consumer_secret,
+ 'truncated_key' => substr( $consumer_key, -7 ),
+ )
+ );
+
+ // authentication_fallback() only authenticates when nothing did so during
+ // 'determine_current_user', which is the state left by setUp. Basic auth needs SSL.
+ $_SERVER['HTTPS'] = 'on';
+ $_SERVER['PHP_AUTH_USER'] = $consumer_key;
+ $_SERVER['PHP_AUTH_PW'] = $consumer_secret;
+ $_SERVER['REQUEST_URI'] = '/wp-json/wc/v3/products';
+ $wp->query_vars['rest_route'] = '/wp/v2/users';
+
+ wp_set_current_user( 0 );
+
+ try {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Running WordPress core's filter so both of our callbacks fire in order; not defining a hook.
+ $result = apply_filters( 'rest_authentication_errors', null );
+
+ $this->assertWPError( $result, 'A key authenticated by the fallback must not reach a core REST route.' );
+ $this->assertSame( 'woocommerce_rest_authentication_error', $result->get_error_code() );
+ $this->assertSame( 401, $result->get_error_data()['status'] );
+ } finally {
+ $wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'consumer_key' => wc_api_hash( $consumer_key ) ) );
+ unset( $_SERVER['HTTPS'], $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] );
+ wp_set_current_user( 0 );
+ }
+ }
+
+ /**
+ * @testdox Should let the authentication fallback through for a WooCommerce route.
+ */
+ public function test_rest_authentication_errors_allows_in_scope_route_from_fallback(): void {
+ global $wp, $wpdb;
+
+ $consumer_key = 'ck_' . wp_generate_password( 32, false );
+ $consumer_secret = 'cs_' . wp_generate_password( 32, false );
+
+ $wpdb->insert(
+ $wpdb->prefix . 'woocommerce_api_keys',
+ array(
+ 'user_id' => 1,
+ 'description' => 'Route scope test key',
+ 'permissions' => 'read_write',
+ 'consumer_key' => wc_api_hash( $consumer_key ),
+ 'consumer_secret' => $consumer_secret,
+ 'truncated_key' => substr( $consumer_key, -7 ),
+ )
+ );
+
+ $_SERVER['HTTPS'] = 'on';
+ $_SERVER['PHP_AUTH_USER'] = $consumer_key;
+ $_SERVER['PHP_AUTH_PW'] = $consumer_secret;
+ $_SERVER['REQUEST_URI'] = '/wp-json/wc/v3/products';
+ $wp->query_vars['rest_route'] = '/wc/v3/products';
+
+ wp_set_current_user( 0 );
+
+ try {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Running WordPress core's filter so both of our callbacks fire in order; not defining a hook.
+ $authenticated = apply_filters( 'rest_authentication_errors', null );
+
+ $this->assertTrue( $authenticated, 'The fallback must still authenticate a genuine WooCommerce route.' );
+ } finally {
+ $wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'consumer_key' => wc_api_hash( $consumer_key ) ) );
+ unset( $_SERVER['HTTPS'], $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] );
+ wp_set_current_user( 0 );
+ }
+ }
+
+ /**
+ * @testdox Should stay out of a request no WooCommerce API key authenticated.
+ */
+ public function test_reject_out_of_scope_route_ignores_requests_it_did_not_authenticate(): void {
+ global $wp;
+
+ $wp->query_vars['rest_route'] = '/wp/v2/users';
+
+ $this->assertNull(
+ $this->sut->reject_out_of_scope_route( null ),
+ 'A request without WooCommerce API key credentials should be left alone.'
+ );
+ }
+
/**
* The default behaviour is to record a last_access datetime only once per request.
*