PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-connection/src/class-rest-connector.php +827 -114 12.3.2 → 16.3 View file →
@@ -6,10 +6,12 @@
6 6 */
7 7
8 8 namespace Automattic\Jetpack\Connection;
9 9
10 +use Automattic\Jetpack\Connection\Webhooks\Authorize_Redirect;
10 11 use Automattic\Jetpack\Constants;
11 12 use Automattic\Jetpack\Redirect;
13 +use Automattic\Jetpack\Roles;
12 14 use Automattic\Jetpack\Status;
13 15 use Jetpack_XMLRPC_Server;
14 16 use WP_Error;
15 17 use WP_REST_Request;
@@ -17,11 +19,26 @@
17 19 use WP_REST_Server;
18 20
19 21 /**
20 22 * Registers the REST routes for Connections.
23 + *
24 + * @phan-constructor-used-for-side-effects
21 25 */
22 26 class REST_Connector {
27 +
23 28 /**
29 + * Site record options left out of the site data REST response.
30 + *
31 + * @since 9.9.0.1
32 + *
33 + * @var string[]
34 + */
35 + const EXCLUDED_SITE_OPTIONS = array(
36 + 'frame_nonce',
37 + 'jetpack_frame_nonce',
38 + );
39 +
40 + /**
24 41 * The Connection Manager.
25 42 *
26 43 * @var Manager
27 44 */
@@ -83,8 +100,88 @@
83 100 'permission_callback' => '__return_true',
84 101 )
85 102 );
86 103
104 + // Authorize a remote user.
105 + register_rest_route(
106 + 'jetpack/v4',
107 + '/remote_provision',
108 + array(
109 + 'methods' => WP_REST_Server::EDITABLE,
110 + 'callback' => array( $this, 'remote_provision' ),
111 + 'permission_callback' => array( $this, 'remote_provision_permission_check' ),
112 + )
113 + );
114 +
115 + register_rest_route(
116 + 'jetpack/v4',
117 + '/remote_register',
118 + array(
119 + 'methods' => WP_REST_Server::EDITABLE,
120 + 'callback' => array( $this, 'remote_register' ),
121 + 'permission_callback' => array( $this, 'remote_register_permission_check' ),
122 + )
123 + );
124 +
125 + // Connect a remote user.
126 + register_rest_route(
127 + 'jetpack/v4',
128 + '/remote_connect',
129 + array(
130 + 'methods' => WP_REST_Server::EDITABLE,
131 + 'callback' => array( $this, 'remote_connect' ),
132 + 'permission_callback' => array( $this, 'remote_connect_permission_check' ),
133 + )
134 + );
135 +
136 + // The endpoint verifies blog connection and blog token validity.
137 + register_rest_route(
138 + 'jetpack/v4',
139 + '/connection/check',
140 + array(
141 + 'methods' => WP_REST_Server::READABLE,
142 + 'callback' => array( $this, 'connection_check' ),
143 + 'permission_callback' => array( $this, 'connection_check_permission_check' ),
144 + )
145 + );
146 +
147 + // Get the site's own record from WordPress.com.
148 + register_rest_route(
149 + 'jetpack/v4',
150 + '/site',
151 + array(
152 + 'methods' => WP_REST_Server::READABLE,
153 + 'callback' => array( $this, 'get_site_data' ),
154 + 'permission_callback' => __CLASS__ . '::site_data_permission_check',
155 + ),
156 + true // override other implementations.
157 + );
158 +
159 + // Run all connection health tests.
160 + register_rest_route(
161 + 'jetpack/v4',
162 + '/connection/test',
163 + array(
164 + 'methods' => WP_REST_Server::READABLE,
165 + 'callback' => array( $this, 'connection_test' ),
166 + 'permission_callback' => __CLASS__ . '::connection_test_permission_check',
167 + ),
168 + true // override other implementations.
169 + );
170 +
171 + // Connection health tests for privileged external callers (WP.com debugger).
172 + // Trailing slash matches the old Jetpack plugin registration so the override takes effect.
173 + register_rest_route(
174 + 'jetpack/v4',
175 + '/connection/test-wpcom/',
176 + array(
177 + 'methods' => WP_REST_Server::READABLE,
178 + 'callback' => array( $this, 'connection_test_for_external' ),
179 + 'permission_callback' => __CLASS__ . '::is_request_signed_by_jetpack_debugger',
180 + ),
181 + true // override other implementations.
182 + );
183 +
87 184 // Get current connection status of Jetpack.
88 185 register_rest_route(
89 186 'jetpack/v4',
90 187 '/connection',
@@ -122,8 +219,22 @@
122 219 ),
123 220 )
124 221 );
125 222
223 + // Disconnect/unlink user from WordPress.com servers.
224 + // this endpoint is set to override the older endpoint that was previously in the Jetpack plugin
225 + // Override is here in case an older version of the Jetpack plugin is installed alongside an updated standalone.
226 + register_rest_route(
227 + 'jetpack/v4',
228 + '/connection/user',
229 + array(
230 + 'methods' => WP_REST_Server::EDITABLE,
231 + 'callback' => __CLASS__ . '::unlink_user',
232 + 'permission_callback' => __CLASS__ . '::unlink_user_permission_callback',
233 + ),
234 + true // override other implementations.
235 + );
236 +
126 237 // We are only registering this route if Jetpack-the-plugin is not active or it's version is ge 10.0-alpha.
127 238 // The reason for doing so is to avoid conflicts between the Connection package and
128 239 // older versions of Jetpack, registering the same route twice.
129 240 if ( empty( $jp_version ) || version_compare( $jp_version, '10.0-alpha', '>=' ) ) {
@@ -169,22 +280,17 @@
169 280 'methods' => WP_REST_Server::EDITABLE,
170 281 'callback' => array( $this, 'connection_register' ),
171 282 'permission_callback' => __CLASS__ . '::jetpack_register_permission_check',
172 283 'args' => array(
173 - 'from' => array(
284 + 'from' => array(
174 285 'description' => __( 'Indicates where the registration action was triggered for tracking/segmentation purposes', 'jetpack-connection' ),
175 286 'type' => 'string',
176 287 ),
177 - 'registration_nonce' => array(
178 - 'description' => __( 'The registration nonce', 'jetpack-connection' ),
179 - 'type' => 'string',
180 - 'required' => true,
181 - ),
182 - 'redirect_uri' => array(
288 + 'redirect_uri' => array(
183 289 'description' => __( 'URI of the admin page where the user should be redirected after connection flow', 'jetpack-connection' ),
184 290 'type' => 'string',
185 291 ),
186 - 'plugin_slug' => array(
292 + 'plugin_slug' => array(
187 293 'description' => __( 'Indicates from what plugin the request is coming from', 'jetpack-connection' ),
188 294 'type' => 'string',
189 295 ),
190 296 ),
@@ -203,8 +309,12 @@
203 309 'redirect_uri' => array(
204 310 'description' => __( 'URI of the admin page where the user should be redirected after connection flow', 'jetpack-connection' ),
205 311 'type' => 'string',
206 312 ),
313 + 'from' => array(
314 + 'description' => __( 'Tracking/segmentation identifier for this authorize URL request', 'jetpack-connection' ),
315 + 'type' => 'string',
316 + ),
207 317 ),
208 318 )
209 319 );
210 320
@@ -247,8 +357,30 @@
247 357 ),
248 358 ),
249 359 )
250 360 );
361 +
362 + // Confirm the current user as the protected owner. Not the connection-owner change above.
363 + register_rest_route(
364 + 'jetpack/v4',
365 + '/connection/owner/protect',
366 + array(
367 + 'methods' => WP_REST_Server::EDITABLE,
368 + 'callback' => array( static::class, 'protect_connection_owner' ),
369 + 'permission_callback' => array( static::class, 'protect_connection_owner_permission_check' ),
370 + )
371 + );
372 +
373 + // Release the protected owner, leaving ownership open to any connected administrator.
374 + register_rest_route(
375 + 'jetpack/v4',
376 + '/connection/owner/release',
377 + array(
378 + 'methods' => WP_REST_Server::EDITABLE,
379 + 'callback' => array( static::class, 'release_connection_owner' ),
380 + 'permission_callback' => array( static::class, 'release_connection_owner_permission_check' ),
381 + )
382 + );
251 383 }
252 384
253 385 /**
254 386 * Handles verification that a site is registered.
@@ -273,9 +405,9 @@
273 405 * @since-jetpack 5.4.0
274 406 *
275 407 * @param WP_REST_Request $request The request sent to the WP REST API.
276 408 *
277 - * @return array|wp-error
409 + * @return array|WP_Error
278 410 */
279 411 public static function remote_authorize( $request ) {
280 412 $xmlrpc_server = new Jetpack_XMLRPC_Server();
281 413 $result = $xmlrpc_server->remote_authorize( $request );
@@ -287,8 +419,117 @@
287 419 return $result;
288 420 }
289 421
290 422 /**
423 + * Initiate the site provisioning process.
424 + *
425 + * @since 2.5.0
426 + *
427 + * @param WP_REST_Request $request The request sent to the WP REST API.
428 + *
429 + * @return WP_Error|array
430 + */
431 + public function remote_provision( WP_REST_Request $request ) {
432 + $request_data = $request->get_params();
433 +
434 + if ( current_user_can( 'jetpack_connect_user' ) ) {
435 + $request_data['local_user'] = get_current_user_id();
436 + }
437 +
438 + $xmlrpc_server = new Jetpack_XMLRPC_Server();
439 + $result = $xmlrpc_server->remote_provision( $request_data );
440 +
441 + if ( is_a( $result, 'IXR_Error' ) ) {
442 + $result = new WP_Error( $result->code, $result->message );
443 + }
444 +
445 + return $result;
446 + }
447 +
448 + /**
449 + * Connect a remote user.
450 + *
451 + * @since 2.6.0
452 + *
453 + * @param WP_REST_Request $request The request sent to the WP REST API.
454 + *
455 + * @return WP_Error|array
456 + */
457 + public static function remote_connect( WP_REST_Request $request ) {
458 + $xmlrpc_server = new Jetpack_XMLRPC_Server();
459 + $result = $xmlrpc_server->remote_connect( $request );
460 +
461 + if ( is_a( $result, 'IXR_Error' ) ) {
462 + $result = new WP_Error( $result->code, $result->message );
463 + }
464 +
465 + return $result;
466 + }
467 +
468 + /**
469 + * Register the site so that a plan can be provisioned.
470 + *
471 + * @since 2.5.0
472 + *
473 + * @param WP_REST_Request $request The request object.
474 + *
475 + * @return WP_Error|array
476 + */
477 + public function remote_register( WP_REST_Request $request ) {
478 + $xmlrpc_server = new Jetpack_XMLRPC_Server();
479 + $result = $xmlrpc_server->remote_register( $request );
480 +
481 + if ( is_a( $result, 'IXR_Error' ) ) {
482 + $result = new WP_Error( $result->code, $result->message );
483 + }
484 +
485 + return $result;
486 + }
487 +
488 + /**
489 + * Remote provision endpoint permission check.
490 + *
491 + * @param WP_REST_Request $request The request object.
492 + *
493 + * @return true|WP_Error
494 + */
495 + public function remote_provision_permission_check( WP_REST_Request $request ) {
496 + if ( empty( $request['local_user'] ) && current_user_can( 'jetpack_connect_user' ) ) {
497 + return true;
498 + }
499 +
500 + return Rest_Authentication::is_signed_with_blog_token()
501 + ? true
502 + : new WP_Error( 'invalid_permission_remote_provision', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
503 + }
504 +
505 + /**
506 + * Remote connect endpoint permission check.
507 + *
508 + * @return true|WP_Error
509 + */
510 + public function remote_connect_permission_check() {
511 + return Rest_Authentication::is_signed_with_blog_token()
512 + ? true
513 + : new WP_Error( 'invalid_permission_remote_connect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
514 + }
515 +
516 + /**
517 + * Remote register endpoint permission check.
518 + *
519 + * @return true|WP_Error
520 + */
521 + public function remote_register_permission_check() {
522 + if ( $this->connection->has_connected_owner() ) {
523 + return Rest_Authentication::is_signed_with_blog_token()
524 + ? true
525 + : new WP_Error( 'already_registered', __( 'Blog is already registered', 'jetpack-connection' ), 400 );
526 + }
527 +
528 + return true;
529 + }
530 +
531 + /**
291 532 * Get connection status for this Jetpack site.
292 533 *
293 534 * @since 1.7.0
294 535 * @since-jetpack 4.3.0
@@ -302,9 +543,9 @@
302 543 $connection = new Manager();
303 544
304 545 $connection_status = array(
305 546 'isActive' => $connection->has_connected_owner(), // TODO deprecate this.
306 - 'isStaging' => $status->is_staging_site(),
547 + 'isStaging' => $status->in_safe_mode(), // TODO deprecate this.
307 548 'isRegistered' => $connection->is_connected(),
308 549 'isUserConnected' => $connection->is_user_connected(),
309 550 'hasConnectedOwner' => $connection->has_connected_owner(),
310 551 'offlineMode' => array(
@@ -311,10 +552,11 @@
311 552 'isActive' => $status->is_offline_mode(),
312 553 'constant' => defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG,
313 554 'url' => $status->is_local_site(),
314 555 /** This filter is documented in packages/status/src/class-status.php */
315 - 'filter' => ( apply_filters( 'jetpack_development_mode', false ) || apply_filters( 'jetpack_offline_mode', false ) ), // jetpack_development_mode is deprecated.
556 + 'filter' => apply_filters( 'jetpack_offline_mode', false ),
316 557 'wpLocalConstant' => defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV,
558 + 'option' => (bool) get_option( 'jetpack_offline_mode' ),
317 559 ),
318 560 'isPublic' => '1' == get_option( 'blog_public' ), // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
319 561 );
320 562
@@ -403,9 +645,11 @@
403 645 * Permission check for the disconnect site endpoint.
404 646 *
405 647 * @since 1.30.1
406 648 *
407 - * @return bool|WP_Error True if user is able to disconnect the site.
649 + * @since 5.1.0 Modified the permission check to accept requests signed with blog tokens.
650 + *
651 + * @return bool|WP_Error True if user is able to disconnect the site or the request is signed with a blog token (aka a direct request from WPCOM).
408 652 */
409 653 public static function disconnect_site_permission_check() {
410 654 if ( current_user_can( 'jetpack_disconnect' ) ) {
411 655 return true;
@@ -410,10 +654,29 @@
410 654 if ( current_user_can( 'jetpack_disconnect' ) ) {
411 655 return true;
412 656 }
413 657
658 + return Rest_Authentication::is_signed_with_blog_token()
659 + ? true
660 + : new WP_Error( 'invalid_user_permission_jetpack_disconnect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
661 + }
662 +
663 + /**
664 + * Verify that a user can use the /connection/user endpoint. Has to be a registered user and be currently linked.
665 + *
666 + * @since 6.3.3
667 + *
668 + * @return bool|WP_Error True if user is able to unlink.
669 + */
670 + public static function unlink_user_permission_callback() {
671 + // This is a mapped capability
672 + // phpcs:ignore WordPress.WP.Capabilities.Unknown
673 + if ( current_user_can( 'jetpack_unlink_user' ) && ( new Manager() )->is_user_connected( get_current_user_id() ) ) {
674 + return true;
675 + }
676 +
414 677 return new WP_Error(
415 - 'invalid_user_permission_jetpack_disconnect',
678 + 'invalid_user_permission_unlink_user',
416 679 self::get_user_permissions_error_msg(),
417 680 array( 'status' => rest_authorization_required_code() )
418 681 );
419 682 }
@@ -433,9 +696,17 @@
433 696 $blog_id = \Jetpack_Options::get_option( 'id' );
434 697
435 698 $connection = new Manager();
436 699
437 - $current_user = wp_get_current_user();
700 + $current_user = wp_get_current_user();
701 +
702 + // Token-dependent on purpose: connectionOwner and isMaster describe the
703 + // *connected* owner and go null/false when the owner's token is broken. Status
704 + // UIs (e.g. My Jetpack's connection card) rely on that meaning. Record-based
705 + // ownership identity (who holds the connection per the master_user option,
706 + // token or not) is exposed separately via Initial_State's connectionOwner, and
707 + // owner token health via connectionStatus.hasConnectedOwner. Do not consolidate
708 + // the two derivations: they answer different questions.
438 709 $connection_owner = $connection->get_connection_owner();
439 710
440 711 $owner_display_name = false === $connection_owner ? null : $connection_owner->display_name;
441 712
@@ -458,21 +729,33 @@
458 729 )
459 730 )
460 731 : false );
461 732
733 + // Check for possible account errors between the local user and WPCOM account.
734 + $possible_errors = array();
735 + if ( $is_user_connected && ! empty( $wpcom_user_data['email'] ) ) {
736 + $user_account_status = new \Automattic\Jetpack\Connection\User_Account_Status();
737 + $possible_errors = $user_account_status->check_account_errors( $current_user->user_email, $wpcom_user_data['email'] );
738 + }
739 +
462 740 $current_user_connection_data = array(
463 - 'isConnected' => $is_user_connected,
464 - 'isMaster' => $is_master_user,
465 - 'username' => $current_user->user_login,
466 - 'id' => $current_user->ID,
467 - 'blogId' => $blog_id,
468 - 'wpcomUser' => $wpcom_user_data,
469 - 'gravatar' => get_avatar_url( $current_user->ID, 64, 'mm', '', array( 'force_display' => true ) ),
470 - 'permissions' => array(
471 - 'connect' => current_user_can( 'jetpack_connect' ),
472 - 'connect_user' => current_user_can( 'jetpack_connect_user' ),
473 - 'disconnect' => current_user_can( 'jetpack_disconnect' ),
741 + 'isConnected' => $is_user_connected,
742 + 'isMaster' => $is_master_user,
743 + 'username' => $current_user->user_login,
744 + 'id' => $current_user->ID,
745 + 'blogId' => $blog_id,
746 + 'wpcomUser' => $wpcom_user_data,
747 + 'gravatar' => get_avatar_url( $current_user->ID ),
748 + 'permissions' => array(
749 + 'connect' => current_user_can( 'jetpack_connect' ),
750 + 'connect_user' => current_user_can( 'jetpack_connect_user' ),
751 + // This is a mapped capability
752 + // phpcs:ignore WordPress.WP.Capabilities.Unknown
753 + 'unlink_user' => current_user_can( 'jetpack_unlink_user' ),
754 + 'disconnect' => current_user_can( 'jetpack_disconnect' ),
755 + 'manage_options' => current_user_can( 'manage_options' ),
474 756 ),
757 + 'possibleAccountErrors' => $possible_errors,
475 758 );
476 759
477 760 /**
478 761 * Filters the current user connection data.
@@ -485,8 +768,9 @@
485 768
486 769 $response = array(
487 770 'currentUser' => $current_user_connection_data,
488 771 'connectionOwner' => $owner_display_name,
772 + 'isRegistered' => $connection->is_connected(),
489 773 );
490 774
491 775 if ( $rest_response ) {
492 776 return rest_ensure_response( $response );
@@ -495,77 +779,20 @@
495 779 return $response;
496 780 }
497 781
498 782 /**
499 - * Permission check for the connection/data endpoint
783 + * Verify that user is allowed to restore the connection.
500 784 *
501 - * @return bool|WP_Error
502 - */
503 - public static function user_connection_data_permission_check() {
504 - if ( current_user_can( 'jetpack_connect_user' ) ) {
505 - return true;
506 - }
507 -
508 - return new WP_Error(
509 - 'invalid_user_permission_user_connection_data',
510 - self::get_user_permissions_error_msg(),
511 - array( 'status' => rest_authorization_required_code() )
512 - );
513 - }
514 -
515 - /**
516 - * Verifies if the request was signed with the Jetpack Debugger key
785 + * Users with only 'jetpack_connect_user' get through, but connection_reconnect()
786 + * limits them to refreshing their own user token.
517 787 *
518 - * @param string|null $pub_key The public key used to verify the signature. Default is the Jetpack Debugger key. This is used for testing purposes.
519 - *
520 - * @return bool
521 - */
522 - public static function is_request_signed_by_jetpack_debugger( $pub_key = null ) {
523 - // phpcs:disable WordPress.Security.NonceVerification.Recommended
524 - if ( ! isset( $_GET['signature'] ) || ! isset( $_GET['timestamp'] ) || ! isset( $_GET['url'] ) || ! isset( $_GET['rest_route'] ) ) {
525 - return false;
526 - }
527 -
528 - // signature timestamp must be within 5min of current time.
529 - if ( abs( time() - (int) $_GET['timestamp'] ) > 300 ) {
530 - return false;
531 - }
532 -
533 - $signature = base64_decode( filter_var( wp_unslash( $_GET['signature'] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
534 -
535 - $signature_data = wp_json_encode(
536 - array(
537 - 'rest_route' => filter_var( wp_unslash( $_GET['rest_route'] ) ),
538 - 'timestamp' => (int) $_GET['timestamp'],
539 - 'url' => filter_var( wp_unslash( $_GET['url'] ) ),
540 - )
541 - );
542 -
543 - if (
544 - ! function_exists( 'openssl_verify' )
545 - || 1 !== openssl_verify(
546 - $signature_data,
547 - $signature,
548 - $pub_key ? $pub_key : static::JETPACK__DEBUGGER_PUBLIC_KEY
549 - )
550 - ) {
551 - return false;
552 - }
553 -
554 - // phpcs:enable WordPress.Security.NonceVerification.Recommended
555 -
556 - return true;
557 - }
558 -
559 - /**
560 - * Verify that user is allowed to disconnect Jetpack.
561 - *
562 788 * @since 1.15.0
789 + * @since 9.8.0 Also allows 'jetpack_connect_user'.
563 790 *
564 - * @return bool|WP_Error Whether user has the capability 'jetpack_disconnect'.
791 + * @return bool|WP_Error Whether user has the capability 'jetpack_reconnect' or 'jetpack_connect_user'.
565 792 */
566 793 public static function jetpack_reconnect_permission_check() {
567 - if ( current_user_can( 'jetpack_reconnect' ) ) {
794 + if ( current_user_can( 'jetpack_reconnect' ) || current_user_can( 'jetpack_connect_user' ) ) {
568 795 return true;
569 796 }
570 797
571 798 return new WP_Error( 'invalid_user_permission_jetpack_disconnect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
@@ -583,8 +810,9 @@
583 810 /**
584 811 * The endpoint tried to partially or fully reconnect the website to WP.com.
585 812 *
586 813 * @since 1.15.0
814 + * @since 9.8.0 Users without 'jetpack_reconnect' only refresh their own user token.
587 815 *
588 816 * @return \WP_REST_Response|WP_Error
589 817 */
590 818 public function connection_reconnect() {
@@ -591,9 +819,11 @@
591 819 $response = array();
592 820
593 821 $next = null;
594 822
595 - $result = $this->connection->restore();
823 + $result = current_user_can( 'jetpack_reconnect' )
824 + ? $this->connection->restore()
825 + : $this->connection->refresh_user_token( false );
596 826
597 827 if ( is_wp_error( $result ) ) {
598 828 $response = $result;
599 829 } elseif ( is_string( $result ) ) {
@@ -639,11 +869,12 @@
639 869 return new WP_Error( 'invalid_user_permission_jetpack_connect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
640 870 }
641 871
642 872 /**
643 - * The endpoint tried to partially or fully reconnect the website to WP.com.
873 + * The endpoint tried to connect Jetpack site to WPCOM.
644 874 *
645 875 * @since 1.7.0
876 + * @since 6.7.0 No longer needs `registration_nonce`.
646 877 * @since-jetpack 7.7.0
647 878 *
648 879 * @param \WP_REST_Request $request The request sent to the WP REST API.
649 880 *
@@ -649,16 +880,13 @@
649 880 *
650 881 * @return \WP_REST_Response|WP_Error
651 882 */
652 883 public function connection_register( $request ) {
653 - if ( ! wp_verify_nonce( $request->get_param( 'registration_nonce' ), 'jetpack-registration-nonce' ) ) {
654 - return new WP_Error( 'invalid_nonce', __( 'Unable to verify your request.', 'jetpack-connection' ), array( 'status' => 403 ) );
884 + $from = isset( $request['from'] ) ? (string) $request['from'] : '';
885 + if ( '' !== $from ) {
886 + $this->connection->add_register_request_param( 'from', $from );
655 887 }
656 888
657 - if ( isset( $request['from'] ) ) {
658 - $this->connection->add_register_request_param( 'from', (string) $request['from'] );
659 - }
660 -
661 889 if ( ! empty( $request['plugin_slug'] ) ) {
662 890 // If `plugin_slug` matches a plugin using the connection, let's inform the plugin that is establishing the connection.
663 891 $connected_plugin = Plugin_Storage::get_one( (string) $request['plugin_slug'] );
664 892 if ( ! is_wp_error( $connected_plugin ) && ! empty( $connected_plugin ) ) {
@@ -673,13 +901,9 @@
673 901 }
674 902
675 903 $redirect_uri = $request->get_param( 'redirect_uri' ) ? admin_url( $request->get_param( 'redirect_uri' ) ) : null;
676 904
677 - if ( class_exists( 'Jetpack' ) ) {
678 - $authorize_url = \Jetpack::build_authorize_url( $redirect_uri );
679 - } else {
680 - $authorize_url = $this->connection->get_authorization_url( null, $redirect_uri );
681 - }
905 + $authorize_url = ( new Authorize_Redirect( $this->connection ) )->build_authorize_url( $redirect_uri, '' !== $from ? $from : false );
682 906
683 907 /**
684 908 * Filters the response of jetpack/v4/connection/register endpoint
685 909 *
@@ -690,9 +914,9 @@
690 914 'jetpack_register_site_rest_response',
691 915 array()
692 916 );
693 917
694 - // We manipulate the alternate URLs after the filter is applied, so they can not be overwritten.
918 + // We manipulate the alternate URLs after the filter is applied, so they cannot be overwritten.
695 919 $response_body['authorizeUrl'] = $authorize_url;
696 920 if ( ! empty( $response_body['alternateAuthorizeUrl'] ) ) {
697 921 $response_body['alternateAuthorizeUrl'] = Redirect::get_url( $response_body['alternateAuthorizeUrl'] );
698 922 }
@@ -710,9 +934,10 @@
710 934 * @return \WP_REST_Response|WP_Error
711 935 */
712 936 public function connection_authorize_url( $request ) {
713 937 $redirect_uri = $request->get_param( 'redirect_uri' ) ? admin_url( $request->get_param( 'redirect_uri' ) ) : null;
714 - $authorize_url = $this->connection->get_authorization_url( null, $redirect_uri );
938 + $from = $request->get_param( 'from' );
939 + $authorize_url = $this->connection->get_authorization_url( null, $redirect_uri, ! empty( $from ) ? (string) $from : false );
715 940
716 941 return rest_ensure_response(
717 942 array(
718 943 'authorizeUrl' => $authorize_url,
@@ -751,20 +976,11 @@
751 976 $is_connection_owner = isset( $request['is_connection_owner'] )
752 977 ? (bool) $request['is_connection_owner']
753 978 : ( new Manager() )->get_connection_owner_id() === $user_id;
754 979
980 + // Tokens::update_user_token() fires jetpack_updated_user_token itself.
755 981 ( new Tokens() )->update_user_token( $user_id, $request['user_token'], $is_connection_owner );
756 982
757 - /**
758 - * Fires when the user token gets successfully replaced.
759 - *
760 - * @since 1.29.0
761 - *
762 - * @param int $user_id User ID.
763 - * @param string $token New user token.
764 - */
765 - do_action( 'jetpack_updated_user_token', $user_id, $request['user_token'] );
766 -
767 983 return rest_ensure_response(
768 984 array(
769 985 'success' => true,
770 986 )
@@ -793,13 +1009,58 @@
793 1009 );
794 1010 }
795 1011
796 1012 /**
1013 + * Unlinks current user from the WordPress.com Servers.
1014 + *
1015 + * @since 6.3.3
1016 + *
1017 + * @param WP_REST_Request $request The request sent to the WP REST API.
1018 + *
1019 + * @return bool|WP_Error True if user successfully unlinked.
1020 + */
1021 + public static function unlink_user( $request ) {
1022 +
1023 + if ( ! isset( $request['linked'] ) || false !== $request['linked'] ) {
1024 + return new WP_Error( 'invalid_param', esc_html__( 'Invalid Parameter', 'jetpack-connection' ), array( 'status' => 404 ) );
1025 + }
1026 +
1027 + // If the user is also connection owner, we need to disconnect all users. Since disconnecting all users is a destructive action, we need to pass a parameter to confirm the action.
1028 + $disconnect_all_users = false;
1029 +
1030 + if ( ( new Manager() )->get_connection_owner_id() === get_current_user_id() ) {
1031 + if ( isset( $request['disconnect-all-users'] ) && false !== $request['disconnect-all-users'] ) {
1032 + $disconnect_all_users = true;
1033 + } else {
1034 + return new WP_Error( 'unlink_user_failed', esc_html__( 'Unable to unlink the connection owner.', 'jetpack-connection' ), array( 'status' => 400 ) );
1035 + }
1036 + }
1037 +
1038 + // Allow admins to force a disconnect by passing the "force" parameter
1039 + // This allows an admin to disconnect themselves
1040 + if ( isset( $request['force'] ) && false !== $request['force'] && current_user_can( 'manage_options' ) && ( new Manager( 'jetpack' ) )->disconnect_user_force( get_current_user_id(), $disconnect_all_users ) ) {
1041 + return rest_ensure_response(
1042 + array(
1043 + 'code' => 'success',
1044 + )
1045 + );
1046 + } elseif ( ( new Manager( 'jetpack' ) )->disconnect_user() ) {
1047 + return rest_ensure_response(
1048 + array(
1049 + 'code' => 'success',
1050 + )
1051 + );
1052 + }
1053 +
1054 + return new WP_Error( 'unlink_user_failed', esc_html__( 'Was not able to unlink the user. Please try again.', 'jetpack-connection' ), array( 'status' => 400 ) );
1055 + }
1056 +
1057 + /**
797 1058 * Verify that the API client is allowed to replace user token.
798 1059 *
799 1060 * @since 1.29.0
800 1061 *
801 - * @return bool|WP_Error.
1062 + * @return bool|WP_Error
802 1063 */
803 1064 public static function update_user_token_permission_check() {
804 1065 return Rest_Authentication::is_signed_with_blog_token()
805 1066 ? true
@@ -845,6 +1106,458 @@
845 1106 return true;
846 1107 }
847 1108
848 1109 return new WP_Error( 'invalid_user_permission_set_connection_owner', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
1110 + }
1111 +
1112 + /**
1113 + * Confirm the current user as the protected owner.
1114 + *
1115 + * The claim is always for the signed-in user. A caller cannot name someone else.
1116 + *
1117 + * @since 9.9.0
1118 + *
1119 + * @return WP_REST_Response|WP_Error
1120 + */
1121 + public static function protect_connection_owner() {
1122 + $result = ( new Manager() )->set_protected_owner( get_current_user_id() );
1123 +
1124 + if ( is_wp_error( $result ) ) {
1125 + return $result;
1126 + }
1127 +
1128 + return rest_ensure_response(
1129 + array(
1130 + 'code' => 'success',
1131 + )
1132 + );
1133 + }
1134 +
1135 + /**
1136 + * Whether the current user may confirm a protected owner.
1137 + *
1138 + * A connected administrator qualifies, and only while a consumer is requesting a protected
1139 + * owner. Holding the connection owner slot does not matter.
1140 + *
1141 + * `requires_protected_owner()` is documented as a momentary answer, but it is the only opt-in
1142 + * signal there is, so a consumer that surfaces a confirmation must keep answering true for as
1143 + * long as it is on screen. One that flips to false between render and submit turns its own
1144 + * link into a 403.
1145 + *
1146 + * @since 9.9.0
1147 + *
1148 + * @return true|WP_Error
1149 + */
1150 + public static function protect_connection_owner_permission_check() {
1151 + $user_id = get_current_user_id();
1152 + $admin_cap = ( new Roles() )->translate_role_to_cap( 'administrator' );
1153 + $manager = new Manager();
1154 +
1155 + if (
1156 + $user_id
1157 + && current_user_can( 'jetpack_connect' )
1158 + && $admin_cap
1159 + && current_user_can( $admin_cap )
1160 + && $manager->is_user_connected( $user_id )
1161 + && $manager->requires_protected_owner()
1162 + ) {
1163 + return true;
1164 + }
1165 +
1166 + return new WP_Error(
1167 + 'invalid_user_permission_protect_owner',
1168 + self::get_user_permissions_error_msg(),
1169 + array( 'status' => rest_authorization_required_code() )
1170 + );
1171 + }
1172 +
1173 + /**
1174 + * Release the protected owner for this site.
1175 + *
1176 + * @since 9.9.0
1177 + *
1178 + * @return WP_REST_Response|WP_Error
1179 + */
1180 + public static function release_connection_owner() {
1181 + $result = ( new Manager() )->release_protected_owner();
1182 +
1183 + if ( is_wp_error( $result ) ) {
1184 + return $result;
1185 + }
1186 +
1187 + return rest_ensure_response(
1188 + array(
1189 + 'code' => 'success',
1190 + )
1191 + );
1192 + }
1193 +
1194 + /**
1195 + * Whether the current user may release the protected owner.
1196 + *
1197 + * Only the confirmed owner qualifies. WordPress.com is asked again before anything is cleared,
1198 + * and its answer is the one that decides.
1199 + *
1200 + * Deliberately not gated on `requires_protected_owner()`, unlike confirming: a consumer that
1201 + * has stopped asking must not strand a site holding a lock it can no longer release.
1202 + *
1203 + * @since 9.9.0
1204 + *
1205 + * @return true|WP_Error
1206 + */
1207 + public static function release_connection_owner_permission_check() {
1208 + $user_id = get_current_user_id();
1209 + $admin_cap = ( new Roles() )->translate_role_to_cap( 'administrator' );
1210 + $manager = new Manager();
1211 +
1212 + if (
1213 + $user_id
1214 + && current_user_can( 'jetpack_connect' )
1215 + && $admin_cap
1216 + && current_user_can( $admin_cap )
1217 + && $manager->is_user_connected( $user_id )
1218 + ) {
1219 + // `RE_EVALUATE` settles that the connection owner matches the anchor, so pinning this
1220 + // user to that owner is what makes it their identity. A matching binding would not:
1221 + // Premium Content writes the same key directly, so the IDs are not unique site-wide.
1222 + $state = $manager->resolve_protected_owner_state();
1223 +
1224 + if ( Manager::PO_STATE_RE_EVALUATE === $state['status'] && $user_id === (int) $manager->get_connection_owner_id() ) {
1225 + return true;
1226 + }
1227 + }
1228 +
1229 + return new WP_Error(
1230 + 'invalid_user_permission_release_owner',
1231 + self::get_user_permissions_error_msg(),
1232 + array( 'status' => rest_authorization_required_code() )
1233 + );
1234 + }
1235 +
1236 + /**
1237 + * The endpoint verifies blog connection and blog token validity.
1238 + *
1239 + * @since 2.7.0
1240 + *
1241 + * @return mixed|null
1242 + */
1243 + public function connection_check() {
1244 + /**
1245 + * Filters the successful response of the REST API test_connection method
1246 + *
1247 + * @param string $response The response string.
1248 + */
1249 + $status = apply_filters( 'jetpack_rest_connection_check_response', 'success' );
1250 +
1251 + return rest_ensure_response(
1252 + array(
1253 + 'status' => $status,
1254 + )
1255 + );
1256 + }
1257 +
1258 + /**
1259 + * Remote connect endpoint permission check.
1260 + *
1261 + * @return true|WP_Error
1262 + */
1263 + public function connection_check_permission_check() {
1264 + if ( current_user_can( 'jetpack_connect' ) ) {
1265 + return true;
1266 + }
1267 +
1268 + return Rest_Authentication::is_signed_with_blog_token()
1269 + ? true
1270 + : new WP_Error( 'invalid_permission_connection_check', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
1271 + }
1272 +
1273 + /**
1274 + * Permission check for the connection/test endpoint.
1275 + *
1276 + * @since 8.5.0
1277 + *
1278 + * @return true|WP_Error
1279 + */
1280 + public static function connection_test_permission_check() {
1281 + if ( current_user_can( 'manage_options' ) ) {
1282 + return true;
1283 + }
1284 +
1285 + return new WP_Error(
1286 + 'invalid_user_permission_manage_options',
1287 + self::get_user_permissions_error_msg(),
1288 + array( 'status' => rest_authorization_required_code() )
1289 + );
1290 + }
1291 +
1292 + /**
1293 + * Whether the current user may read the site record.
1294 + *
1295 + * The floor is `edit_posts` because the Jetpack dashboard requests this route on mount and
1296 + * is reachable by contributors, matching how My Jetpack and admin-ui gate their pages.
1297 + *
1298 + * An offline site keeps its blog ID and blog token, so the fetch stays signed and reaches
1299 + * WordPress.com. The floor there is `manage_options`, matching the capability the route
1300 + * carried before it moved into this package.
1301 + *
1302 + * @since 8.10.0
1303 + *
1304 + * @return true|WP_Error
1305 + */
1306 + public static function site_data_permission_check() {
1307 + if ( ( new Status() )->is_offline_mode() ) {
1308 + if ( current_user_can( 'manage_options' ) ) {
1309 + return true;
1310 + }
1311 + } elseif ( current_user_can( 'edit_posts' ) ) {
1312 + return true;
1313 + }
1314 +
1315 + return new WP_Error(
1316 + 'invalid_user_permission_view_admin',
1317 + self::get_user_permissions_error_msg(),
1318 + array( 'status' => rest_authorization_required_code() )
1319 + );
1320 + }
1321 +
1322 + /**
1323 + * Return the site's WordPress.com record, or an error envelope describing the failure.
1324 + *
1325 + * @since 8.10.0
1326 + *
1327 + * @return WP_Error|\WP_HTTP_Response|WP_REST_Response
1328 + */
1329 + public function get_site_data() {
1330 + return self::site_data_response( $this->connection );
1331 + }
1332 +
1333 + /**
1334 + * Build the site data response.
1335 + *
1336 + * Separate from the route callback so callers that only want the response, such as the
1337 + * Jetpack plugin's deprecated wrapper, do not have to construct a `REST_Connector` and
1338 + * re-register the routes.
1339 + *
1340 + * @since 8.10.0
1341 + *
1342 + * @param Manager|null $connection The connection manager to fetch with. Defaults to a new one.
1343 + * @return WP_Error|\WP_HTTP_Response|WP_REST_Response
1344 + */
1345 + public static function site_data_response( ?Manager $connection = null ) {
1346 + $site_data = ( $connection ?? new Manager() )->get_connected_site_data();
1347 +
1348 + if ( ! is_wp_error( $site_data ) ) {
1349 + $site_data = self::exclude_site_options( $site_data );
1350 +
1351 + /**
1352 + * Fires when the site data was successfully returned from the /sites/%d wpcom endpoint.
1353 + *
1354 + * @since 8.10.0
1355 + * @since-jetpack 8.7.0
1356 + */
1357 + do_action( 'jetpack_get_site_data_success' );
1358 +
1359 + return rest_ensure_response(
1360 + array(
1361 + 'code' => 'success',
1362 + 'message' => esc_html__( 'Site data correctly received.', 'jetpack-connection' ),
1363 + 'data' => wp_json_encode( $site_data, JSON_UNESCAPED_SLASHES ),
1364 + )
1365 + );
1366 + }
1367 +
1368 + $error_data = $site_data->get_error_data();
1369 +
1370 + if ( empty( $error_data['api_error_code'] ) ) {
1371 + $error_message = esc_html__( 'Failed fetching site data from WordPress.com. If the problem persists, try reconnecting Jetpack.', 'jetpack-connection' );
1372 + } else {
1373 + /* translators: %s is an error code (e.g. `token_mismatch`) */
1374 + $error_message = sprintf( esc_html__( 'Failed fetching site data from WordPress.com (%s). If the problem persists, try reconnecting Jetpack.', 'jetpack-connection' ), $error_data['api_error_code'] );
1375 + }
1376 +
1377 + return new WP_Error(
1378 + $site_data->get_error_code(),
1379 + $error_message,
1380 + array(
1381 + 'status' => 400,
1382 + 'api_error_code' => empty( $error_data['api_error_code'] ) ? null : $error_data['api_error_code'],
1383 + 'api_http_code' => empty( $error_data['api_http_code'] ) ? null : $error_data['api_http_code'],
1384 + )
1385 + );
1386 + }
1387 +
1388 + /**
1389 + * Removes EXCLUDED_SITE_OPTIONS from the site record before it is served to a REST caller.
1390 + *
1391 + * Works on a copy: a listener on 'jetpack_site_data_fetched', or any other internal
1392 + * consumer holding the record, keeps seeing it whole.
1393 + *
1394 + * @since 9.9.0.1
1395 + *
1396 + * @param object $site_data The decoded site record.
1397 + * @return object The record to serve, with EXCLUDED_SITE_OPTIONS removed.
1398 + */
1399 + private static function exclude_site_options( $site_data ) {
1400 + if ( ! is_object( $site_data ) || ! isset( $site_data->options ) || ! is_object( $site_data->options ) ) {
1401 + return $site_data;
1402 + }
1403 +
1404 + $site_data = clone $site_data;
1405 + $site_data->options = clone $site_data->options;
1406 +
1407 + foreach ( self::EXCLUDED_SITE_OPTIONS as $option ) {
1408 + unset( $site_data->options->$option );
1409 + }
1410 +
1411 + return $site_data;
1412 + }
1413 +
1414 + /**
1415 + * Run all connection health tests and return the result.
1416 + *
1417 + * @since 8.5.0
1418 + *
1419 + * @return WP_REST_Response|WP_Error
1420 + */
1421 + public function connection_test() {
1422 + $cxntests = new Connection_Health_Tests();
1423 + $tests_run = array_keys( $cxntests->list_tests() );
1424 +
1425 + if ( $cxntests->pass() ) {
1426 + return rest_ensure_response(
1427 + array(
1428 + 'code' => 'success',
1429 + 'message' => __( 'All connection tests passed.', 'jetpack-connection' ),
1430 + 'tests_run' => $tests_run,
1431 + )
1432 + );
1433 + }
1434 +
1435 + return $cxntests->output_fails_as_wp_error();
1436 + }
1437 +
1438 + /**
1439 + * Run connection health tests for a privileged external caller (WP.com debugger).
1440 + *
1441 + * Results are encrypted so only WP.com can read them.
1442 + *
1443 + * @since 8.5.0
1444 + *
1445 + * @return WP_REST_Response
1446 + */
1447 + public function connection_test_for_external() {
1448 + // Since we are running this test for inclusion in the WP.com testing suite,
1449 + // let's not try to run them as part of these results.
1450 + add_filter( 'jetpack_debugger_run_self_test', '__return_false' );
1451 + $cxntests = new Connection_Health_Tests();
1452 +
1453 + if ( $cxntests->pass() ) {
1454 + $result = array(
1455 + 'code' => 'success',
1456 + 'message' => __( 'All connection tests passed.', 'jetpack-connection' ),
1457 + );
1458 + } else {
1459 + $error = $cxntests->output_fails_as_wp_error();
1460 + $errors = array();
1461 +
1462 + // Borrowed from WP_REST_Server::error_to_response().
1463 + foreach ( (array) $error->errors as $code => $messages ) {
1464 + foreach ( (array) $messages as $message ) {
1465 + $errors[] = array(
1466 + 'code' => $code,
1467 + 'message' => $message,
1468 + 'data' => $error->get_error_data( $code ),
1469 + );
1470 + }
1471 + }
1472 +
1473 + $result = ( ! empty( $errors ) ) ? $errors[0] : null;
1474 + if ( count( $errors ) > 1 ) {
1475 + // Remove the primary error.
1476 + array_shift( $errors );
1477 + $result['additional_errors'] = $errors;
1478 + }
1479 + }
1480 +
1481 + $result = wp_json_encode( $result, JSON_UNESCAPED_SLASHES );
1482 +
1483 + $encrypted = $cxntests->encrypt_string_for_wpcom( $result );
1484 +
1485 + if ( ! $encrypted || ! is_array( $encrypted ) ) {
1486 + return rest_ensure_response(
1487 + array(
1488 + 'code' => 'action_required',
1489 + 'message' => 'Please request results from the in-plugin debugger',
1490 + )
1491 + );
1492 + }
1493 +
1494 + return rest_ensure_response(
1495 + array(
1496 + 'code' => 'response',
1497 + 'debug' => $encrypted,
1498 + )
1499 + );
1500 + }
1501 +
1502 + /**
1503 + * Permission check for the connection/data endpoint
1504 + *
1505 + * @return bool|WP_Error
1506 + */
1507 + public static function user_connection_data_permission_check() {
1508 + if ( current_user_can( 'jetpack_connect_user' ) ) {
1509 + return true;
1510 + }
1511 +
1512 + return new WP_Error(
1513 + 'invalid_user_permission_user_connection_data',
1514 + self::get_user_permissions_error_msg(),
1515 + array( 'status' => rest_authorization_required_code() )
1516 + );
1517 + }
1518 +
1519 + /**
1520 + * Verifies if the request was signed with the Jetpack Debugger key
1521 + *
1522 + * @param string|null $pub_key The public key used to verify the signature. Default is the Jetpack Debugger key. This is used for testing purposes.
1523 + *
1524 + * @return bool
1525 + */
1526 + public static function is_request_signed_by_jetpack_debugger( $pub_key = null ) {
1527 + // phpcs:disable WordPress.Security.NonceVerification.Recommended
1528 + if ( ! isset( $_GET['signature'] ) || ! isset( $_GET['timestamp'] ) || ! isset( $_GET['url'] ) || ! isset( $_GET['rest_route'] ) ) {
1529 + return false;
1530 + }
1531 +
1532 + // signature timestamp must be within 5min of current time.
1533 + if ( abs( time() - (int) $_GET['timestamp'] ) > 300 ) {
1534 + return false;
1535 + }
1536 +
1537 + $signature = base64_decode( filter_var( wp_unslash( $_GET['signature'] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1538 +
1539 + $signature_data = wp_json_encode(
1540 + array(
1541 + 'rest_route' => filter_var( wp_unslash( $_GET['rest_route'] ) ),
1542 + 'timestamp' => (int) $_GET['timestamp'],
1543 + 'url' => filter_var( wp_unslash( $_GET['url'] ) ),
1544 + ),
1545 + 0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
1546 + );
1547 +
1548 + if (
1549 + ! function_exists( 'openssl_verify' )
1550 + || 1 !== openssl_verify(
1551 + $signature_data,
1552 + $signature,
1553 + is_string( $pub_key ) ? $pub_key : static::JETPACK__DEBUGGER_PUBLIC_KEY
1554 + )
1555 + ) {
1556 + return false;
1557 + }
1558 +
1559 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
1560 +
1561 + return true;
849 1562 }
850 1563 }