Commit e9ccd715987 for woocommerce

commit e9ccd715987c101792b199b065fabf2fb2064ec5
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Nov 4 14:02:29 2026 +0100

    Require a browser session on the Jetpack connection onboarding endpoints (#70630)

    The jetpack-authorization-url and connect-jetpack wc-admin endpoints now need a logged-in cookie and a wp_rest nonce on top of install_plugins.

    Co-authored-by: Raluca Stan <1628454+ralucaStan@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/require-interactive-session-jetpack-onboarding b/plugins/woocommerce/changelog/require-interactive-session-jetpack-onboarding
new file mode 100644
index 00000000000..bb7dcff4f01
--- /dev/null
+++ b/plugins/woocommerce/changelog/require-interactive-session-jetpack-onboarding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Require a logged-in admin browser session for the Jetpack connection endpoints used during onboarding.
diff --git a/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php b/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php
index 385a133acc4..f528844edc5 100644
--- a/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php
+++ b/plugins/woocommerce/src/Admin/API/OnboardingPlugins.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Admin\API;
 defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Admin\PluginsHelper;
 use Automattic\WooCommerce\Internal\Jetpack\JetpackConnection;
+use Automattic\WooCommerce\Internal\RestApi\InteractiveSessionCheck;
 use WC_REST_Data_Controller;
 use WP_Error;
 use WP_REST_Request;
@@ -22,6 +23,7 @@ use WP_REST_Response;
  * @extends WC_REST_Data_Controller
  */
 class OnboardingPlugins extends WC_REST_Data_Controller {
+
 	/**
 	 * Endpoint namespace.
 	 *
@@ -108,7 +110,7 @@ class OnboardingPlugins extends WC_REST_Data_Controller {
 				array(
 					'methods'             => 'GET',
 					'callback'            => array( $this, 'get_jetpack_authorization_url' ),
-					'permission_callback' => array( $this, 'can_install_plugins' ),
+					'permission_callback' => array( $this, 'check_jetpack_authorization_url_permissions' ),
 					'args'                => array(
 						'redirect_url' => array(
 							'description'       => 'The URL to redirect to after authorization',
@@ -249,6 +251,25 @@ class OnboardingPlugins extends WC_REST_Data_Controller {
 		return true;
 	}

+	/**
+	 * Permission check for the Jetpack authorization URL endpoint.
+	 *
+	 * Requires an interactive admin session on top of the `install_plugins` capability.
+	 *
+	 * @since 11.2.2
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request WP_REST_Request object.
+	 * @return WP_Error|boolean
+	 */
+	public function check_jetpack_authorization_url_permissions( $request ) {
+		$session = InteractiveSessionCheck::require_interactive_session( $request );
+		if ( is_wp_error( $session ) ) {
+			return $session;
+		}
+
+		return $this->can_install_plugins();
+	}
+
 	/**
 	 * Check whether the current user has permission to install and activate plugins
 	 *
diff --git a/plugins/woocommerce/src/Admin/API/Plugins.php b/plugins/woocommerce/src/Admin/API/Plugins.php
index e153d87917b..7ba6bab82a7 100644
--- a/plugins/woocommerce/src/Admin/API/Plugins.php
+++ b/plugins/woocommerce/src/Admin/API/Plugins.php
@@ -8,6 +8,7 @@
 namespace Automattic\WooCommerce\Admin\API;

 use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
+use Automattic\WooCommerce\Internal\RestApi\InteractiveSessionCheck;
 use Automattic\WooCommerce\Admin\PluginsHelper;

 defined( 'ABSPATH' ) || exit;
@@ -19,6 +20,7 @@ defined( 'ABSPATH' ) || exit;
  * @extends \WC_REST_Data_Controller
  */
 class Plugins extends \WC_REST_Data_Controller {
+
 	/**
 	 * Endpoint namespace.
 	 *
@@ -148,7 +150,7 @@ class Plugins extends \WC_REST_Data_Controller {
 				array(
 					'methods'             => \WP_REST_Server::READABLE,
 					'callback'            => array( $this, 'connect_jetpack' ),
-					'permission_callback' => array( $this, 'update_item_permissions_check' ),
+					'permission_callback' => array( $this, 'connect_jetpack_permissions_check' ),
 				),
 				'schema' => array( $this, 'get_connect_schema' ),
 			)
@@ -220,6 +222,25 @@ class Plugins extends \WC_REST_Data_Controller {
 		return true;
 	}

+	/**
+	 * Permission check for the connect-jetpack endpoint.
+	 *
+	 * Requires an interactive admin session on top of the `install_plugins` capability.
+	 *
+	 * @since 11.2.2
+	 *
+	 * @param  \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+	 * @return \WP_Error|boolean
+	 */
+	public function connect_jetpack_permissions_check( $request ) {
+		$session = InteractiveSessionCheck::require_interactive_session( $request );
+		if ( is_wp_error( $session ) ) {
+			return $session;
+		}
+
+		return $this->update_item_permissions_check( $request );
+	}
+
 	/**
 	 * Install the requested plugin.
 	 *
diff --git a/plugins/woocommerce/src/Internal/RestApi/InteractiveSessionCheck.php b/plugins/woocommerce/src/Internal/RestApi/InteractiveSessionCheck.php
new file mode 100644
index 00000000000..a31f38e08c9
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/InteractiveSessionCheck.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\RestApi;
+
+use WP_Error;
+use WP_REST_Request;
+
+/**
+ * Checks that a REST request comes from an interactive admin session.
+ *
+ * @since 11.2.2
+ */
+final class InteractiveSessionCheck {
+
+	/**
+	 * Check that the current user was authenticated by cookie and that the
+	 * request carries a valid `wp_rest` nonce, as a `_wpnonce` parameter or
+	 * an `X-WP-Nonce` header.
+	 *
+	 * @since 11.2.2
+	 *
+	 * @global mixed $wp_rest_auth_cookie True when core validated an auth cookie for this request.
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request WP_REST_Request object.
+	 * @return WP_Error|true
+	 */
+	public static function require_interactive_session( $request ) {
+		global $wp_rest_auth_cookie;
+
+		$nonce = $request->get_param( '_wpnonce' ) ?? $request->get_header( 'X-WP-Nonce' );
+
+		if ( ! is_user_logged_in() || true !== $wp_rest_auth_cookie || ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
+			return new WP_Error(
+				'woocommerce_rest_missing_session',
+				__( 'Sorry, you cannot perform this action without an interactive admin session.', 'woocommerce' ),
+				array( 'status' => rest_authorization_required_code() )
+			);
+		}
+
+		return true;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/README.md b/plugins/woocommerce/src/Internal/RestApi/README.md
index ab6189de2bf..5f710885393 100644
--- a/plugins/woocommerce/src/Internal/RestApi/README.md
+++ b/plugins/woocommerce/src/Internal/RestApi/README.md
@@ -8,6 +8,7 @@ Only `Routes/V4/` is the experimental v4 API. Other code in this directory is sh

 - `Refunds/`: the version-neutral refund engine used by the wc/v3 refund endpoints and the v4 routes.
 - `ProductRequestPreparationTrait.php`: used by the wc/v2 and wc/v3 product endpoints.
+- `InteractiveSessionCheck.php`: used by the wc-admin plugin and onboarding endpoints.

 `Routes/V4/Orders/OrderLineMetaValidator.php` is also used by the wc/v2 and wc/v3 order endpoints. Move it out of `Routes/V4/` before removing the v4 routes.

diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/plugins.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/plugins.php
index 46fdd451305..e16b96e56d7 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/plugins.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/plugins.php
@@ -50,6 +50,7 @@ class WC_Admin_Tests_API_Plugins extends WC_REST_Unit_Test_Case {
 	public function tearDown(): void {
 		deactivate_plugins( 'akismet/akismet.php', true );
 		$this->delete_test_plugin();
+		unset( $GLOBALS['wp_rest_auth_cookie'] );

 		parent::tearDown();
 	}
@@ -257,4 +258,97 @@ class WC_Admin_Tests_API_Plugins extends WC_REST_Unit_Test_Case {
 	public function set_france_locale() {
 		return 'fr_FR';
 	}
+
+	/**
+	 * @testdox The connect-jetpack endpoint rejects a request with no wp_rest nonce.
+	 */
+	public function test_connect_jetpack_rejects_request_without_nonce() {
+		wp_set_current_user( $this->user );
+		unset( $GLOBALS['wp_rest_auth_cookie'] );
+
+		$request  = new WP_REST_Request( 'GET', $this->endpoint . '/connect-jetpack' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status(), 'A request without an interactive session nonce must be rejected.' );
+		$this->assertEquals( 'woocommerce_rest_missing_session', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The connect-jetpack endpoint rejects a cookie session whose wp_rest nonce is missing or invalid.
+	 *
+	 * @testWith [null]
+	 *           ["not-a-valid-nonce"]
+	 *
+	 * @param string|null $nonce Nonce header to send, or null to send none.
+	 */
+	public function test_connect_jetpack_rejects_cookie_with_bad_nonce( $nonce ) {
+		wp_set_current_user( $this->user );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint . '/connect-jetpack' );
+		$this->add_rest_nonce( $request );
+		if ( null === $nonce ) {
+			$request->remove_header( 'X-WP-Nonce' );
+		} else {
+			$request->set_header( 'X-WP-Nonce', $nonce );
+		}
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status(), 'A cookie session without a valid wp_rest nonce must be rejected.' );
+		$this->assertEquals( 'woocommerce_rest_missing_session', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The connect-jetpack endpoint rejects a wp_rest nonce sent without a cookie session.
+	 */
+	public function test_connect_jetpack_rejects_nonce_without_cookie() {
+		wp_set_current_user( $this->user );
+		unset( $GLOBALS['wp_rest_auth_cookie'] );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint . '/connect-jetpack' );
+		$request->set_header( 'X-WP-Nonce', wp_create_nonce( 'wp_rest' ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status(), 'A wp_rest nonce without a cookie session must be rejected.' );
+		$this->assertEquals( 'woocommerce_rest_missing_session', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The connect-jetpack endpoint allows an admin with a valid wp_rest nonce.
+	 *
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $header_nonce Send the nonce as the X-WP-Nonce header, or as the _wpnonce parameter.
+	 */
+	public function test_connect_jetpack_allows_interactive_admin( $header_nonce ) {
+		wp_set_current_user( $this->user );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint . '/connect-jetpack' );
+		$this->add_rest_nonce( $request );
+		if ( ! $header_nonce ) {
+			$request->set_param( '_wpnonce', $request->get_header( 'X-WP-Nonce' ) );
+			$request->remove_header( 'X-WP-Nonce' );
+		}
+		$response = $this->server->dispatch( $request );
+
+		$this->assertNotEquals( 403, $response->get_status(), 'An admin browser session must pass the interactive-session check.' );
+		$this->assertNotEquals( 'woocommerce_rest_missing_session', $response->get_data()['code'] ?? '' );
+	}
+
+	/**
+	 * Authenticate the current user through a `logged_in` cookie, as core does
+	 * for a browser request, and attach the matching `wp_rest` nonce header.
+	 *
+	 * @param WP_REST_Request $request Request to authenticate.
+	 * @return void
+	 */
+	private function add_rest_nonce( WP_REST_Request $request ) {
+		$user_id    = get_current_user_id();
+		$expiration = time() + DAY_IN_SECONDS;
+		$token      = WP_Session_Tokens::get_instance( $user_id )->create( $expiration );
+
+		// Validating the cookie fires auth_cookie_valid, which core records in $wp_rest_auth_cookie.
+		wp_validate_auth_cookie( wp_generate_auth_cookie( $user_id, $expiration, 'logged_in', $token ), 'logged_in' );
+		$request->set_header( 'X-WP-Nonce', wp_create_nonce( 'wp_rest' ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/OnboardingPluginsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/OnboardingPluginsTest.php
index 578445e8171..142b94d3e12 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/OnboardingPluginsTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/OnboardingPluginsTest.php
@@ -31,6 +31,15 @@ class OnboardingPluginsTest extends WC_REST_Unit_Test_Case {
 		$this->useAdmin();
 	}

+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		unset( $GLOBALS['wp_rest_auth_cookie'] );
+
+		parent::tearDown();
+	}
+
 	/**
 	 * Use a user with administrator role.
 	 *
@@ -157,4 +166,111 @@ class OnboardingPluginsTest extends WC_REST_Unit_Test_Case {
 			$this->assertEquals( 403, $response->get_status() );
 		}
 	}
+
+	/**
+	 * @testdox The Jetpack authorization URL endpoint rejects a request with no wp_rest nonce.
+	 *
+	 * @return void
+	 */
+	public function test_jetpack_authorization_url_rejects_request_without_nonce() {
+		unset( $GLOBALS['wp_rest_auth_cookie'] );
+
+		$request = new WP_REST_Request( 'GET', self::ENDPOINT . '/jetpack-authorization-url' );
+		$request->set_param( 'redirect_url', 'https://example.com' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 403, $response->get_status(), 'A request without an interactive session nonce must be rejected.' );
+		$this->assertSame( 'woocommerce_rest_missing_session', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The Jetpack authorization URL endpoint rejects a cookie session whose wp_rest nonce is missing or invalid.
+	 *
+	 * @testWith [null]
+	 *           ["not-a-valid-nonce"]
+	 *
+	 * @param string|null $nonce Nonce header to send, or null to send none.
+	 * @return void
+	 */
+	public function test_jetpack_authorization_url_rejects_cookie_with_bad_nonce( $nonce ) {
+		$request = new WP_REST_Request( 'GET', self::ENDPOINT . '/jetpack-authorization-url' );
+		$request->set_param( 'redirect_url', 'https://example.com' );
+		$this->add_rest_nonce( $request );
+		if ( null === $nonce ) {
+			$request->remove_header( 'X-WP-Nonce' );
+		} else {
+			$request->set_header( 'X-WP-Nonce', $nonce );
+		}
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 403, $response->get_status(), 'A cookie session without a valid wp_rest nonce must be rejected.' );
+		$this->assertSame( 'woocommerce_rest_missing_session', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The Jetpack authorization URL endpoint rejects a wp_rest nonce sent without a cookie session.
+	 *
+	 * @return void
+	 */
+	public function test_jetpack_authorization_url_rejects_nonce_without_cookie() {
+		unset( $GLOBALS['wp_rest_auth_cookie'] );
+
+		$request = new WP_REST_Request( 'GET', self::ENDPOINT . '/jetpack-authorization-url' );
+		$request->set_param( 'redirect_url', 'https://example.com' );
+		$request->set_header( 'X-WP-Nonce', wp_create_nonce( 'wp_rest' ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 403, $response->get_status(), 'A wp_rest nonce without a cookie session must be rejected.' );
+		$this->assertSame( 'woocommerce_rest_missing_session', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The Jetpack authorization URL endpoint allows an admin with a valid wp_rest nonce.
+	 *
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $header_nonce Send the nonce as the X-WP-Nonce header, or as the _wpnonce parameter.
+	 * @return void
+	 */
+	public function test_jetpack_authorization_url_allows_interactive_admin( $header_nonce ) {
+		// The route callback registers an unconnected site with WordPress.com; keep that offline and fast.
+		add_filter(
+			'pre_http_request',
+			function () {
+				return new \WP_Error( 'http_request_blocked', 'Outbound HTTP is disabled in this test.' );
+			}
+		);
+		// The registration step destructures this option with list(), which PHP 8.5 warns about when the option is unset.
+		update_option( 'jetpack_activation_source', array( 'woocommerce', 'onboarding' ) );
+
+		$request = new WP_REST_Request( 'GET', self::ENDPOINT . '/jetpack-authorization-url' );
+		$request->set_param( 'redirect_url', 'https://example.com' );
+		$this->add_rest_nonce( $request );
+		if ( ! $header_nonce ) {
+			$request->set_param( '_wpnonce', $request->get_header( 'X-WP-Nonce' ) );
+			$request->remove_header( 'X-WP-Nonce' );
+		}
+		$response = $this->server->dispatch( $request );
+
+		$this->assertNotSame( 403, $response->get_status(), 'An admin browser session must pass the interactive-session check.' );
+		$this->assertArrayHasKey( 'url', $response->get_data() );
+	}
+
+	/**
+	 * Authenticate the current user through a `logged_in` cookie, as core does
+	 * for a browser request, and attach the matching `wp_rest` nonce header.
+	 *
+	 * @param WP_REST_Request $request Request to authenticate.
+	 * @return void
+	 */
+	private function add_rest_nonce( WP_REST_Request $request ) {
+		$user_id    = get_current_user_id();
+		$expiration = time() + DAY_IN_SECONDS;
+		$token      = \WP_Session_Tokens::get_instance( $user_id )->create( $expiration );
+
+		// Validating the cookie fires auth_cookie_valid, which core records in $wp_rest_auth_cookie.
+		wp_validate_auth_cookie( wp_generate_auth_cookie( $user_id, $expiration, 'logged_in', $token ), 'logged_in' );
+		$request->set_header( 'X-WP-Nonce', wp_create_nonce( 'wp_rest' ) );
+	}
 }