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' ) );
+ }
}