Completed
Push — improve/rename-anti-spam-in-si... ( e5a567...f08131 )
by
unknown
75:27 queued 67:29
created

packages/connection/src/class-error-handler.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * The Jetpack Connection error class file.
4
 *
5
 * @package automattic/jetpack-connection
6
 */
7
8
namespace Automattic\Jetpack\Connection;
9
10
/**
11
 * The Jetpack Connection Errors that handles errors
12
 *
13
 * This class handles the following workflow:
14
 *
15
 * 1. A XML-RCP request with an invalid signature triggers a error
16
 * 2. Applies a gate to only process each error code once an hour to avoid overflow
17
 * 3. It stores the error on the database, but we don't know yet if this is a valid error, because
18
 *    we can't confirm it came from WP.com.
19
 * 4. It encrypts the error details and send it to thw wp.com server
20
 * 5. wp.com checks it and, if valid, sends a new request back to this site using the verify_xml_rpc_error REST endpoint
21
 * 6. This endpoint add this error to the Verified errors in the database
22
 * 7. Triggers a workflow depending on the error (display user an error message, do some self healing, etc.)
23
 *
24
 * Errors are stored in the database as options in the following format:
25
 *
26
 * [
27
 *   $error_code => [
28
 *     $user_id => [
29
 *       $error_details
30
 *     ]
31
 *   ]
32
 * ]
33
 *
34
 * For each error code we store a maximum of 5 errors for 5 different user ids.
35
 *
36
 * An user ID can be
37
 * * 0 for blog tokens
38
 * * positive integer for user tokens
39
 * * 'invalid' for malformed tokens
40
 *
41
 * @since 8.7.0
42
 */
43
class Error_Handler {
44
45
	/**
46
	 * The name of the option that stores the errors
47
	 *
48
	 * @since 8.7.0
49
	 *
50
	 * @var string
51
	 */
52
	const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
53
54
	/**
55
	 * The name of the option that stores the errors
56
	 *
57
	 * @since 8.7.0
58
	 *
59
	 * @var string
60
	 */
61
	const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
62
63
	/**
64
	 * The prefix of the transient that controls the gate for each error code
65
	 *
66
	 * @since 8.7.0
67
	 *
68
	 * @var string
69
	 */
70
	const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
71
72
	/**
73
	 * Time in seconds a test should live in the database before being discarded
74
	 *
75
	 * @since 8.7.0
76
	 */
77
	const ERROR_LIFE_TIME = DAY_IN_SECONDS;
78
	/**
79
	 * List of known errors. Only error codes in this list will be handled
80
	 *
81
	 * @since 8.7.0
82
	 *
83
	 * @var array
84
	 */
85
	public $known_errors = array(
86
		'malformed_token',
87
		'malformed_user_id',
88
		'unknown_user',
89
		'no_user_tokens',
90
		'empty_master_user_option',
91
		'no_token_for_user',
92
		'token_malformed',
93
		'user_id_mismatch',
94
		'no_possible_tokens',
95
		'no_valid_token',
96
		'unknown_token',
97
		'could_not_sign',
98
		'invalid_scheme',
99
		'invalid_secret',
100
		'invalid_token',
101
		'token_mismatch',
102
		'invalid_body',
103
		'invalid_signature',
104
		'invalid_body_hash',
105
		'invalid_nonce',
106
		'signature_mismatch',
107
	);
108
109
	/**
110
	 * Holds the instance of this singleton class
111
	 *
112
	 * @since 8.7.0
113
	 *
114
	 * @var Error_Handler $instance
115
	 */
116
	public static $instance = null;
117
118
	/**
119
	 * Initialize instance, hookds and load verified errors handlers
120
	 *
121
	 * @since 8.7.0
122
	 */
123
	private function __construct() {
124
		defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
125
126
		add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
127
128
		$this->handle_verified_errors();
129
130
		// If the site gets reconnected, clear errors.
131
		add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
132
		add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_errors' ) );
133
	}
134
135
	/**
136
	 * Gets the list of verified errors and act upon them
137
	 *
138
	 * @since 8.7.0
139
	 *
140
	 * @return void
141
	 */
142
	public function handle_verified_errors() {
143
		$verified_errors = $this->get_verified_errors();
144
		foreach ( $verified_errors as $error_code => $user_errors ) {
145
146
			switch ( $error_code ) {
147
				case 'malformed_token':
148
				case 'token_malformed':
149
				case 'no_possible_tokens':
150
				case 'no_valid_token':
151
				case 'unknown_token':
152
				case 'could_not_sign':
153
				case 'invalid_token':
154
				case 'token_mismatch':
155
				case 'invalid_signature':
156
				case 'signature_mismatch':
157
					new Error_Handlers\Invalid_Blog_Token( $user_errors );
158
					break;
159
			}
160
		}
161
	}
162
163
	/**
164
	 * Gets the instance of this singleton class
165
	 *
166
	 * @since 8.7.0
167
	 *
168
	 * @return Error_Handler $instance
169
	 */
170
	public static function get_instance() {
171
		if ( is_null( self::$instance ) ) {
172
			self::$instance = new self();
173
		}
174
		return self::$instance;
175
	}
176
177
	/**
178
	 * Keep track of a connection error that was encountered
179
	 *
180
	 * @since 8.7.0
181
	 *
182
	 * @param \WP_Error $error the error object.
183
	 * @param boolean   $force Force the report, even if should_report_error is false.
184
	 * @return void
185
	 */
186
	public function report_error( \WP_Error $error, $force = false ) {
187
		if ( in_array( $error->get_error_code(), $this->known_errors, true ) && $this->should_report_error( $error ) || $force ) {
188
			$stored_error = $this->store_error( $error );
189
			if ( $stored_error ) {
190
				$this->send_error_to_wpcom( $stored_error );
191
			}
192
		}
193
	}
194
195
	/**
196
	 * Checks the status of the gate
197
	 *
198
	 * This protects the site (and WPCOM) against over loads.
199
	 *
200
	 * @since 8.7.0
201
	 *
202
	 * @param \WP_Error $error the error object.
203
	 * @return boolean $should_report True if gate is open and the error should be reported.
204
	 */
205
	public function should_report_error( \WP_Error $error ) {
206
207
		if ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) {
208
			return true;
209
		}
210
211
		/**
212
		 * Whether to bypass the gate for XML-RPC error handling
213
		 *
214
		 * By default, we only process XML-RPC errors once an hour for each error code.
215
		 * This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
216
		 *
217
		 * This filter is useful for unit testing
218
		 *
219
		 * @since 8.7.0
220
		 *
221
		 * @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
222
		 */
223
		$bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
224
		if ( true === $bypass_gate ) {
225
			return true;
226
		}
227
228
		$transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
229
230
		if ( get_transient( $transient ) ) {
231
			return false;
232
		}
233
234
		set_transient( $transient, true, HOUR_IN_SECONDS );
235
		return true;
236
	}
237
238
	/**
239
	 * Stores the error in the database so we know there is an issue and can inform the user
240
	 *
241
	 * @since 8.7.0
242
	 *
243
	 * @param \WP_Error $error the error object.
244
	 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
245
	 */
246
	public function store_error( \WP_Error $error ) {
247
248
		$stored_errors = $this->get_stored_errors();
249
		$error_array   = $this->wp_error_to_array( $error );
250
		$error_code    = $error->get_error_code();
251
		$user_id       = $error_array['user_id'];
252
253
		if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
254
			$stored_errors[ $error_code ] = array();
255
		}
256
257
		$stored_errors[ $error_code ][ $user_id ] = $error_array;
258
259
		// Let's store a maximum of 5 different user ids for each error code.
260
		if ( count( $stored_errors[ $error_code ] ) > 5 ) {
261
			// array_shift will destroy keys here because they are numeric, so manually remove first item.
262
			$keys = array_keys( $stored_errors[ $error_code ] );
263
			unset( $stored_errors[ $error_code ][ $keys[0] ] );
264
		}
265
266
		if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
267
			return $error_array;
268
		}
269
270
		return false;
271
272
	}
273
274
	/**
275
	 * Converts a WP_Error object in the array representation we store in the database
276
	 *
277
	 * @since 8.7.0
278
	 *
279
	 * @param \WP_Error $error the error object.
280
	 * @return boolean|array False if error is invalid or the error array
281
	 */
282
	public function wp_error_to_array( \WP_Error $error ) {
283
284
		$data = $error->get_error_data();
285
286
		if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
287
			return false;
288
		}
289
290
		$data = $data['signature_details'];
291
292
		if ( ! isset( $data['token'] ) || empty( $data['token'] ) ) {
293
			return false;
294
		}
295
296
		$user_id = $this->get_user_id_from_token( $data['token'] );
297
298
		$error_array = array(
299
			'error_code'    => $error->get_error_code(),
300
			'user_id'       => $user_id,
301
			'error_message' => $error->get_error_message(),
302
			'error_data'    => $data,
303
			'timestamp'     => time(),
304
			'nonce'         => wp_generate_password( 10, false ),
305
		);
306
307
		if ( $this->track_lost_active_master_user( $error->get_error_code(), $data['token'], $user_id ) ) {
0 ignored issues
show
The method get_error_code() does not seem to exist on object<WP_Error>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
308
			$error_array['error_message'] = 'Site has a deleted but active master user token';
309
		}
310
311
		return $error_array;
312
313
	}
314
315
	/**
316
	 * This is been used to track blogs with deleted master user but whose tokens are still actively being used
317
	 *
318
	 * See p9dueE-1GB-p2
319
	 *
320
	 * This tracking should be removed as long as we no longer need, possibly in 8.9
321
	 *
322
	 * @since 8.8.1
323
	 *
324
	 * @param string  $error_code The error code.
325
	 * @param string  $token The token that triggered the error.
326
	 * @param integer $user_id The user ID used to make the request that triggered the error.
327
	 * @return boolean
328
	 */
329
	private function track_lost_active_master_user( $error_code, $token, $user_id ) {
330
		if ( 'unknown_user' === $error_code ) {
331
			$manager = new Manager();
332
			// If the Unknown user is the master user (master user has been deleted).
333
			if ( $manager->is_missing_connection_owner() && (int) $user_id === (int) $manager->get_connection_owner_id() ) {
334
				$user_token = $manager->get_access_token( JETPACK_MASTER_USER );
0 ignored issues
show
JETPACK_MASTER_USER is of type boolean, but the function expects a false|integer.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
335
				// If there's still a token stored for the deleted master user.
336
				if ( $user_token && is_object( $user_token ) && isset( $user_token->secret ) ) {
337
					$token_parts = explode( ':', wp_unslash( $token ) );
338
					// If the token stored for the deleted master user matches the token user by wpcom to make the request.
339
					// This means that requests FROM this site TO wpcom using the JETPACK_MASTER_USER constant are still working.
340
					if ( isset( $token_parts[0] ) && ! empty( $token_parts[0] ) && false !== strpos( $user_token->secret, $token_parts[0] ) ) {
341
						return true;
342
					}
343
				}
344
			}
345
		}
346
		return false;
347
	}
348
349
	/**
350
	 * Sends the error to WP.com to be verified
351
	 *
352
	 * @since 8.7.0
353
	 *
354
	 * @param array $error_array The array representation of the error as it is stored in the database.
355
	 * @return bool
356
	 */
357
	public function send_error_to_wpcom( $error_array ) {
358
359
		$blog_id = \Jetpack_Options::get_option( 'id' );
360
361
		$encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
362
363
		if ( false === $encrypted_data ) {
364
			return false;
365
		}
366
367
		$args = array(
368
			'body' => array(
369
				'error_data' => $encrypted_data,
370
			),
371
		);
372
373
		// send encrypted data to WP.com Public-API v2.
374
		wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
375
		return true;
376
	}
377
378
	/**
379
	 * Encrypt data to be sent over to WP.com
380
	 *
381
	 * @since 8.7.0
382
	 *
383
	 * @param array|string $data the data to be encoded.
384
	 * @return boolean|string The encoded string on success, false on failure
385
	 */
386
	public function encrypt_data_to_wpcom( $data ) {
387
388
		try {
389
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
390
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
391
			$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
392
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
393
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
394
		} catch ( \SodiumException $e ) {
395
			// error encrypting data.
396
			return false;
397
		}
398
399
		return $encrypted_data;
400
401
	}
402
403
	/**
404
	 * Extracts the user ID from a token
405
	 *
406
	 * @since 8.7.0
407
	 *
408
	 * @param string $token the token used to make the xml-rpc request.
409
	 * @return string $the user id or `invalid` if user id not present.
410
	 */
411
	public function get_user_id_from_token( $token ) {
412
		$parsed_token = explode( ':', wp_unslash( $token ) );
413
414
		if ( isset( $parsed_token[2] ) && ! empty( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
415
			$user_id = $parsed_token[2];
416
		} else {
417
			$user_id = 'invalid';
418
		}
419
420
		return $user_id;
421
422
	}
423
424
	/**
425
	 * Gets the reported errors stored in the database
426
	 *
427
	 * @since 8.7.0
428
	 *
429
	 * @return array $errors
430
	 */
431 View Code Duplication
	public function get_stored_errors() {
432
433
		$stored_errors = get_option( self::STORED_ERRORS_OPTION );
434
435
		if ( ! is_array( $stored_errors ) ) {
436
			$stored_errors = array();
437
		}
438
439
		$stored_errors = $this->garbage_collector( $stored_errors );
440
441
		return $stored_errors;
442
	}
443
444
	/**
445
	 * Gets the verified errors stored in the database
446
	 *
447
	 * @since 8.7.0
448
	 *
449
	 * @return array $errors
450
	 */
451 View Code Duplication
	public function get_verified_errors() {
452
453
		$verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
454
455
		if ( ! is_array( $verified_errors ) ) {
456
			$verified_errors = array();
457
		}
458
459
		$verified_errors = $this->garbage_collector( $verified_errors );
460
461
		return $verified_errors;
462
	}
463
464
	/**
465
	 * Removes expired errors from the array
466
	 *
467
	 * This method is called by get_stored_errors and get_verified errors and filters their result
468
	 * Whenever a new error is stored to the database or verified, this will be triggered and the
469
	 * expired error will be permantently removed from the database
470
	 *
471
	 * @since 8.7.0
472
	 *
473
	 * @param array $errors array of errors as stored in the database.
474
	 * @return array
475
	 */
476
	private function garbage_collector( $errors ) {
477
		foreach ( $errors as $error_code => $users ) {
478
			foreach ( $users as $user_id => $error ) {
479
				if ( self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
480
					unset( $errors[ $error_code ][ $user_id ] );
481
				}
482
			}
483
		}
484
		// Clear empty error codes.
485
		$errors = array_filter(
486
			$errors,
487
			function( $user_errors ) {
488
				return ! empty( $user_errors );
489
			}
490
		);
491
		return $errors;
492
	}
493
494
	/**
495
	 * Delete all stored and verified errors from the database
496
	 *
497
	 * @since 8.7.0
498
	 *
499
	 * @return void
500
	 */
501
	public function delete_all_errors() {
502
		$this->delete_stored_errors();
503
		$this->delete_verified_errors();
504
	}
505
506
	/**
507
	 * Delete the reported errors stored in the database
508
	 *
509
	 * @since 8.7.0
510
	 *
511
	 * @return boolean True, if option is successfully deleted. False on failure.
512
	 */
513
	public function delete_stored_errors() {
514
		return delete_option( self::STORED_ERRORS_OPTION );
515
	}
516
517
	/**
518
	 * Delete the verified errors stored in the database
519
	 *
520
	 * @since 8.7.0
521
	 *
522
	 * @return boolean True, if option is successfully deleted. False on failure.
523
	 */
524
	public function delete_verified_errors() {
525
		return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
526
	}
527
528
	/**
529
	 * Gets an error based on the nonce
530
	 *
531
	 * Receives a nonce and finds the related error.
532
	 *
533
	 * @since 8.7.0
534
	 *
535
	 * @param string $nonce The nonce created for the error we want to get.
536
	 * @return null|array Returns the error array representation or null if error not found.
537
	 */
538
	public function get_error_by_nonce( $nonce ) {
539
		$errors = $this->get_stored_errors();
540
		foreach ( $errors as $user_group ) {
541
			foreach ( $user_group as $error ) {
542
				if ( $error['nonce'] === $nonce ) {
543
					return $error;
544
				}
545
			}
546
		}
547
		return null;
548
	}
549
550
	/**
551
	 * Adds an error to the verified error list
552
	 *
553
	 * @since 8.7.0
554
	 *
555
	 * @param array $error The error array, as it was saved in the unverified errors list.
556
	 * @return void
557
	 */
558
	public function verify_error( $error ) {
559
560
		$verified_errors = $this->get_verified_errors();
561
		$error_code      = $error['error_code'];
562
		$user_id         = $error['user_id'];
563
564
		if ( ! isset( $verified_errors[ $error_code ] ) ) {
565
			$verified_errors[ $error_code ] = array();
566
		}
567
568
		$verified_errors[ $error_code ][ $user_id ] = $error;
569
570
		update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
571
572
	}
573
574
	/**
575
	 * Register REST API end point for error hanlding.
576
	 *
577
	 * @since 8.7.0
578
	 *
579
	 * @return void
580
	 */
581
	public function register_verify_error_endpoint() {
582
		register_rest_route(
583
			'jetpack/v4',
584
			'/verify_xmlrpc_error',
585
			array(
586
				'methods'             => \WP_REST_Server::CREATABLE,
587
				'callback'            => array( $this, 'verify_xml_rpc_error' ),
588
				'permission_callback' => '__return_true',
589
				'args'                => array(
590
					'nonce' => array(
591
						'required' => true,
592
						'type'     => 'string',
593
					),
594
				),
595
			)
596
		);
597
	}
598
599
	/**
600
	 * Handles verification that a xml rpc error is legit and came from WordPres.com
601
	 *
602
	 * @since 8.7.0
603
	 *
604
	 * @param \WP_REST_Request $request The request sent to the WP REST API.
605
	 *
606
	 * @return boolean
607
	 */
608
	public function verify_xml_rpc_error( \WP_REST_Request $request ) {
609
610
		$error = $this->get_error_by_nonce( $request['nonce'] );
611
612
		if ( $error ) {
613
			$this->verify_error( $error );
614
			return new \WP_REST_Response( true, 200 );
615
		}
616
617
		return new \WP_REST_Response( false, 200 );
618
619
	}
620
621
}
622