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.
 	 */