Completed
Push — add/handling-connection-errors ( 700587...612b6c )
by
unknown
06:53
created

Error_Handler::get_verified_errors()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 0
dl 0
loc 7
rs 10
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
	 * List of known errors. Only error codes in this list will be handled
49
	 *
50
	 * @var array
51
	 */
52
	public $known_errors = array(
53
		'malformed_token',
54
		'malformed_user_id',
55
		'unknown_user',
56
		'no_user_tokens',
57
		'empty_master_user_option',
58
		'no_token_for_user',
59
		'token_malformed',
60
		'user_id_mismatch',
61
		'no_possible_tokens',
62
		'no_valid_token',
63
		'unknown_token',
64
		'could_not_sign',
65
		'invalid_scheme',
66
		'invalid_secret',
67
		'invalid_token',
68
		'token_mismatch',
69
		'invalid_body',
70
		'invalid_signature',
71
		'invalid_body_hash',
72
		'invalid_nonce',
73
		'signature_mismatch',
74
	);
75
76
	/**
77
	 * Holds the instance of this singleton class
78
	 *
79
	 * @var Error_Handler $instance
80
	 */
81
	public static $instance = null;
82
83
	/**
84
	 * Initialize hooks
85
	 */
86
	private function __construct() {
87
		defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
88
89
		add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
90
91
		$this->handle_verified_errors();
92
	}
93
94
	/**
95
	 * Gets the list of verified errors and act upon them
96
	 *
97
	 * @return void
98
	 */
99
	public function handle_verified_errors() {
100
		$verified_errors = $this->get_verified_errors();
101
		foreach ( $verified_errors as $error_code => $user_errors ) {
102
103
			switch ( $error_code ) {
104
				case 'malformed_token':
105
				case 'token_malformed':
106
				case 'no_possible_tokens':
107
				case 'no_valid_token':
108
				case 'unknown_token':
109
				case 'could_not_sign':
110
				case 'invalid_token':
111
				case 'could_not_sign':
112
				case 'token_mismatch':
113
				case 'invalid_signature':
114
				case 'signature_mismatch':
115
					new Error_Handlers\Invalid_Blog_Token( $user_errors );
116
					break;
117
			}
118
		}
119
	}
120
121
	/**
122
	 * Gets the instance of this singleton class
123
	 *
124
	 * @return Error_Handler $instance
125
	 */
126
	public static function get_instance() {
127
		if ( is_null( self::$instance ) ) {
128
			self::$instance = new self();
129
		}
130
		return self::$instance;
131
	}
132
133
	/**
134
	 * Keep track of a connection error that was encoutered
135
	 *
136
	 * @param \WP_Error $error the error object.
137
	 * @param boolean   $force Force the report, even if should_report_error is false.
138
	 * @return void
139
	 */
140
	public function report_error( \WP_Error $error, $force = false ) {
141
		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...
142
			$stored_error = $this->store_error( $error );
143
			if ( $stored_error ) {
144
				$this->send_error_to_wpcom( $stored_error );
145
			}
146
		}
147
	}
148
149
	/**
150
	 * Checks the status of the gate
151
	 *
152
	 * This protects the site (and WPCOM) against over loads.
153
	 *
154
	 * @param \WP_Error $error the error object.
155
	 * @return boolean $should_report True if gate is open and the error should be reported.
156
	 */
157
	public function should_report_error( \WP_Error $error ) {
158
159
		if ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) {
160
			return true;
161
		}
162
163
		$bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
164
		if ( true === $bypass_gate ) {
165
			return true;
166
		}
167
168
		$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...
169
170
		if ( get_transient( $transient ) ) {
171
			return false;
172
		}
173
174
		set_transient( $transient, true, HOUR_IN_SECONDS );
175
		return true;
176
	}
177
178
	/**
179
	 * Stores the error in the database so we know there is an issue and can inform the user
180
	 *
181
	 * @param \WP_Error $error the error object.
182
	 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
183
	 */
184
	public function store_error( \WP_Error $error ) {
185
186
		$stored_errors = $this->get_stored_errors();
187
		$error_array   = $this->wp_error_to_array( $error );
188
		$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...
189
		$user_id       = $error_array['user_id'];
190
191
		if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
192
			$stored_errors[ $error_code ] = array();
193
		}
194
195
		$stored_errors[ $error_code ][ $user_id ] = $error_array;
196
197
		// Let's store a maximum of 5 different user ids for each error code.
198
		if ( count( $stored_errors[ $error_code ] ) > 5 ) {
199
			// array_shift will destroy keys here because they are numeric, so manually remove first item.
200
			$keys = array_keys( $stored_errors[ $error_code ] );
201
			unset( $stored_errors[ $error_code ][ $keys[0] ] );
202
		}
203
204
		if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
205
			return $error_array;
206
		}
207
208
		return false;
209
210
	}
211
212
	/**
213
	 * Converts a WP_Error object in the array representation we store in the database
214
	 *
215
	 * @param \WP_Error $error the error object.
216
	 * @return boolean|array False if error is invalid or the error array
217
	 */
218
	public function wp_error_to_array( \WP_Error $error ) {
219
220
		$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...
221
		$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...
222
223
		if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
224
			return false;
225
		}
226
227
		$data = $data['signature_details'];
228
229
		if ( ! isset( $data['token'] ) || empty( $data['token'] ) ) {
230
			return false;
231
		}
232
233
		$user_id = $this->get_user_id_from_token( $data['token'] );
234
235
		$error_array = array(
236
			'error_code'    => $error_code,
237
			'user_id'       => $user_id,
238
			'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...
239
			'error_data'    => $data,
240
			'timestamp'     => time(),
241
			'nonce'         => wp_generate_password( 10, false ),
242
		);
243
244
		return $error_array;
245
246
	}
247
248
	/**
249
	 * Sends the error to WP.com to be verified
250
	 *
251
	 * @param array $error_array The array representation of the error as it is stored in the database.
252
	 * @return bool
253
	 */
254
	public function send_error_to_wpcom( $error_array ) {
255
256
		$blog_id = \Jetpack_Options::get_option( 'id' );
257
258
		$encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
259
260
		$args = array(
261
			'body' => array(
262
				'error_data' => $encrypted_data,
263
			),
264
		);
265
266
		// send encrypted data to WP.com Public-API v2.
267
		wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
268
		return true;
269
	}
270
271
	/**
272
	 * Encrypt data to be sent over to WP.com
273
	 *
274
	 * @param array|string $data the data to be encoded.
275
	 * @return boolean|string The encoded string on success, false on failure
276
	 */
277
	public function encrypt_data_to_wpcom( $data ) {
278
279
		try {
280
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
281
			// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
282
			$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
283
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
284
			// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
285
		} 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...
286
			// error encrypting data.
287
			return false;
288
		}
289
290
		return $encrypted_data;
291
292
	}
293
294
	/**
295
	 * Extracts the user ID from a token
296
	 *
297
	 * @param string $token the token used to make the xml-rpc request.
298
	 * @return string $the user id or `invalid` if user id not present.
299
	 */
300
	public function get_user_id_from_token( $token ) {
301
		$parsed_token = explode( ':', wp_unslash( $token ) );
302
303
		if ( isset( $parsed_token[2] ) && ! empty( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
304
			$user_id = $parsed_token[2];
305
		} else {
306
			$user_id = 'invalid';
307
		}
308
309
		return $user_id;
310
311
	}
312
313
	/**
314
	 * Gets the reported errors stored in the database
315
	 *
316
	 * @return array $errors
317
	 */
318
	public function get_stored_errors() {
319
		// todo: add object cache.
320
		// todo: garbage collector, delete old unverified errors based on timestamp.
321
		$stored_errors = get_option( self::STORED_ERRORS_OPTION );
322
		if ( ! is_array( $stored_errors ) ) {
323
			$stored_errors = array();
324
		}
325
		return $stored_errors;
326
	}
327
328
	/**
329
	 * Gets the verified errors stored in the database
330
	 *
331
	 * @return array $errors
332
	 */
333
	public function get_verified_errors() {
334
		$verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
335
		if ( ! is_array( $verified_errors ) ) {
336
			$verified_errors = array();
337
		}
338
		return $verified_errors;
339
	}
340
341
	/**
342
	 * Delete all stored and verified errors from the database
343
	 *
344
	 * @return void
345
	 */
346
	public function delete_all_errors() {
347
		$this->delete_stored_errors();
348
		$this->delete_verified_errors();
349
	}
350
351
	/**
352
	 * Delete the reported errors stored in the database
353
	 *
354
	 * @return boolean True, if option is successfully deleted. False on failure.
355
	 */
356
	public function delete_stored_errors() {
357
		return delete_option( self::STORED_ERRORS_OPTION );
358
	}
359
360
	/**
361
	 * Delete the verified errors stored in the database
362
	 *
363
	 * @return boolean True, if option is successfully deleted. False on failure.
364
	 */
365
	public function delete_verified_errors() {
366
		return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
367
	}
368
369
	/**
370
	 * Gets an error based on the nonce
371
	 *
372
	 * Receives a nonce and finds the related error. If error is found, move it to the verified errors option.
373
	 *
374
	 * @param string $nonce The nonce created for the error we want to get.
375
	 * @return null|array Returns the error array representation or null if error not found.
376
	 */
377
	public function get_error_by_nonce( $nonce ) {
378
		$errors = $this->get_stored_errors();
379
		foreach ( $errors as $user_group ) {
380
			foreach ( $user_group as $error ) {
381
				if ( $error['nonce'] === $nonce ) {
382
					return $error;
383
				}
384
			}
385
		}
386
		return null;
387
	}
388
389
	/**
390
	 * Adds an error to the verified error list
391
	 *
392
	 * @param array $error The error array, as it was saved in the unverified errors list.
393
	 * @return void
394
	 */
395
	public function verify_error( $error ) {
396
397
		$verified_errors = $this->get_verified_errors();
398
		$error_code      = $error['error_code'];
399
		$user_id         = $error['user_id'];
400
401
		if ( ! isset( $verified_errors[ $error_code ] ) ) {
402
			$verified_errors[ $error_code ] = array();
403
		}
404
405
		$verified_errors[ $error_code ][ $user_id ] = $error;
406
407
		update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
408
409
	}
410
411
	/**
412
	 * Register REST API end point for error hanlding.
413
	 *
414
	 * @since 8.7.0
415
	 *
416
	 * @return void
417
	 */
418
	public function register_verify_error_endpoint() {
419
		register_rest_route(
420
			'jetpack/v4',
421
			'/verify_xmlrpc_error',
422
			array(
423
				'methods'  => \WP_REST_Server::CREATABLE,
424
				'callback' => array( $this, 'verify_xml_rpc_error' ),
425
				'args'     => array(
426
					'nonce' => array(
427
						'required' => true,
428
						'type'     => 'string',
429
					),
430
				),
431
			)
432
		);
433
	}
434
435
	/**
436
	 * Handles verification that a xml rpc error is legit and came from WordPres.com
437
	 *
438
	 * @since 8.7.0
439
	 *
440
	 * @param \WP_REST_Request $request The request sent to the WP REST API.
441
	 *
442
	 * @return boolean
443
	 */
444
	public function verify_xml_rpc_error( \WP_REST_Request $request ) {
445
446
		$error = $this->get_error_by_nonce( $request['nonce'] );
447
448
		if ( $error ) {
449
			$this->verify_error( $error );
450
			return new \WP_REST_Response( true, 200 );
451
		}
452
453
		return new \WP_REST_Response( false, 200 );
454
455
	}
456
457
}
458