Commit b74dee44447 for woocommerce
commit b74dee44447f600f3f2388e8bdb0d1463254c9e2
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Wed Nov 4 13:56:01 2026 +0100
Authenticate REST API keys only during REST dispatch (#70632)
WC_REST_Authentication treats a request as a WooCommerce REST API request only once wp_is_rest_endpoint() is true, so API keys are checked for REST requests only.
Co-authored-by: Jorge A. Torres <184724+jorgeatorres@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-rest-api-key-auth-during-dispatch b/plugins/woocommerce/changelog/fix-rest-api-key-auth-during-dispatch
new file mode 100644
index 00000000000..b3d1aa1801e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-rest-api-key-auth-during-dispatch
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restrict REST API key authentication to REST API requests.
diff --git a/plugins/woocommerce/includes/class-wc-rest-authentication.php b/plugins/woocommerce/includes/class-wc-rest-authentication.php
index 768bc221d20..c151ccaa851 100644
--- a/plugins/woocommerce/includes/class-wc-rest-authentication.php
+++ b/plugins/woocommerce/includes/class-wc-rest-authentication.php
@@ -55,7 +55,7 @@ class WC_REST_Authentication {
*/
public function __construct() {
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, 'authentication_fallback' ), 8 );
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 );
@@ -72,9 +72,14 @@ class WC_REST_Authentication {
return false;
}
+ // Only treat this as a WooCommerce REST API request once WordPress is actually dispatching one.
+ if ( ! wp_is_rest_endpoint() ) {
+ return false;
+ }
+
$resolved_route = $this->resolved_route();
$is_wc_route = $this->is_wc_namespace( $this->route_from_request_uri() )
- || ( wp_is_rest_endpoint() && null !== $resolved_route && $this->is_wc_namespace( $resolved_route ) );
+ || ( null !== $resolved_route && $this->is_wc_namespace( $resolved_route ) );
/**
* Filters whether the current request is a request to the WooCommerce REST API.
@@ -224,7 +229,11 @@ class WC_REST_Authentication {
* Authenticate the user if authentication wasn't performed during the
* determine_current_user action.
*
- * Necessary in cases where wp_get_current_user() is called before WooCommerce is loaded.
+ * This is the usual place a WooCommerce REST API key gets authenticated: it happens here
+ * because wp_get_current_user() usually resolves before REST dispatch is confirmed.
+ *
+ * Runs at priority 8, just ahead of the default priority other plugins tend to use on this
+ * filter, so our own call to get_current_user_id() below is what resolves the real user first.
*
* @see https://github.com/woocommerce/woocommerce/issues/26847
*
@@ -232,18 +241,20 @@ class WC_REST_Authentication {
* @return WP_Error|null|bool
*/
public function authentication_fallback( $error ) {
- if ( ! empty( $error ) ) {
- // Another plugin has already declared a failure.
+ if ( is_wp_error( $error ) ) {
+ // Another plugin has already declared a failure. A plain `true` falls through instead.
return $error;
}
- if ( empty( $this->error ) && empty( $this->auth_method ) && empty( $this->user ) && 0 === get_current_user_id() ) {
+ if ( 0 === get_current_user_id() && empty( $this->error ) && empty( $this->auth_method ) && empty( $this->user ) ) {
// Authentication hasn't occurred during `determine_current_user`, so check auth.
$user_id = $this->authenticate( false );
if ( $user_id ) {
wp_set_current_user( $user_id );
- return true;
}
}
+ if ( $this->user ) {
+ return true;
+ }
return $error;
}
@@ -265,9 +276,9 @@ class WC_REST_Authentication {
/**
* 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
+ * Scope is first judged from REQUEST_URI, during 'determine_current_user', where a URI that names
+ * one of our routes is enough, and that is not necessarily the route WordPress dispatches the
+ * request to. 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.
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 3b290be3a7d..88219b4907a 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
@@ -243,7 +243,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
$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.'
+ 'The same route must still be recognized once WordPress is dispatching it as REST.'
);
}
@@ -260,6 +260,76 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should treat a path as a WooCommerce REST API request only while WordPress is dispatching REST.
+ *
+ * @testWith ["/wp-json/wc/v3/products"]
+ * ["/wp-json/wc/v3/products?wc-auth-version=1&wc-auth-route=authorize"]
+ * ["/wp-json/wc/../../wp-admin/admin-ajax.php?action=whoami"]
+ *
+ * @param string $request_uri Request URI.
+ */
+ public function test_is_request_to_rest_api_requires_rest_dispatch( string $request_uri ): void {
+ $_SERVER['REQUEST_URI'] = $request_uri;
+
+ $this->simulate_rest_dispatching( false );
+ $this->assertFalse(
+ $this->is_request_to_rest_api(),
+ 'A request WordPress is not dispatching as REST is not a WooCommerce REST API request.'
+ );
+
+ $this->simulate_rest_dispatching( true );
+ $this->assertTrue(
+ $this->is_request_to_rest_api(),
+ 'The same path must still be recognized once WordPress is dispatching it as REST.'
+ );
+ }
+
+ /**
+ * @testdox Should authenticate a valid key once WordPress is dispatching the request as REST.
+ */
+ public function test_authenticate_accepts_valid_key_only_when_dispatching(): void {
+ global $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' => 'Dispatch test key',
+ 'permissions' => 'read',
+ 'consumer_key' => wc_api_hash( $consumer_key ),
+ 'consumer_secret' => $consumer_secret,
+ 'truncated_key' => substr( $consumer_key, -7 ),
+ )
+ );
+
+ // 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';
+
+ try {
+ $this->simulate_rest_dispatching( false );
+ $this->assertFalse( $this->sut->authenticate( false ), 'A key is not checked before REST dispatch.' );
+ $this->assertNull( $this->authentication_property( 'user' )->getValue( $this->sut ) );
+ $this->assertSame(
+ '',
+ $this->authentication_property( 'auth_method' )->getValue( $this->sut ),
+ 'Authentication must be left untried, so that authentication_fallback() can still run it once dispatch starts.'
+ );
+
+ $this->simulate_rest_dispatching( true );
+ $this->assertEquals( 1, $this->sut->authenticate( false ), 'The same key must authenticate once WordPress is dispatching the request as REST.' );
+ } 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'] );
+ }
+ }
+
/**
* @testdox Should not fall back to the path route when a query string names a different route.
*
@@ -322,7 +392,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
$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.'
+ 'The same resolved route must still be recognized once WordPress is dispatching it as REST.'
);
}
@@ -340,6 +410,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
};
add_filter( 'option_home', $home_filter );
+ $this->simulate_rest_dispatching( true );
$_SERVER['REQUEST_URI'] = $request_uri;
try {
@@ -479,6 +550,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
* @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 {
+ $this->simulate_rest_dispatching( true );
$_SERVER['REQUEST_URI'] = '/wp-json/myplugin/v1/resource';
$this->assertFalse(
@@ -493,6 +565,12 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
$this->is_request_to_rest_api(),
'A namespace opted in through the filter must be treated as a WooCommerce request.'
);
+
+ $this->simulate_rest_dispatching( false );
+ $this->assertFalse(
+ $this->is_request_to_rest_api(),
+ 'The filter must not opt in a request WordPress is not dispatching as REST.'
+ );
} finally {
remove_filter( 'woocommerce_rest_is_request_to_rest_api', '__return_true' );
}
@@ -590,6 +668,7 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
// authentication_fallback() only authenticates when nothing did so during
// 'determine_current_user', which is the state left by setUp. Basic auth needs SSL.
+ $this->simulate_rest_dispatching( true );
$_SERVER['HTTPS'] = 'on';
$_SERVER['PHP_AUTH_USER'] = $consumer_key;
$_SERVER['PHP_AUTH_PW'] = $consumer_secret;
@@ -660,6 +739,113 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should still authenticate its own key, and still enforce read/write permissions, when an
+ * earlier rest_authentication_errors callback already answered true.
+ */
+ public function test_authentication_fallback_still_binds_key_when_earlier_callback_reports_true(): 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' => 'Early true callback test key',
+ 'permissions' => 'read',
+ 'consumer_key' => wc_api_hash( $consumer_key ),
+ 'consumer_secret' => $consumer_secret,
+ 'truncated_key' => substr( $consumer_key, -7 ),
+ )
+ );
+
+ $this->simulate_rest_dispatching( true );
+ $_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 );
+
+ // A third-party plugin authenticating by its own scheme and reporting success ahead of our priority 8.
+ add_filter( 'rest_authentication_errors', '__return_true', 5 );
+
+ 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, 'An earlier plugin reporting success must not be overridden.' );
+
+ $result = $this->sut->check_user_permissions( null, $this->server, new WP_REST_Request( 'POST', '/wc/v3/products' ) );
+
+ $this->assertWPError( $result, 'Read/write permissions still apply to the key after an earlier callback returned true.' );
+ } finally {
+ remove_filter( 'rest_authentication_errors', '__return_true', 5 );
+ $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 still authenticate its own key even when an earlier, undispatched pass left
+ * a stale Application Passwords error behind.
+ */
+ public function test_authentication_fallback_ignores_stale_application_password_error(): void {
+ global $wp, $wpdb, $wp_rest_application_password_status, $current_user;
+
+ $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' => 'Stale application password error test key',
+ 'permissions' => 'read',
+ 'consumer_key' => wc_api_hash( $consumer_key ),
+ 'consumer_secret' => $consumer_secret,
+ 'truncated_key' => substr( $consumer_key, -7 ),
+ )
+ );
+
+ $this->simulate_rest_dispatching( true );
+ $_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';
+
+ // Clear the current user the way serve_request() does, so resolving it below goes through
+ // the real determine_current_user pass instead of our own direct authenticate() call.
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reproducing serve_request()'s own cache-busting exactly, not a WC global.
+ $current_user = null;
+
+ // Save the global, then simulate the stale error a premature pass would leave behind.
+ $original_status = $wp_rest_application_password_status;
+ $wp_rest_application_password_status = new WP_Error( 'invalid_username', 'The username is not registered on this site.' );
+
+ try {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Running WordPress core's filter chain, including core's own rest_application_password_check_errors(); not defining a hook.
+ $authenticated = apply_filters( 'rest_authentication_errors', null );
+
+ $this->assertTrue( $authenticated, 'Our own key must still authenticate; core must not act on the stale Application Passwords error.' );
+ $this->assertEquals(
+ 1,
+ $this->authentication_property( 'user' )->getValue( $this->sut )->user_id,
+ 'The key must still authenticate.'
+ );
+ } finally {
+ $wp_rest_application_password_status = $original_status;
+ $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.
*/