PluginProbe
UpdraftPlus: WP Backup & Migration Plugin / 1.16.5
UpdraftPlus: WP Backup & Migration Plugin v1.16.5
1.26.7 1.26.6 1.26.5 1.26.4 1.26.3 1.9.19 1.9.25 1.9.26 1.9.30 1.9.31 1.9.32 1.9.4 1.9.40 1.9.41 1.9.42 1.9.43 1.9.44 1.9.45 1.9.46 1.9.5 1.9.50 1.9.51 1.9.60 1.9.62 1.9.63 All 371 releases
updraftplus / includes / class-udrpc.php

class-udrpc.php in UpdraftPlus: WP Backup & Migration Plugin 1.16.5, at includes/class-udrpc.php

1,099 lines 40.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreStart
3 /*
4 This class provides methods for encrypting, sending, receiving and decrypting messages of arbitrary length, using standard encryption methods and including protection against replay attacks.
5
6 Example:
7
8 // Set a key and encrypt with it
9 $ud_rpc = new UpdraftPlus_Remote_Communications($name_indicator); // $name_indicator is a key indicator - indicating which key is being used.
10 $ud_rpc->set_key_local($our_private_key);
11 $ud_rpc->set_key_remote($their_public_key);
12 $encrypted = $ud_rpc->encrypt_message('blah blah');
13
14 // Use the saved WP site option
15 $ud_rpc = new UpdraftPlus_Remote_Communications($name_indicator); // $name_indicator is a key indicator - indicating which key is being used.
16 $ud_rpc->set_option_name('udrpc_remotekey');
17 if (!$ud_rpc->get_key_remote()) throw new Exception('...');
18 $encrypted = $ud_rpc->encrypt_message('blah blah');
19
20 // Generate a new key
21 $ud_rpc = new UpdraftPlus_Remote_Communications('myindicator.example.com');
22 $ud_rpc->set_option_name('udrpc_localkey'); // Save as a WP site option
23 $new_pair = $ud_rpc->generate_new_keypair();
24 if ($new_pair) {
25 $local_private_key = $ud_rpc->get_key_local();
26 $remote_public_key = $ud_rpc->get_key_remote();
27 // ...
28 } else {
29 throw new Exception('...');
30 }
31
32 // Send a message
33 $ud_rpc->activate_replay_protection();
34 $ud_rpc->set_destination_url('https://example.com/path/to/wp');
35 $ud_rpc->send_message('ping');
36 $ud_rpc->send_message('somecommand', array('param1' => 'data', 'param2' => 'moredata'));
37
38 // N.B. The data sent needs to be something that will pass json_encode(). So, it may be desirable to base64-encode it first.
39
40 // Create a listener for incoming messages
41
42 add_filter('udrpc_command_somecommand', 'my_function', 10, 3);
43 // function my_function($response, $data, $name_indicator) { ... ; return array('response' => 'my_reply', 'data' => 'any mixed data'); }
44 // Or:
45 // add_filter('udrpc_action', 'some_function', 10, 4); // Function must return something other than false to indicate that it handled the specific command. Any returned value will be sent as the reply.
46 // function some_function($response, $command, $data, $name_indicator) { ...; return array('response' => 'my_reply', 'data' => 'any mixed data'); }
47 $ud_rpc->set_option_name('udrpc_local_private_key');
48 $ud_rpc->activate_replay_protection();
49 if ($ud_rpc->get_key_local()) {
50 // Make sure you call this before the wp_loaded action is fired (e.g. at init)
51 $ud_rpc->create_listener();
52 }
53
54 // Instead of using activate_replay_protection(), you can use activate_sequence_protection() (receiving side) and set_next_send_sequence_id(). They are very similar; but, the sequence number code isn't tested, and is problematic if you may have multiple clients that don't share storage (you can use the current time as a sequence number, but if two clients send at the same millisecond (or whatever granularity you use), you may have problems); whereas the replay protection code relies on database storage on the sending side (not just the receiving).
55
56 */
57 // @codingStandardsIgnoreEnd
58 if (!class_exists('UpdraftPlus_Remote_Communications')) :
59 class UpdraftPlus_Remote_Communications {
60
61 // Version numbers relate to versions of this PHP library only (i.e. it's not a protocol support number, and version numbers of other compatible libraries (e.g. JavaScript) are not comparable)
62 public $version = '1.4.16';
63
64 private $key_name_indicator;
65
66 private $key_option_name = false;
67
68 private $key_remote = false;
69
70 private $key_local = false;
71
72 private $can_generate = false;
73
74 private $destination_url = false;
75
76 private $maximum_replay_time_difference = 300;
77
78 private $extra_replay_protection = false;
79
80 private $sequence_protection_tolerance;
81
82 private $sequence_protection_table;
83
84 private $sequence_protection_column;
85
86 private $sequence_protection_where_sql;
87
88 // Debug may log confidential data using $this->log() - so only use when you are in a secure environment
89 private $debug = false;
90
91 private $next_send_sequence_id;
92
93 private $allow_cors_from = array();
94
95 private $http_transport = null;
96
97 // Default protocol version - this can be over-ridden with set_message_format
98 // Protocol version 1 (which uses only one RSA key-pair, instead of two) is legacy/deprecated
99 private $format = 2;
100
101 private $http_credentials = array();
102
103 private $incoming_message = null;
104
105 private $message_random_number = null;
106
107 private $require_message_to_be_understood = false;
108
109 public function __construct($key_name_indicator = 'default') {
110 $this->set_key_name_indicator($key_name_indicator);
111 }
112
113 public function set_key_name_indicator($key_name_indicator) {
114 $this->key_name_indicator = $key_name_indicator;
115 }
116
117 public function set_can_generate($can_generate = true) {
118 $this->can_generate = $can_generate;
119 }
120
121 /**
122 * Which sites to allow CORS requests from
123 *
124 * @param string $allow_cors_from
125 */
126 public function set_allow_cors_from($allow_cors_from) {
127 $this->allow_cors_from = $allow_cors_from;
128 }
129
130 public function set_maximum_replay_time_difference($replay_time_difference) {
131 $this->maximum_replay_time_difference = (int) $replay_time_difference;
132 }
133
134 /**
135 * This will cause more things to be sent to $this->log()
136 *
137 * @param boolean $debug
138 */
139 public function set_debug($debug = true) {
140 $this->debug = (bool) $debug;
141 }
142
143 /**
144 * Supported values: a Guzzle object, or, if not, then WP's HTTP API function siwll be used
145 *
146 * @param string $transport
147 */
148 public function set_http_transport($transport) {
149 $this->http_transport = $transport;
150 }
151
152 /**
153 * Sequence protection and replay protection perform similar functions, and using both is often over-kill; the distinction is that sequence protection can be used without needing to do database writes on the sending side (e.g. use the value of time() as the sequence number).
154 * The only rule of sequences is that the receiving side will reject any sequence number that is less than the last previously seen one, within the bounds of the tolerance (but it may also reject those if they are repeats).
155 * The given table/column will record a comma-separated list of recently seen sequences numbers within the tolerance threshold.
156 *
157 * @param string $table
158 * @param string $column
159 * @param string $where_sql
160 * @param integer $tolerance
161 */
162 public function activate_sequence_protection($table, $column, $where_sql, $tolerance = 5) {
163 $this->sequence_protection_tolerance = (int) $tolerance;
164 $this->sequence_protection_table = (string) $table;
165 $this->sequence_protection_column = (string) $column;
166 $this->sequence_protection_where_sql = (string) $where_sql;
167 }
168
169 private function ensure_crypto_loaded() {
170 if (!class_exists('Crypt_Rijndael') || !class_exists('Crypt_RSA') || !class_exists('Crypt_Hash')) {
171 global $updraftplus;
172 // phpseclib 1.x uses deprecated PHP4-style constructors
173 $this->no_deprecation_warnings_on_php7();
174 if (is_a($updraftplus, 'UpdraftPlus')) {
175 $ensure_phpseclib = $updraftplus->ensure_phpseclib(array('Crypt_Rijndael', 'Crypt_RSA', 'Crypt_Hash'), array('Crypt/Rijndael', 'Crypt/RSA', 'Crypt/Hash'));
176 if (is_wp_error($ensure_phpseclib)) return $ensure_phpseclib;
177 } elseif (defined('UPDRAFTPLUS_DIR') && file_exists(UPDRAFTPLUS_DIR.'/vendor/phpseclib/phpseclib/phpseclib')) {
178 $pdir = UPDRAFTPLUS_DIR.'/vendor/phpseclib/phpseclib/phpseclib';
179 if (false === strpos(get_include_path(), $pdir)) set_include_path($pdir.PATH_SEPARATOR.get_include_path());
180 if (!class_exists('Crypt_Rijndael')) include_once 'Crypt/Rijndael.php';
181 if (!class_exists('Crypt_RSA')) include_once 'Crypt/RSA.php';
182 if (!class_exists('Crypt_Hash')) include_once 'Crypt/Hash.php';
183 } elseif (file_exists(dirname(dirname(__FILE__)).'/vendor/phpseclib/phpseclib/phpseclib')) {
184 $pdir = dirname(dirname(__FILE__)).'/vendor/phpseclib/phpseclib/phpseclib';
185 if (false === strpos(get_include_path(), $pdir)) set_include_path($pdir.PATH_SEPARATOR.get_include_path());
186 if (!class_exists('Crypt_Rijndael')) include_once 'Crypt/Rijndael.php';
187 if (!class_exists('Crypt_RSA')) include_once 'Crypt/RSA.php';
188 if (!class_exists('Crypt_Hash')) include_once 'Crypt/Hash.php';
189 }
190 }
191 }
192
193 /**
194 * Ugly, but necessary to prevent debug output breaking the conversation when the user has debug turned on
195 */
196 private function no_deprecation_warnings_on_php7() {
197 // PHP_MAJOR_VERSION is defined in PHP 5.2.7+
198 // We don't test for PHP > 7 because the specific deprecated element will be removed in PHP 8 - and so no warning should come anyway (and we shouldn't suppress other stuff until we know we need to).
199 // @codingStandardsIgnoreLine
200 if (defined('PHP_MAJOR_VERSION') && PHP_MAJOR_VERSION == 7) {
201 $old_level = error_reporting();
202 // @codingStandardsIgnoreLine
203 $new_level = $old_level & ~E_DEPRECATED;
204 if ($old_level != $new_level) error_reporting($new_level);
205 }
206 }
207
208 public function set_destination_url($destination_url) {
209 $this->destination_url = $destination_url;
210 }
211
212 public function get_destination_url() {
213 return $this->destination_url;
214 }
215
216 public function set_option_name($key_option_name) {
217 $this->key_option_name = $key_option_name;
218 }
219
220 /**
221 * Method to get the remote key
222 *
223 * @return array
224 */
225 public function get_key_remote() {
226 if (empty($this->key_remote) && $this->can_generate) {
227 $this->generate_new_keypair();
228 }
229
230 return empty($this->key_remote) ? false : $this->key_remote;
231 }
232
233 /**
234 * Set the remote key
235 *
236 * @param string $key_remote
237 */
238 public function set_key_remote($key_remote) {
239 $this->key_remote = $key_remote;
240 }
241
242 /**
243 * Used for sending - when receiving, the format is part of the message
244 *
245 * @param integer $format
246 */
247 public function set_message_format($format = 2) {
248 $this->format = $format;
249 }
250
251 /**
252 * Method to get the local key
253 *
254 * @return array
255 */
256 public function get_key_local() {
257 if (empty($this->key_local)) {
258 if ($this->key_option_name) {
259 $key_local = get_site_option($this->key_option_name);
260 if ($key_local) {
261 $this->key_local = $key_local;
262 }
263 }
264 }
265 if (empty($this->key_local) && $this->can_generate) {
266 $this->generate_new_keypair();
267 }
268
269 return empty($this->key_local) ? false : $this->key_local;
270 }
271
272 /**
273 * Tests whether a supplied string (after trimming) is a valid portable bundle
274 *
275 * @param string $bundle [description]
276 * @param string $format same as get_portable_bundle()
277 * @return array (which the consumer is free to use - e.g. convert into internationalised string), with keys 'code' and (perhaps) 'data'
278 */
279 public function decode_portable_bundle($bundle, $format = 'raw') {
280 $bundle = trim($bundle);
281 if ('base64_with_count' == $format) {
282 if (strlen($bundle) < 5) return array('code' => 'invalid_wrong_length', 'data' => 'too_short');
283 $len = substr($bundle, 0, 4);
284 $bundle = substr($bundle, 4);
285 $len = hexdec($len);
286 if (strlen($bundle) != $len) return array('code' => 'invalid_wrong_length', 'data' => "1,$len,".strlen($bundle));
287 if (false === ($bundle = base64_decode($bundle))) return array('code' => 'invalid_corrupt', 'data' => 'not_base64');
288 if (null === ($bundle = json_decode($bundle, true))) return array('code' => 'invalid_corrupt', 'data' => 'not_json');
289 }
290 if (empty($bundle['key'])) return array('code' => 'invalid_corrupt', 'data' => 'no_key');
291 if (empty($bundle['url'])) return array('code' => 'invalid_corrupt', 'data' => 'no_url');
292 if (empty($bundle['name_indicator'])) return array('code' => 'invalid_corrupt', 'data' => 'no_name_indicator');
293
294 return $bundle;
295 }
296
297 /**
298 * Method to get a portable bundle sufficient to contact this site (i.e. remote site - so you need to have generated a key-pair, or stored the remote key somewhere and restored it)
299 *
300 * @param string $format Supported formats: base64_with_count and default)raw
301 * @param array $extra_info needs to be JSON-serialisable, so be careful about what you put into it.
302 * @param array $options [description]
303 * @return array
304 */
305 public function get_portable_bundle($format = 'raw', $extra_info = array(), $options = array()) {
306
307 $bundle = array_merge($extra_info, array(
308 'key' => empty($options['key']) ? $this->get_key_remote() : $options['key'],
309 'name_indicator' => $this->key_name_indicator,
310 'url' => trailingslashit(network_site_url()),
311 'admin_url' => trailingslashit(admin_url()),
312 'network_admin_url' => trailingslashit(network_admin_url()),
313 ));
314
315 if ('base64_with_count' == $format) {
316 $bundle = base64_encode(json_encode($bundle));
317
318 $len = strlen($bundle); // Get the length
319 $len = dechex($len); // The first bytes of the message are the bundle length
320 $len = str_pad($len, 4, '0', STR_PAD_LEFT); // Zero pad
321
322 return $len.$bundle;
323
324 } else {
325 return $bundle;
326 }
327
328 }
329
330 public function set_key_local($key_local) {
331 $this->key_local = $key_local;
332 if ($this->key_option_name) update_site_option($this->key_option_name, $this->key_local);
333 }
334
335 public function generate_new_keypair($key_size = 2048) {
336
337 $this->ensure_crypto_loaded();
338
339 $rsa = new Crypt_RSA();
340 $keys = $rsa->createKey($key_size);
341
342 if (empty($keys['privatekey'])) {
343 $this->set_key_local(false);
344 } else {
345 $this->set_key_local($keys['privatekey']);
346 }
347
348 if (empty($keys['publickey'])) {
349 $this->set_key_remote(false);
350 } else {
351 $this->set_key_remote($keys['publickey']);
352 }
353
354 return empty($keys['publickey']) ? false : true;
355 }
356
357 /**
358 * A base-64 encoded RSA hash (PKCS_1) of the message digest
359 *
360 * @param string $message
361 * @param boolean $use_key
362 * @return array
363 */
364 public function signature_for_message($message, $use_key = false) {
365
366 $hash_algorithm = 'sha256';
367
368 // Sign with the private (local) key
369 if (!$use_key) {
370 if (!$this->key_local) throw new Exception('No signing key has been set');
371 $use_key = $this->key_local;
372 }
373
374 $this->ensure_crypto_loaded();
375
376 $rsa = new Crypt_RSA();
377 $rsa->loadKey($use_key);
378 // This is the older signature mode; phpseclib's default is the preferred CRYPT_RSA_SIGNATURE_PSS; however, Forge JS doesn't yet support this. More info: https://en.wikipedia.org/wiki/PKCS_1
379 $rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1);
380
381 // Don't do this: Crypt_RSA::sign() already calculates the digest of the hash
382 // $hash = new Crypt_Hash($hash_algorithm);
383 // $hashed = $hash->hash($message);
384
385 // if ($this->debug) $this->log("Message hash (hash=$hash_algorithm) (hex): ".bin2hex($hashed));
386
387 // phpseclib defaults to SHA1
388 $rsa->setHash($hash_algorithm);
389 $encrypted = $rsa->sign($message);
390
391 if ($this->debug) $this->log('Signed hash (mode='.CRYPT_RSA_SIGNATURE_PKCS1.') (hex): '.bin2hex($encrypted));
392
393 $signature = base64_encode($encrypted);
394
395 if ($this->debug) $this->log("Message signature (base64): $signature");
396
397 return $signature;
398 }
399
400 /**
401 * Log description
402 *
403 * @param string $message
404 * @param string $level $level is not yet used much
405 */
406 private function log($message, $level = 'notice') {
407 // Allow other plugins to do something with the message
408 do_action('udrpc_log', $message, $level, $this->key_name_indicator, $this->debug, $this);
409 if ('info' != $level) error_log('UDRPC ('.$this->key_name_indicator.", $level): $message");
410 }
411
412 /**
413 * Encrypt the message, using the local key (which needs to exist)
414 *
415 * @param string $plaintext
416 * @param boolean $use_key
417 * @param integer $key_length
418 * @return array
419 */
420 public function encrypt_message($plaintext, $use_key = false, $key_length = 32) {
421
422 if (!$use_key) {
423 if (1 == $this->format) {
424 if (!$this->key_local) throw new Exception('No encryption key has been set');
425 $use_key = $this->key_local;
426 } else {
427 if (!$this->key_remote) throw new Exception('No encryption key has been set');
428 $use_key = $this->key_remote;
429 }
430 }
431
432 $this->ensure_crypto_loaded();
433
434 $rsa = new Crypt_RSA();
435
436 if (defined('UDRPC_PHPSECLIB_ENCRYPTION_MODE')) $rsa->setEncryptionMode(UDRPC_PHPSECLIB_ENCRYPTION_MODE);
437
438 $rij = new Crypt_Rijndael();
439
440 // Generate Random Symmetric Key
441 $sym_key = crypt_random_string($key_length);
442
443 if ($this->debug) $this->log('Unencrypted symmetric key (hex): '.bin2hex($sym_key));
444
445 // Encrypt Message with new Symmetric Key
446 $rij->setKey($sym_key);
447 $ciphertext = $rij->encrypt($plaintext);
448
449 if ($this->debug) $this->log('Encrypted ciphertext (hex): '.bin2hex($ciphertext));
450
451 $ciphertext = base64_encode($ciphertext);
452
453 // Encrypt the Symmetric Key with the Asymmetric Key
454 $rsa->loadKey($use_key);
455 $sym_key = $rsa->encrypt($sym_key);
456
457 if ($this->debug) $this->log('Encrypted symmetric key (hex): '.bin2hex($sym_key));
458
459 // Base 64 encode the symmetric key for transport
460 $sym_key = base64_encode($sym_key);
461
462 if ($this->debug) $this->log('Encrypted symmetric key (b64): '.$sym_key);
463
464 $len = str_pad(dechex(strlen($sym_key)), 3, '0', STR_PAD_LEFT); // Zero pad to be sure.
465
466 // 16 characters of hex is enough for the payload to be to 16 exabytes (giga < tera < peta < exa) of data
467 $cipherlen = str_pad(dechex(strlen($ciphertext)), 16, '0', STR_PAD_LEFT);
468
469 // Concatenate the length, the encrypted symmetric key, and the message
470 return $len.$sym_key.$cipherlen.$ciphertext;
471
472 }
473
474 /**
475 * Decrypt the message, using the local key (which needs to exist)
476 *
477 * @param string $message
478 * @return array
479 */
480 public function decrypt_message($message) {
481
482 if (!$this->key_local) throw new Exception('No decryption key has been set');
483
484 $this->ensure_crypto_loaded();
485
486 $rsa = new Crypt_RSA();
487 if (defined('UDRPC_PHPSECLIB_ENCRYPTION_MODE')) $rsa->setEncryptionMode(UDRPC_PHPSECLIB_ENCRYPTION_MODE);
488 // Defaults to CRYPT_AES_MODE_CBC
489 $rij = new Crypt_Rijndael();
490
491 // Extract the Symmetric Key
492 $len = substr($message, 0, 3);
493 $len = hexdec($len);
494 $sym_key = substr($message, 3, $len);
495
496 // Extract the encrypted message
497 $cipherlen = substr($message, ($len + 3), 16);
498 $cipherlen = hexdec($cipherlen);
499
500 $ciphertext = substr($message, ($len + 19), $cipherlen);
501 $ciphertext = base64_decode($ciphertext);
502
503 // Decrypt the encrypted symmetric key
504 $rsa->loadKey($this->key_local);
505 $sym_key = base64_decode($sym_key);
506 $sym_key = $rsa->decrypt($sym_key);
507
508 // Decrypt the message
509 $rij->setKey($sym_key);
510
511 return $rij->decrypt($ciphertext);
512
513 }
514
515 /**
516 * Creates a message
517 *
518 * @param string $command
519 * @param string $data
520 * @param boolean $is_response
521 * @param boolean $use_key_remote
522 * @param boolean $use_key_local
523 * @return array which the caller will then format as required (e.g. use as body in post, or JSON-encode, etc.) [description]
524 */
525 public function create_message($command, $data = null, $is_response = false, $use_key_remote = false, $use_key_local = false) {
526
527 if ($is_response) {
528 $send_array = array('response' => $command);
529 } else {
530 $send_array = array('command' => $command);
531 }
532
533 $send_array['time'] = time();
534 // This goes in the encrypted portion as well to prevent replays with a different unencrypted name indicator
535 $send_array['key_name'] = $this->key_name_indicator;
536
537 // This random element means that if the site needs to send two identical commands or responses in the same second, then it can, and still use replay protection
538 // The value of PHP_INT_MAX on a 32-bit platform
539 $this->message_random_number = rand(1, 2147483647);
540 $send_array['rand'] = $this->message_random_number;
541
542 if ($this->next_send_sequence_id) {
543 $send_array['sequence_id'] = $this->next_send_sequence_id;
544 ++$this->next_send_sequence_id;
545 }
546
547 if ($is_response && !empty($this->incoming_message) && isset($this->incoming_message['rand'])) {
548 $send_array['incoming_rand'] = $this->incoming_message['rand'];
549 }
550
551 if (null !== $data) $send_array['data'] = $data;
552 $send_data = $this->encrypt_message(json_encode($send_array), $use_key_remote);
553
554 $message = array(
555 'format' => $this->format,
556 'key_name' => $this->key_name_indicator,
557 'udrpc_message' => $send_data,
558 );
559
560 if ($this->format >= 2) {
561 $signature = $this->signature_for_message($send_data, $use_key_local);
562 $message['signature'] = $signature;
563 }
564
565 return $message;
566
567 }
568
569 /**
570 * N.B. There's already some time-based replay protection. This can be turned on to beef it up.
571 * This is only for listeners. Replays can only be detection if transients are working on the WP site (which by default only means that the option table is working).
572 *
573 * @param boolean $activate
574 */
575 public function activate_replay_protection($activate = true) {
576 $this->extra_replay_protection = (bool) $activate;
577 }
578
579 public function set_next_send_sequence_id($id) {
580 $this->next_send_sequence_id = $id;
581 }
582
583 /**
584 * Set_http_credentials
585 *
586 * @param string $credentials should be an array with entries for 'username' and 'password'
587 */
588 public function set_http_credentials($credentials) {
589 $this->http_credentials = $credentials;
590 }
591
592 /**
593 * This needs only to return an array with keys body and response - where response is also an array, with key 'code' (the HTTP status code)
594 * The $post_options array support these keys: timeout, body,
595 * Public, to allow short-circuiting of the library's own encoding/decoding (e.g. for acting as a proxy for a message already encrypted elsewhere)
596 *
597 * @param array $post_options
598 * @return array
599 */
600 public function http_post($post_options) {
601 // @codingStandardsIgnoreLine
602 @include ABSPATH.WPINC.'/version.php';
603 $http_credentials = $this->http_credentials;
604
605 if (is_a($this->http_transport, 'GuzzleHttp\Client')) {
606
607 // https://guzzle.readthedocs.org/en/5.3/clients.html
608
609 $client = $this->http_transport;
610
611 $guzzle_options = array(
612 'body' => $post_options['body'],
613 'headers' => array(
614 'User-Agent' => 'WordPress/'.$wp_version.'; class-udrpc.php-Guzzle/'.$this->version.'; '.get_bloginfo('url'),
615 ),
616 'exceptions' => false,
617 'timeout' => $post_options['timeout'],
618 );
619
620 if (!class_exists('WP_HTTP_Proxy')) include_once ABSPATH.WPINC.'/class-http.php';
621 $proxy = new WP_HTTP_Proxy();
622 if ($proxy->is_enabled()) {
623 $user = $proxy->username();
624 $pass = $proxy->password();
625 $host = $proxy->host();
626 $port = (int) $proxy->port();
627 if (empty($port)) $port = 8080;
628 if (!empty($host) && $proxy->send_through_proxy($this->destination_url)) {
629 $proxy_auth = '';
630 if (!empty($user)) {
631 $proxy_auth = $user;
632 if (!empty($pass)) $proxy_auth .= ':'.$pass;
633 $proxy_auth .= '@';
634 }
635 $guzzle_options['proxy'] = array(
636 'http' => "http://${proxy_auth}$host:$port",
637 'https' => "http://${proxy_auth}$host:$port",
638 );
639 }
640 }
641
642 if (defined('UDRPC_GUZZLE_SSL_VERIFY')) {
643 $verify = UDRPC_GUZZLE_SSL_VERIFY;
644 } elseif (file_exists(ABSPATH.WPINC.'/certificates/ca-bundle.crt')) {
645 $verify = ABSPATH.WPINC.'/certificates/ca-bundle.crt';
646 } else {
647 $verify = true;
648 }
649 $guzzle_options['verify'] = apply_filters('udrpc_guzzle_verify', $verify);
650
651 if (!empty($http_credentials['username'])) {
652
653 $authentication_method = empty($http_credentials['authentication_method']) ? 'basic' : $http_credentials['authentication_method'];
654
655 $password = empty($http_credentials['password']) ? '' : $http_credentials['password'];
656
657 $guzzle_options['auth'] = array(
658 $http_credentials['username'],
659 $password,
660 $authentication_method,
661 );
662
663 }
664
665 $response = $client->post($this->destination_url, apply_filters('udrpc_guzzle_options', $guzzle_options, $this));
666
667 $formatted_response = array(
668 'response' => array(
669 'code' => $response->getStatusCode(),
670 ),
671 'body' => $response->getBody(),
672 );
673
674 return $formatted_response;
675
676 } else {
677
678 $post_options['user-agent'] = 'WordPress/'.$wp_version.'; class-udrpc.php/'.$this->version.'; '.get_bloginfo('url');
679
680 if (!empty($http_credentials['username'])) {
681
682 $authentication_type = empty($http_credentials['authentication_type']) ? 'basic' : $http_credentials['authentication_type'];
683
684 if ('basic' != $authentication_type) {
685 return new WP_Error('unsupported_http_authentication_type', 'Only HTTP basic authentication is supported (for other types, use Guzzle)');
686 }
687
688 $password = empty($http_credentials['password']) ? '' : $http_credentials['password'];
689 $post_options['headers'] = array(
690 'Authorization' => 'Basic '.base64_encode($http_credentials['username'].':'.$password),
691 );
692 }
693
694 return wp_remote_post(
695 $this->destination_url,
696 $post_options
697 );
698 }
699 }
700
701 public function send_message($command, $data = null, $timeout = 20) {
702
703 if (empty($this->destination_url)) return new WP_Error('not_initialised', 'RPC error: URL not initialised');
704
705 $message = $this->create_message($command, $data);
706
707 $post_options = array(
708 'timeout' => $timeout,
709 'body' => $message,
710 );
711
712 $post_options = apply_filters('udrpc_post_options', $post_options, $command, $data, $timeout, $this);
713
714 try {
715 $post = $this->http_post($post_options);
716 } catch (Exception $e) {
717 // Curl can return an error code 0, which causes WP_Error to return early, without recording the message. So, we prefix the code.
718 return new WP_Error('http_post_'.$e->getCode(), $e->getMessage());
719 }
720
721 if (is_wp_error($post)) return $post;
722
723 $response_code = wp_remote_retrieve_response_code($post);
724
725 if (empty($response_code)) return new WP_Error('empty_http_code', 'Unexpected HTTP response code');
726
727 if ($response_code < 200 || $response_code >= 300) return new WP_Error('unexpected_http_code', 'Unexpected HTTP response code ('.$response_code.')', $post);
728
729 $response_body = wp_remote_retrieve_body($post);
730
731 if (empty($response_body)) return new WP_Error('empty_response', 'Empty response from remote site');
732
733 $decoded = json_decode($response_body, true);
734
735 if (empty($decoded)) {
736
737 if (false != ($found_at = strpos($response_body, '{"format":'))) {
738 $new_body = substr($response_body, $found_at);
739 $decoded = json_decode($new_body, true);
740 }
741
742 if (empty($decoded)) {
743 $this->log('response from remote site could not be understood: '.substr($response_body, 0, 100).' ... ');
744
745 return new WP_Error('response_not_understood', 'Response from remote site could not be understood', $response_body);
746 }
747 }
748
749 if (!is_array($decoded) || empty($decoded['udrpc_message'])) return new WP_Error('response_not_understood', 'Response from remote site was not in the expected format ('.$post['body'].')', $decoded);
750
751 if ($this->format >= 2) {
752 if (empty($decoded['signature'])) {
753 $this->log('No message signature found');
754 die;
755 }
756 if (!$this->key_remote) {
757 $this->log('No signature verification key has been set');
758 die;
759 }
760 if (!$this->verify_signature($decoded['udrpc_message'], $decoded['signature'], $this->key_remote)) {
761 $this->log('Signature verification failed; discarding');
762 die;
763 }
764 }
765
766 $decoded = $this->decrypt_message($decoded['udrpc_message']);
767
768 if (!is_string($decoded)) return new WP_Error('not_decrypted', 'Response from remote site was not successfully decrypted', $decoded['udrpc_message']);
769
770 $json_decoded = json_decode($decoded, true);
771
772 if (!is_array($json_decoded) || empty($json_decoded['response']) || empty($json_decoded['time']) || !is_numeric($json_decoded['time'])) return new WP_Error('response_corrupt', 'Response from remote site was not in the expected format', $decoded);
773
774 // Don't do the reply detection until now, because $post['body'] may not be a message that originated from the remote component at all (e.g. an HTTP error)
775 if ($this->extra_replay_protection) {
776 $message_hash = $this->calculate_message_hash((string) $post['body']);
777 if ($this->message_hash_seen($message_hash)) {
778 return new WP_Error('replay_detected', 'Message refused: replay detected', $message_hash);
779 }
780 }
781
782 $time_difference = absint((time() - $json_decoded['time']));
783 if ($time_difference > $this->maximum_replay_time_difference) return new WP_Error('window_error', 'Message refused: maxium replay time difference exceeded', $time_difference);
784
785 if (isset($json_decoded['incoming_rand']) && !empty($this->message_random_number) && $json_decoded['incoming_rand'] != $this->message_random_number) {
786 // @codingStandardsIgnoreLine
787 $this->log('UDRPC: Message mismatch (possibly MITM) (sent_rand=' + $this->message_random_number + ', returned_rand='.$json_decoded['incoming_rand'].'): dropping', 'error');
788
789 return new WP_Error('message_mismatch_error', 'Message refused: message mismatch (possible MITM)');
790
791 }
792
793 // Should be an array with keys including 'response' and (if relevant) 'data'
794 return $json_decoded;
795
796 }
797
798 /**
799 * Returns a boolean indicating whether a listener was created - which depends on whether one was needed (so, false does not necessarily indicate an error condition)
800 *
801 * @return boolean
802 */
803 public function create_listener() {
804
805 $http_origin = function_exists('get_http_origin') ? get_http_origin() : (empty($_SERVER['HTTP_ORIGIN']) ? '' : $_SERVER['HTTP_ORIGIN']);
806
807 // Create the WP actions to handle incoming commands, handle built-in commands (e.g. ping, create_keys (authenticate with admin creds)), dispatch them to the right place, and die
808 if ((!empty($_POST) && !empty($_POST['udrpc_message']) && !empty($_POST['format'])) || (!empty($_SERVER['REQUEST_METHOD']) && 'OPTIONS' == $_SERVER['REQUEST_METHOD'] && $http_origin)) {
809 add_action('wp_loaded', array($this, 'wp_loaded'));
810 add_action('wp_loaded', array($this, 'wp_loaded_final'), 10000);
811 return true;
812 }
813
814 return false;
815 }
816
817 public function wp_loaded_final() {
818 if (empty($this->require_message_to_be_understood)) return;
819 $message_for = empty($_POST['key_name']) ? '' : (string) $_POST['key_name'];
820 $this->log("Message was received, but not understood by local site (for: $message_for)");
821 die;
822 }
823
824 public function wp_loaded() {
825
826 /*
827 // What if something else already set some response headers?
828 if (function_exists('apache_response_headers')) {
829 $apache_response_headers = apache_response_headers();
830 // Do something...
831 }
832 */
833
834 // CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
835 // get_http_origin() : since WP 3.4
836 $http_origin = function_exists('get_http_origin') ? get_http_origin() : (empty($_SERVER['HTTP_ORIGIN']) ? '' : $_SERVER['HTTP_ORIGIN']);
837 if (!empty($_SERVER['REQUEST_METHOD']) && 'OPTIONS' == $_SERVER['REQUEST_METHOD'] && $http_origin) {
838 if (in_array($http_origin, $this->allow_cors_from)) {
839 // @codingStandardsIgnoreLine
840 if (!@constant('UDRPC_DO_NOT_SEND_CORS_HEADERS')) {
841 header("Access-Control-Allow-Origin: $http_origin");
842 header('Access-Control-Allow-Credentials: true');
843 if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) header('Access-Control-Allow-Methods: POST, OPTIONS');
844 if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) header('Access-Control-Allow-Headers: '.$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']);
845 }
846 die;
847 } elseif ($this->debug) {
848 $this->log('Non-allowed CORS from: '.$http_origin);
849 }
850 // Having detected that this is a CORS request, there's nothing more to do. We return, because a different listener might pick it up, even though we didn't.
851 return;
852 }
853
854 // Silently return, rather than dying, in case another instance is able to handle this
855 if (empty($_POST['format']) || (1 != $_POST['format'] && 2 != $_POST['format'])) return;
856
857 $this->require_message_to_be_understood = true;
858
859 $format = $_POST['format'];
860
861 /*
862 In format 1 (legacy/obsolete), the one encrypts (the shared AES key) using one half of the key-pair, and decrypts with the other; whereas the other side of the conversation does the reverse when replying (and uses a different shared AES key). Though this is possible in RSA, this is the wrong thing to do - see https://crypto.stackexchange.com/questions/2123/rsa-encryption-with-private-key-and-decryption-with-a-public-key
863 In format 2, both sides have their own private and public key. The sender encrypts using the other side's public key, and decrypts using its own private key. Messages are signed (the message digest is SHA-256).
864 */
865
866 // Is this for us?
867 if (empty($_POST['key_name']) || $_POST['key_name'] != $this->key_name_indicator) {
868 return;
869 }
870
871 // wp_unslash() does not exist until after WP 3.5
872 // $udrpc_message = function_exists('wp_unslash') ? wp_unslash($_POST['udrpc_message']) : stripslashes_deep($_POST['udrpc_message']);
873
874 // Data should not have any slashes - it is base64-encoded
875 $udrpc_message = (string) $_POST['udrpc_message'];
876
877 // Check this now, rather than allow the decrypt method to thrown an Exception
878
879 if (empty($this->key_local)) {
880 $this->log('no local key (format 1): cannot decrypt', 'error');
881 die;
882 }
883
884 if ($format >= 2) {
885 if (empty($_POST['signature'])) {
886 $this->log('No message signature found', 'error');
887 die;
888 }
889 if (!$this->key_remote) {
890 $this->log('No signature verification key has been set', 'error');
891 die;
892 }
893 if (!$this->verify_signature($udrpc_message, $_POST['signature'], $this->key_remote)) {
894 $this->log('Signature verification failed; discarding', 'error');
895 die;
896 }
897 }
898
899 try {
900 $udrpc_message = $this->decrypt_message($udrpc_message);
901 } catch (Exception $e) {
902 $this->log('Exception ('.get_class($e).'): '.$e->getMessage(), 'error');
903 die;
904 }
905
906 $udrpc_message = json_decode($udrpc_message, true);
907
908 if (empty($udrpc_message) || !is_array($udrpc_message) || empty($udrpc_message['command']) || !is_string($udrpc_message['command'])) {
909 $this->log('Could not decode JSON on incoming message', 'error');
910 die;
911 }
912
913 if (empty($udrpc_message['time'])) {
914 $this->log('No time set in incoming message', 'error');
915 die;
916 }
917
918 // Mismatch indicating a replay of the message with a different key name in the unencrypted portion?
919 if (empty($udrpc_message['key_name']) || $_POST['key_name'] != $udrpc_message['key_name']) {
920 $this->log('key_name mismatch between encrypted and unencrypted portions', 'error');
921 die;
922 }
923
924 if ($this->extra_replay_protection) {
925 $message_hash = $this->calculate_message_hash((string) $_POST['udrpc_message']);
926 if ($this->message_hash_seen($message_hash)) {
927 $this->log("Message dropped: apparently a replay (hash: $message_hash)", 'error');
928 die;
929 }
930 }
931
932 // Do this after the extra replay protection, as that checks hashes within the maximum time window - so don't check the maximum time window until afterwards, to avoid a tiny window (race) in between.
933 $time_difference = absint(($udrpc_message['time'] - time()));
934 if ($time_difference > $this->maximum_replay_time_difference) {
935 $this->log("Time in incoming message is outside of allowed window ($time_difference > ".$this->maximum_replay_time_difference.')', 'error');
936 die;
937 }
938
939 // The sequence number should always be larger than any previously-sent sequence number
940 if ($this->sequence_protection_tolerance) {
941
942 if ($this->debug) $this->log('Sequence protection is active; tolerance: '.$this->sequence_protection_tolerance);
943
944 global $wpdb;
945
946 if (!isset($udrpc_message['sequence_id']) || !is_numeric($udrpc_message['sequence_id'])) {
947 $this->log('a numerical sequence number is required, but none was included in the message - dropping', 'error');
948 die;
949 }
950
951 $message_sequence_id = (int) $udrpc_message['sequence_id'];
952 $recently_seen_sequences_ids = $wpdb->get_var($wpdb->prepare('SELECT %s FROM %s LIMIT 1 WHERE '.$this->sequence_protection_where_sql, $this->sequence_protection_column, $this->sequence_protection_table));
953
954 if ('' === $recently_seen_sequences_ids) $recently_seen_sequences_ids = '0';
955
956 $recently_seen_sequences_ids_as_array = explode($recently_seen_sequences_ids, ',');
957 sort($recently_seen_sequences_ids_as_array);
958
959 // Seen before?
960 if (in_array($message_sequence_id, $recently_seen_sequences_ids_as_array)) {
961 $this->log("message with duplicate sequence number received - dropping (received=$message_sequence_id, seen=$recently_seen_sequences_ids)");
962 die;
963 }
964
965 // Within the tolerance threshold? That means: a) either bigger than the max, or b) no more than <tolerance> lower than the least
966 if ($message_sequence_id > max($recently_seen_sequences_ids)) {
967 if ($this->debug) $this->log("Sequence id ($message_sequence_id) is greater than any previous (".max($recently_seen_sequences_ids).') - message is thus OK');
968 // All is well
969 $recently_seen_sequences_ids_as_array[] = $message_sequence_id;
970 } elseif ((max($recently_seen_sequences_ids) - $message_sequence_id) <= $this->sequence_protection_tolerance) {
971 // All is well - was one of those 'missing' in the sequence
972 if ($this->debug) $this->log("Sequence id ($message_sequence_id) is within tolerance range of previous maximum (".max($recently_seen_sequences_ids).') - message is thus OK');
973 $recently_seen_sequences_ids_as_array[] = $message_sequence_id;
974 } else {
975 $this->log("message received outside of allowed sequence window - dropping (received=$message_sequence_id, seen=$recently_seen_sequences_ids, tolerance=".$this->sequence_protection_tolerance.')', 'error');
976 die;
977 }
978
979 // Remove out-of-bounds seen IDs
980 $max_sequence_id_seen = max($recently_seen_sequences_ids_as_array);
981 foreach ($recently_seen_sequences_ids_as_array as $k => $id) {
982 if ($max_sequence_id_seen - $id > $this->sequence_protection_tolerance) {
983 if ($this->debug) $this->log("Removing no-longer-relevant sequence from list of those recently seen: $id");
984 unset($recently_seen_sequences_ids_as_array[$k]);
985 }
986 }
987
988 // Allow reset
989 if ($current_sequence_id > PHP_INT_MAX - 10) {
990 $recently_seen_sequences_ids_as_array = array(0);
991 }
992
993 // Write them back to the database
994 $sql = $wpdb->prepare('UPDATE %s SET %s=%s WHERE '.$this->sequence_protection_where_sql, $this->sequence_protection_table, $this->sequence_protection_column, implode(',', $recently_seen_sequences_ids_as_array));
995 if ($this->debug) $this->log("SQL to send recent sequence IDs back to the database: $sql");
996 $wpdb->query($sql);
997
998 }
999
1000 $this->incoming_message = $udrpc_message;
1001
1002 $command = (string) $udrpc_message['command'];
1003 $data = empty($udrpc_message['data']) ? null : $udrpc_message['data'];
1004
1005 // @codingStandardsIgnoreLine
1006 if ($http_origin && !empty($udrpc_message['cors_headers_wanted']) && !@constant('UDRPC_DO_NOT_SEND_CORS_HEADERS')) {
1007 header("Access-Control-Allow-Origin: $http_origin");
1008 header('Access-Control-Allow-Credentials: true');
1009 }
1010
1011 $this->log('Command received: '.$command, 'info');
1012
1013 if ('ping' == $command) {
1014 echo json_encode($this->create_message('pong', null, true));
1015 } else {
1016 if (has_filter('udrpc_command_'.$command)) {
1017 $command_action_hooked = true;
1018 $response = apply_filters('udrpc_command_'.$command, null, $data, $this->key_name_indicator);
1019 } else {
1020 $response = array('response' => 'rpcerror', 'data' => array('code' => 'unknown_rpc_command', 'data' => $command));
1021 }
1022
1023 $response = apply_filters('udrpc_action', $response, $command, $data, $this->key_name_indicator, $this);
1024
1025 if (is_array($response)) {
1026
1027 if ($this->debug) {
1028 $this->log('UDRPC response (pre-encoding/encryption): '.serialize($response));
1029 }
1030
1031 $data = isset($response['data']) ? $response['data'] : null;
1032 echo json_encode($this->create_message($response['response'], $data, true));
1033 }
1034
1035 }
1036
1037 die;
1038
1039 }
1040
1041 /**
1042 * The hash needs to be in a format that phpseclib likes. phpseclib uses lower case.
1043 * Pass in a base64-encoded signature (i.e. just as signature_for_message creates)
1044 *
1045 * @param string $message
1046 * @param string $signature
1047 * @param string $key
1048 * @param string $hash_algorithm
1049 * @return boolean
1050 */
1051 public function verify_signature($message, $signature, $key, $hash_algorithm = 'sha256') {
1052 $this->ensure_crypto_loaded();
1053 $rsa = new Crypt_RSA();
1054 $rsa->setHash(strtolower($hash_algorithm));
1055 // This is not the default, but is what we use
1056 $rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1);
1057 $rsa->loadKey($key);
1058
1059 // Don't hash it - Crypt_RSA::verify() already does that
1060 // $hash = new Crypt_Hash($hash_algorithm);
1061 // $hashed = $hash->hash($message);
1062
1063 $verified = $rsa->verify($message, base64_decode($signature));
1064
1065 if ($this->debug) $this->log('Signature verification result: '.serialize($verified));
1066
1067 return $verified;
1068 }
1069
1070 private function calculate_message_hash($message) {
1071 return hash('sha256', $message);
1072 }
1073
1074 private function message_hash_seen($message_hash) {
1075 // 39 characters - less than the WP site transient name limit (40). Though, we use a normal transient, as these don't auto-load at all times.
1076 $transient_name = 'udrpch_'.md5($this->key_name_indicator);
1077 $seen_hashes = get_transient($transient_name);
1078 if (!is_array($seen_hashes)) $seen_hashes = array();
1079 $time_now = time();
1080 // $any_changes = false;
1081 // Prune the old hashes
1082 foreach ($seen_hashes as $hash => $last_seen) {
1083 if ($last_seen < ($time_now - $this->maximum_replay_time_difference)) {
1084 // $any_changes = true;
1085 unset($seen_hashes[$hash]);
1086 }
1087 }
1088 if (isset($seen_hashes[$message_hash])) {
1089 return true;
1090 }
1091 $seen_hashes[$message_hash] = $time_now;
1092 set_transient($transient_name, $seen_hashes, $this->maximum_replay_time_difference);
1093
1094 return false;
1095 }
1096 }
1097
1098 endif;
1099