Completed
Push — add/handling-connection-errors ( 431ba8...94b37e )
by
unknown
164:19 queued 156:13
created

Error_Handler::garbage_collector()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 4
nop 1
dl 0
loc 17
rs 9.7
c 0
b 0
f 0
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
class Error_Handler {
25
26
	/**
27
	 * The name of the option that stores the errors
28
	 *
29
	 * @var string
30
	 */
31
	const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
32
33
	/**
34
	 * The name of the option that stores the errors
35
	 *
36
	 * @var string
37
	 */
38
	const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
39
40
	/**
41
	 * The prefix of the transient that controls the gate for each error code
42
	 *
43
	 * @var string
44
	 */
45
	const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
46
47
	/**
48
	 * Time in seconds a test should live in the database before being discarded
49
	 */
50
	const ERROR_LIFE_TIME = DAY_IN_SECONDS * 3;
51
	/**
52
	 * List of known errors. Only error codes in this list will be handled
53
	 *
54
	 * @var array
55
	 */
56
	public $known_errors = array(
57
		'malformed_token',
58
		'malformed_user_id',
59
		'unknown_user',
60
		'no_user_tokens',
61
		'empty_master_user_option',
62
		'no_token_for_user',
63
		'token_malformed',
64
		'user_id_mismatch',
65
		'no_possible_tokens',
66
		'no_valid_token',
67
		'unknown_token',
68
		'could_not_sign',
69
		'invalid_scheme',
70
		'invalid_secret',
71
		'invalid_token',
72
		'token_mismatch',
73
		'invalid_body',
74
		'invalid_signature',
75
		'invalid_body_hash',
76
		'invalid_nonce',
77
		'signature_mismatch',
78
	);
79
80
	/**
81
	 * Holds the instance of this singleton class
82
	 *
83
	 * @var Error_Handler $instance
84
	 */
85
	public static $instance = null;
86
87
	/**
88
	 * Initialize hooks
89
	 */
90
	private function __construct() {
91
		defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
92
93
		add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
94
95
		$this->handle_verified_errors();
96
	}
97
98
	/**
99
	 * Gets the list of verified errors and act upon them
100
	 *
101
	 * @return void
102
	 */
103
	public function handle_verified_errors() {
104
		$verified_errors = $this->get_verified_errors();
105
		foreach ( $verified_errors as $error_code => $user_errors ) {
106
107
			switch ( $error_code ) {
108
				case 'malformed_token':
109
				case 'token_malformed':
110
				case 'no_possible_tokens':
111
				case 'no_valid_token':
112
				case 'unknown_token':
113
				case 'could_not_sign':
114
				case 'invalid_token':
115
				case 'could_not_sign':
116
				case 'token_mismatch':
117
				case 'invalid_signature':
118
				case 'signature_mismatch':
119
					new Error_Handlers\Invalid_Blog_Token( $user_errors );
120
					break;
121
			}
122
		}
123
	}
124
125
	/**
126
	 * Gets the instance of this singleton class
127
	 *
128
	 * @return Error_Handler $instance
129
	 */
130
	public static function get_instance() {
131
		if ( is_null( self::$instance ) ) {
132
			self::$instance = new self();
133
		}
134
		return self::$instance;
135
	}
136
137
	/**
138
	 * Keep track of a connection error that was encoutered
139
	 *
140
	 * @param \WP_Error $error the error object.
141
	 * @param boolean   $force Force the report, even if should_report_error is false.
142
	 * @return void
143
	 */
144
	public function report_error( \WP_Error $error, $force = false ) {
145
		if ( in_array( $error->get_error_code(), $this->known_errors, true ) && $this->should_report_error( $error ) || $force ) {
0 ignored issues
show
Bug introduced by
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...
146
			$stored_error = $this->store_error( $error );
147
			if ( $stored_error ) {
148
				$this->send_error_to_wpcom( $stored_error );
149
			}
150
		}
151
	}
152
153
	/**
154
	 * Checks the status of the gate
155
	 *
156
	 * This protects the site (and WPCOM) against over loads.
157
	 *
158
	 * @param \WP_Error $error the error object.
159
	 * @return boolean $should_report True if gate is open and the error should be reported.
160
	 */
161
	public function should_report_error( \WP_Error $error ) {
162
163
		if ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) {
164
			return true;
165
		}
166
167
		$bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
168
		if ( true === $bypass_gate ) {
169
			return true;
170
		}
171
172
		$transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
0 ignored issues
show
Bug introduced by
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...
173
174
		if ( get_transient( $transient ) ) {
175
			return false;
176
		}
177
178
		set_transient( $transient, true, HOUR_IN_SECONDS );
179
		return true;
180
	}
181
182
	/**
183
	 * Stores the error in the database so we know there is an issue and can inform the user
184
	 *
185
	 * @param \WP_Error $error the error object.
186
	 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
187
	 */
188
	public function store_error( \WP_Error $error ) {
189
190
		$stored_errors = $this->get_stored_errors();
191
		$error_array   = $this->wp_error_to_array( $error );
192
		$error_code    = $error->get_error_code();
0 ignored issues
show
Bug introduced by
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...
193
		$user_id       = $error_array['user_id'];
194
195
		if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
196
			$stored_errors[ $error_code ] = array();
197
		}
198
199
		$stored_errors[ $error_code ][ $user_id ] = $error_array;
200
201
		// Let's store a maximum of 5 different user ids for each error code.
202
		if ( count( $stored_errors[ $error_code ] ) > 5 ) {
203
			// array_shift will destroy keys here because they are numeric, so manually remove first item.
204
			$keys = array_keys( $stored_errors[ $error_code ] );
205
			unset( $stored_errors[ $error_code ][ $keys[0] ] );
206
		}
207
208
		if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
209
			return $error_array;
210
		}
211
212
		return false;
213
214
	}
215
216
	/**
217
	 * Converts a WP_Error object in the array representation we store in the database
218
	 *
219
	 * @param \WP_Error $error the error object.
220
	 * @return boolean|array False if error is invalid or the error array
221
	 */
222
	public function wp_error_to_array( \WP_Error $error ) {
223
224
		$data       = $error->get_error_data();
0 ignored issues
show
Bug introduced by
The method get_error_data() 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...
225
		$error_code = $error->get_error_code();
0 ignored issues
show
Bug introduced by
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...
226
227
		if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
228
			return false;
229
		}
230
231
		$data = $data['signature_details'];
232
233
		if ( ! isset( $data['token'] ) || empty( $data['token'] ) ) {
234
			return false;
235
		}
236
237
		$user_id = $this->get_user_id_from_token( $data['token'] );
238
239
		$error_array = array(
240
			'error_code'    => $error_code,
241
			'user_id'       => $user_id,
242
			'error_message' => $error->get_error_message(),
0 ignored issues
show
Bug introduced by
The method get_error_message() 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...
243
			'error_data'    => $data,
244
			'timestamp'     => time(),
245
			'nonce'         => wp_generate_password( 10, false ),
246
		);
247
248
		return $error_array;
249
250
	}
251
252
	/**
253
	 * Sends the error to WP.com to be verified
254
	 *
255
	 * @param array $error_array The array representation of the error as it is stored in the database.
256
	 * @return bool
257
	 */
258
	public function send_error_to_wpcom( $error_array ) {
259
260
		$blog_id = \Jetpack_Options::get_option( 'id' );
261
262
		$encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
263
264
		$args = array(
265
			'body' => array(
266
				'error_data' => $encrypted_data,
267
			),
268
		);
269
270
		// send encrypted data to WP.com Public-API v2.
271
		wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
272
		return true;
273
	}
274
275
	/**
276
	 * Encrypt data to be sent over to WP.com
277
	 *
278
	 * @param array|string $data the data to be encoded.
279
	 * @return boolean|string The encoded string on success, false on failure
280
	 */
281
	public function encrypt_data_to_wpcom( $data ) {
282
283
		try {
284
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
285
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
286
			$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
287
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
288
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
289
		} catch ( \SodiumException $e ) {
0 ignored issues
show
Bug introduced by
The class SodiumException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
290
			// error encrypting data.
291
			return false;
292
		}
293
294
		return $encrypted_data;
295
296
	}
297
298
	/**
299
	 * Extracts the user ID from a token
300
	 *
301
	 * @param string $token the token used to make the xml-rpc request.
302
	 * @return string $the user id or `invalid` if user id not present.
303
	 */
304
	public function get_user_id_from_token( $token ) {
305
		$parsed_token = explode( ':', wp_unslash( $token ) );
306
307
		if ( isset( $parsed_token[2] ) && ! empty( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
308
			$user_id = $parsed_token[2];
309
		} else {
310
			$user_id = 'invalid';
311
		}
312
313
		return $user_id;
314
315
	}
316
317
	/**
318
	 * Gets the reported errors stored in the database
319
	 *
320
	 * @return array $errors
321
	 */
322 View Code Duplication
	public function get_stored_errors() {
323
324
		$stored_errors = get_option( self::STORED_ERRORS_OPTION );
325
326
		if ( ! is_array( $stored_errors ) ) {
327
			$stored_errors = array();
328
		}
329
330
		$stored_errors = $this->garbage_collector( $stored_errors );
331
332
		return $stored_errors;
333
	}
334
335
	/**
336
	 * Gets the verified errors stored in the database
337
	 *
338
	 * @return array $errors
339
	 */
340 View Code Duplication
	public function get_verified_errors() {
341
342
		$verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
343
344
		if ( ! is_array( $verified_errors ) ) {
345
			$verified_errors = array();
346
		}
347
348
		$verified_errors = $this->garbage_collector( $verified_errors );
349
350
		return $verified_errors;
351
	}
352
353
	/**
354
	 * Removes expired errors from the array
355
	 *
356
	 * This method is calleb by get_stored_errors and get_verified errors and filters their result
357
	 * Whenever a new error is stored to the database or verified, this will be triggered and the
358
	 * expired error will be permantently removed from the database
359
	 *
360
	 * @param array $errors array of errors as stored in the database.
361
	 * @return array
362
	 */
363
	private function garbage_collector( $errors ) {
364
		foreach ( $errors as $error_code => $users ) {
365
			foreach ( $users as $user_id => $error ) {
366
				if ( self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
367
					unset( $errors[ $error_code ][ $user_id ] );
368
				}
369
			}
370
		}
371
		// Clear empty error codes.
372
		$errors = array_filter(
373
			$errors,
374
			function( $user_errors ) {
375
				return ! empty( $user_errors );
376
			}
377
		);
378
		return $errors;
379
	}
380
381
	/**
382
	 * Delete all stored and verified errors from the database
383
	 *
384
	 * @return void
385
	 */
386
	public function delete_all_errors() {
387
		$this->delete_stored_errors();
388
		$this->delete_verified_errors();
389
	}
390
391
	/**
392
	 * Delete the reported errors stored in the database
393
	 *
394
	 * @return boolean True, if option is successfully deleted. False on failure.
395
	 */
396
	public function delete_stored_errors() {
397
		return delete_option( self::STORED_ERRORS_OPTION );
398
	}
399
400
	/**
401
	 * Delete the verified errors stored in the database
402
	 *
403
	 * @return boolean True, if option is successfully deleted. False on failure.
404
	 */
405
	public function delete_verified_errors() {
406
		return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
407
	}
408
409
	/**
410
	 * Gets an error based on the nonce
411
	 *
412
	 * Receives a nonce and finds the related error. If error is found, move it to the verified errors option.
413
	 *
414
	 * @param string $nonce The nonce created for the error we want to get.
415
	 * @return null|array Returns the error array representation or null if error not found.
416
	 */
417
	public function get_error_by_nonce( $nonce ) {
418
		$errors = $this->get_stored_errors();
419
		foreach ( $errors as $user_group ) {
420
			foreach ( $user_group as $error ) {
421
				if ( $error['nonce'] === $nonce ) {
422
					return $error;
423
				}
424
			}
425
		}
426
		return null;
427
	}
428
429
	/**
430
	 * Adds an error to the verified error list
431
	 *
432
	 * @param array $error The error array, as it was saved in the unverified errors list.
433
	 * @return void
434
	 */
435
	public function verify_error( $error ) {
436
437
		$verified_errors = $this->get_verified_errors();
438
		$error_code      = $error['error_code'];
439
		$user_id         = $error['user_id'];
440
441
		if ( ! isset( $verified_errors[ $error_code ] ) ) {
442
			$verified_errors[ $error_code ] = array();
443
		}
444
445
		$verified_errors[ $error_code ][ $user_id ] = $error;
446
447
		update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
448
449
	}
450
451
	/**
452
	 * Register REST API end point for error hanlding.
453
	 *
454
	 * @since 8.7.0
455
	 *
456
	 * @return void
457
	 */
458
	public function register_verify_error_endpoint() {
459
		register_rest_route(
460
			'jetpack/v4',
461
			'/verify_xmlrpc_error',
462
			array(
463
				'methods'  => \WP_REST_Server::CREATABLE,
464
				'callback' => array( $this, 'verify_xml_rpc_error' ),
465
				'args'     => array(
466
					'nonce' => array(
467
						'required' => true,
468
						'type'     => 'string',
469
					),
470
				),
471
			)
472
		);
473
	}
474
475
	/**
476
	 * Handles verification that a xml rpc error is legit and came from WordPres.com
477
	 *
478
	 * @since 8.7.0
479
	 *
480
	 * @param \WP_REST_Request $request The request sent to the WP REST API.
481
	 *
482
	 * @return boolean
483
	 */
484
	public function verify_xml_rpc_error( \WP_REST_Request $request ) {
485
486
		$error = $this->get_error_by_nonce( $request['nonce'] );
487
488
		if ( $error ) {
489
			$this->verify_error( $error );
490
			return new \WP_REST_Response( true, 200 );
491
		}
492
493
		return new \WP_REST_Response( false, 200 );
494
495
	}
496
497
}
498