summaryrefslogtreecommitdiff
blob: 0ddaf7c4af5c024f0f4c80c48fd5b6bad66c434c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
<?php
/**
 * WordPress.com Block Editor
 * Allow new block editor posts to be composed on WordPress.com.
 * This is auto-loaded as of Jetpack v7.4 for sites connected to WordPress.com only.
 *
 * @package Jetpack
 */

/**
 * WordPress.com Block editor for Jetpack
 */
class Jetpack_WPCOM_Block_Editor {
	/**
	 * ID of the user who signed the nonce.
	 *
	 * @var int
	 */
	private $nonce_user_id;

	/**
	 * Singleton
	 */
	public static function init() {
		static $instance = false;

		if ( ! $instance ) {
			$instance = new Jetpack_WPCOM_Block_Editor();
		}

		return $instance;
	}

	/**
	 * Jetpack_WPCOM_Block_Editor constructor.
	 */
	private function __construct() {
		if ( $this->is_iframed_block_editor() ) {
			add_action( 'admin_init', array( $this, 'disable_send_frame_options_header' ), 9 );
			add_filter( 'admin_body_class', array( $this, 'add_iframed_body_class' ) );
		}

		add_action( 'login_init', array( $this, 'allow_block_editor_login' ), 1 );
		add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ), 9 );
		add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) );
		add_filter( 'mce_external_plugins', array( $this, 'add_tinymce_plugins' ) );

		$this->enable_cross_site_auth_cookies();
	}

	/**
	 * Checks if we are embedding the block editor in an iframe in WordPress.com.
	 *
	 * @return bool Whether the current request is from the iframed block editor.
	 */
	public function is_iframed_block_editor() {
		global $pagenow;

		// phpcs:ignore WordPress.Security.NonceVerification
		return ( 'post.php' === $pagenow || 'post-new.php' === $pagenow ) && ! empty( $_GET['frame-nonce'] );
	}

	/**
	 * Prevents frame options header from firing if this is a whitelisted iframe request.
	 */
	public function disable_send_frame_options_header() {
		// phpcs:ignore WordPress.Security.NonceVerification
		if ( $this->framing_allowed( $_GET['frame-nonce'] ) ) {
			remove_action( 'admin_init', 'send_frame_options_header' );
		}
	}

	/**
	 * Adds custom admin body class if this is a whitelisted iframe request.
	 *
	 * @param string $classes Admin body classes.
	 * @return string
	 */
	public function add_iframed_body_class( $classes ) {
		// phpcs:ignore WordPress.Security.NonceVerification
		if ( $this->framing_allowed( $_GET['frame-nonce'] ) ) {
			$classes .= ' is-iframed ';
		}

		return $classes;
	}

	/**
	 * Allows to iframe the login page if a user is logged out
	 * while trying to access the block editor from wordpress.com.
	 */
	public function allow_block_editor_login() {
		// phpcs:ignore WordPress.Security.NonceVerification
		if ( empty( $_REQUEST['redirect_to'] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification
		$query = wp_parse_url( urldecode( $_REQUEST['redirect_to'] ), PHP_URL_QUERY );
		$args  = wp_parse_args( $query );

		// Check nonce and make sure this is a Gutenframe request.
		if ( ! empty( $args['frame-nonce'] ) && $this->framing_allowed( $args['frame-nonce'] ) ) {

			// If SSO is active, we'll let WordPress.com handle authentication...
			if ( Jetpack::is_module_active( 'sso' ) ) {
				// ...but only if it's not an Atomic site. They already do that.
				if ( ! jetpack_is_atomic_site() ) {
					add_filter( 'jetpack_sso_bypass_login_forward_wpcom', '__return_true' );
				}
			} else {
				$_REQUEST['interim-login'] = true;
				add_action( 'wp_login', array( $this, 'do_redirect' ) );
				add_action( 'login_form', array( $this, 'add_login_html' ) );
				add_filter( 'wp_login_errors', array( $this, 'add_login_message' ) );
				remove_action( 'login_init', 'send_frame_options_header' );
				wp_add_inline_style( 'login', '.interim-login #login{padding-top:8%}' );
			}
		}
	}

	/**
	 * Adds a login message.
	 *
	 * Intended to soften the expectation mismatch of ending up with a login screen rather than the editor.
	 *
	 * @param WP_Error $errors WP Error object.
	 * @return \WP_Error
	 */
	public function add_login_message( $errors ) {
		$errors->remove( 'expired' );
		$errors->add( 'info', __( 'Before we continue, please log in to your Jetpack site.', 'jetpack' ), 'message' );

		return $errors;
	}

	/**
	 * Maintains the `redirect_to` parameter in login form links.
	 * Adds visual feedback of login in progress.
	 */
	public function add_login_html() {
		?>
		<input type="hidden" name="redirect_to" value="<?php echo esc_url( $_REQUEST['redirect_to'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>" />
		<script type="application/javascript">
			document.getElementById( 'loginform' ).addEventListener( 'submit' , function() {
				document.getElementById( 'wp-submit' ).setAttribute( 'disabled', 'disabled' );
				document.getElementById( 'wp-submit' ).value = '<?php echo esc_js( __( 'Logging In...', 'jetpack' ) ); ?>';
			} );
		</script>
		<?php
	}

	/**
	 * Does the redirect to the block editor.
	 */
	public function do_redirect() {
		wp_safe_redirect( $GLOBALS['redirect_to'] );
		exit;
	}

	/**
	 * Checks whether this is a whitelisted iframe request.
	 *
	 * @param string $nonce Nonce to verify.
	 * @return bool
	 */
	public function framing_allowed( $nonce ) {
		$verified = $this->verify_frame_nonce( $nonce, 'frame-' . Jetpack_Options::get_option( 'id' ) );

		if ( is_wp_error( $verified ) ) {
			wp_die( $verified ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}

		if ( $verified && ! defined( 'IFRAME_REQUEST' ) ) {
			define( 'IFRAME_REQUEST', true );
		}

		return (bool) $verified;
	}

	/**
	 * Verify that correct nonce was used with time limit.
	 *
	 * The user is given an amount of time to use the token, so therefore, since the
	 * UID and $action remain the same, the independent variable is the time.
	 *
	 * @param string $nonce Nonce that was used in the form to verify.
	 * @param string $action Should give context to what is taking place and be the same when nonce was created.
	 * @return boolean|WP_Error Whether the nonce is valid.
	 */
	public function verify_frame_nonce( $nonce, $action ) {
		if ( empty( $nonce ) ) {
			return false;
		}

		list( $expiration, $user_id, $hash ) = explode( ':', $nonce, 3 );

		$this->nonce_user_id = (int) $user_id;
		if ( ! $this->nonce_user_id ) {
			return false;
		}

		$token = Jetpack_Data::get_access_token( $this->nonce_user_id );
		if ( ! $token ) {
			return false;
		}

		/*
		 * Failures must return `false` (blocking the iframe) prior to the
		 * signature verification.
		 */

		add_filter( 'salt', array( $this, 'filter_salt' ), 10, 2 );
		$expected_hash = wp_hash( "$expiration|$action|{$this->nonce_user_id}", 'jetpack_frame_nonce' );
		remove_filter( 'salt', array( $this, 'filter_salt' ) );

		if ( ! hash_equals( $hash, $expected_hash ) ) {
			return false;
		}

		/*
		 * Failures may return `WP_Error` (showing an error in the iframe) after the
		 * signature verification passes.
		 */

		if ( time() > $expiration ) {
			return new WP_Error( 'nonce_invalid_expired', 'Expired nonce.', array( 'status' => 401 ) );
		}

		// Check if it matches the current user, unless they're trying to log in.
		if ( get_current_user_id() !== $this->nonce_user_id && ! doing_action( 'login_init' ) ) {
			return new WP_Error( 'nonce_invalid_user_mismatch', 'User ID mismatch.', array( 'status' => 401 ) );
		}

		return true;
	}

	/**
	 * Filters the WordPress salt.
	 *
	 * @param string $salt Salt for the given scheme.
	 * @param string $scheme Authentication scheme.
	 * @return string
	 */
	public function filter_salt( $salt, $scheme ) {
		if ( 'jetpack_frame_nonce' === $scheme ) {
			$token = Jetpack_Data::get_access_token( $this->nonce_user_id );

			if ( $token ) {
				$salt = $token->secret;
			}
		}

		return $salt;
	}

	/**
	 * Enqueues the WordPress.com block editor integration assets for the editor.
	 */
	public function enqueue_block_editor_assets() {
		$debug   = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
		$version = gmdate( 'Ymd' );

		wp_enqueue_script(
			'wpcom-block-editor-default-editor-script',
			$debug
				? '//widgets.wp.com/wpcom-block-editor/default.editor.js?minify=false'
				: '//widgets.wp.com/wpcom-block-editor/default.editor.min.js',
			array(
				'jquery',
				'lodash',
				'wp-compose',
				'wp-data',
				'wp-editor',
				'wp-element',
				'wp-rich-text',
			),
			$version,
			true
		);

		wp_localize_script(
			'wpcom-block-editor-default-editor-script',
			'wpcomGutenberg',
			array(
				'switchToClassic' => array(
					'isVisible' => $this->is_iframed_block_editor(),
					'label'     => __( 'Switch to Classic Editor', 'jetpack' ),
					'url'       => Jetpack_Calypsoify::getInstance()->get_switch_to_classic_editor_url(),
				),
				'richTextToolbar' => array(
					'justify'   => __( 'Justify', 'jetpack' ),
					'underline' => __( 'Underline', 'jetpack' ),
				),
			)
		);

		if ( jetpack_is_atomic_site() ) {
			wp_enqueue_script(
				'wpcom-block-editor-wpcom-editor-script',
				$debug
					? '//widgets.wp.com/wpcom-block-editor/wpcom.editor.js?minify=false'
					: '//widgets.wp.com/wpcom-block-editor/wpcom.editor.min.js',
				array(
					'lodash',
					'wp-blocks',
					'wp-data',
					'wp-dom-ready',
					'wp-plugins',
				),
				$version,
				true
			);
		}

		if ( $this->is_iframed_block_editor() ) {
			wp_enqueue_script(
				'wpcom-block-editor-calypso-editor-script',
				$debug
					? '//widgets.wp.com/wpcom-block-editor/calypso.editor.js?minify=false'
					: '//widgets.wp.com/wpcom-block-editor/calypso.editor.min.js',
				array(
					'calypsoify_wpadminmods_js',
					'jquery',
					'lodash',
					'react',
					'wp-blocks',
					'wp-data',
					'wp-hooks',
					'wp-tinymce',
					'wp-url',
				),
				$version,
				true
			);

			wp_enqueue_style(
				'wpcom-block-editor-calypso-editor-styles',
				$debug
					? '//widgets.wp.com/wpcom-block-editor/calypso.editor.css?minify=false'
					: '//widgets.wp.com/wpcom-block-editor/calypso.editor.min.css',
				array(),
				$version
			);
		}
	}

	/**
	 * Enqueues the WordPress.com block editor integration assets for both editor and front-end.
	 */
	public function enqueue_block_assets() {
		// These styles are manually copied from //widgets.wp.com/wpcom-block-editor/default.view.css in order to
		// improve the performance by avoiding an extra network request to download the CSS file on every page.
		wp_add_inline_style( 'wp-block-library', '.has-text-align-justify{text-align:justify;}' );
	}

	/**
	 * Determines if the current $post contains a justified paragraph block.
	 *
	 * @return boolean true if justified paragraph is found, false otherwise.
	 */
	public function has_justified_block() {
		global $post;
		if ( ! $post instanceof WP_Post ) {
			return false;
		};

		if ( ! has_blocks( $post ) ) {
			return false;
		}

		return false !== strpos( $post->post_content, '<!-- wp:paragraph {"align":"justify"' );
	}

	/**
	 * Register the Tiny MCE plugins for the WordPress.com block editor integration.
	 *
	 * @param array $plugin_array An array of external Tiny MCE plugins.
	 * @return array External TinyMCE plugins.
	 */
	public function add_tinymce_plugins( $plugin_array ) {
		if ( $this->is_iframed_block_editor() ) {
			$debug = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;

			$plugin_array['gutenberg-wpcom-iframe-media-modal'] = add_query_arg(
				'v',
				gmdate( 'YW' ),
				$debug
					? '//widgets.wp.com/wpcom-block-editor/calypso.tinymce.js?minify=false'
					: '//widgets.wp.com/wpcom-block-editor/calypso.tinymce.min.js'
			);
		}

		return $plugin_array;
	}

	/**
	 * Ensures the authentication cookies are designated for cross-site access.
	 */
	private function enable_cross_site_auth_cookies() {
		/**
		 * Allow plugins to disable the cross-site auth cookies.
		 *
		 * @since 8.1.1
		 *
		 * @param false bool Whether auth cookies should be disabled for cross-site access. False by default.
		 */
		if ( apply_filters( 'jetpack_disable_cross_site_auth_cookies', false ) ) {
			return;
		}

		add_action( 'set_auth_cookie', array( $this, 'set_samesite_auth_cookies' ), 10, 5 );
		add_action( 'set_logged_in_cookie', array( $this, 'set_samesite_logged_in_cookies' ), 10, 4 );
		add_action( 'clear_auth_cookie', array( $this, 'clear_auth_cookies' ) );
		add_filter( 'send_auth_cookies', array( $this, 'disable_core_auth_cookies' ) );
	}

	/**
	 * Gets the SameSite attribute to use in auth cookies.
	 *
	 * @param  bool $secure Whether the connection is secure.
	 * @return string SameSite attribute to use on auth cookies.
	 */
	public function get_samesite_attr_for_auth_cookies( $secure ) {
		$samesite = $secure ? 'None' : 'Lax';
		/**
		 * Filters the SameSite attribute to use in auth cookies.
		 *
		 * @param string $samesite SameSite attribute to use in auth cookies.
		 *
		 * @since 8.1.1
		 */
		$samesite = apply_filters( 'jetpack_auth_cookie_samesite', $samesite );

		return $samesite;
	}

	/**
	 * Generates cross-site auth cookies so they can be accessed by WordPress.com.
	 *
	 * @param string $auth_cookie Authentication cookie value.
	 * @param int    $expire      The time the login grace period expires as a UNIX timestamp.
	 *                            Default is 12 hours past the cookie's expiration time.
	 * @param int    $expiration  The time when the authentication cookie expires as a UNIX timestamp.
	 *                            Default is 14 days from now.
	 * @param int    $user_id     User ID.
	 * @param string $scheme      Authentication scheme. Values include 'auth' or 'secure_auth'.
	 */
	public function set_samesite_auth_cookies( $auth_cookie, $expire, $expiration, $user_id, $scheme ) {
		if ( wp_startswith( $scheme, 'secure_' ) ) {
			$secure           = true;
			$auth_cookie_name = SECURE_AUTH_COOKIE;
		} else {
			$secure           = false;
			$auth_cookie_name = AUTH_COOKIE;
		}
		$samesite = $this->get_samesite_attr_for_auth_cookies( $secure );

		jetpack_shim_setcookie(
			$auth_cookie_name,
			$auth_cookie,
			array(
				'expires'  => $expire,
				'path'     => PLUGINS_COOKIE_PATH,
				'domain'   => COOKIE_DOMAIN,
				'secure'   => $secure,
				'httponly' => true,
				'samesite' => $samesite,
			)
		);

		jetpack_shim_setcookie(
			$auth_cookie_name,
			$auth_cookie,
			array(
				'expires'  => $expire,
				'path'     => ADMIN_COOKIE_PATH,
				'domain'   => COOKIE_DOMAIN,
				'secure'   => $secure,
				'httponly' => true,
				'samesite' => $samesite,
			)
		);
	}

	/**
	 * Generates cross-site logged in cookies so they can be accessed by WordPress.com.
	 *
	 * @param string $logged_in_cookie The logged-in cookie value.
	 * @param int    $expire           The time the login grace period expires as a UNIX timestamp.
	 *                                 Default is 12 hours past the cookie's expiration time.
	 * @param int    $expiration       The time when the logged-in cookie expires as a UNIX timestamp.
	 *                                 Default is 14 days from now.
	 * @param int    $user_id          User ID.
	 */
	public function set_samesite_logged_in_cookies( $logged_in_cookie, $expire, $expiration, $user_id ) {
		$secure = is_ssl();

		// Front-end cookie is secure when the auth cookie is secure and the site's home URL is forced HTTPS.
		$secure_logged_in_cookie = $secure && 'https' === wp_parse_url( get_option( 'home' ), PHP_URL_SCHEME );

		/** This filter is documented in core/src/wp-includes/pluggable.php */
		$secure = apply_filters( 'secure_auth_cookie', $secure, $user_id );

		/** This filter is documented in core/src/wp-includes/pluggable.php */
		$secure_logged_in_cookie = apply_filters( 'secure_logged_in_cookie', $secure_logged_in_cookie, $user_id, $secure );

		$samesite = $this->get_samesite_attr_for_auth_cookies( $secure_logged_in_cookie );

		jetpack_shim_setcookie(
			LOGGED_IN_COOKIE,
			$logged_in_cookie,
			array(
				'expires'  => $expire,
				'path'     => COOKIEPATH,
				'domain'   => COOKIE_DOMAIN,
				'secure'   => $secure_logged_in_cookie,
				'httponly' => true,
				'samesite' => $samesite,
			)
		);

		if ( COOKIEPATH !== SITECOOKIEPATH ) {
			jetpack_shim_setcookie(
				LOGGED_IN_COOKIE,
				$logged_in_cookie,
				array(
					'expires'  => $expire,
					'path'     => SITECOOKIEPATH,
					'domain'   => COOKIE_DOMAIN,
					'secure'   => $secure_logged_in_cookie,
					'httponly' => true,
					'samesite' => $samesite,
				)
			);
		}
	}

	/**
	 * Prevents the default core auth cookies from being generated so they don't collide with our cross-site cookies.
	 *
	 * @return bool Whether the default core auth cookies should be generated.
	 */
	public function disable_core_auth_cookies() {
		return false;
	}

	/**
	 * Removes all of the cookies associated with authentication.
	 *
	 * This is copied from core's `wp_clear_auth_cookie` since disabling the core auth cookies prevents also the auth
	 * cookies from being cleared.
	 *
	 * @see wp_clear_auth_cookie
	 */
	public function clear_auth_cookies() {
		// Auth cookies.
		setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, ADMIN_COOKIE_PATH, COOKIE_DOMAIN );
		setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, ADMIN_COOKIE_PATH, COOKIE_DOMAIN );
		setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN );
		setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN );
		setcookie( LOGGED_IN_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
		setcookie( LOGGED_IN_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );

		// Settings cookies.
		setcookie( 'wp-settings-' . get_current_user_id(), ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH );
		setcookie( 'wp-settings-time-' . get_current_user_id(), ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH );

		// Old cookies.
		setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
		setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );
		setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
		setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );

		// Even older cookies.
		setcookie( USER_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
		setcookie( PASS_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
		setcookie( USER_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );
		setcookie( PASS_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );

		// Post password cookie.
		setcookie( 'wp-postpass_' . COOKIEHASH, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
	}
}

Jetpack_WPCOM_Block_Editor::init();