PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-waf / src / class-brute-force-protection.php

class-brute-force-protection.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at jetpack_vendor/automattic/jetpack-waf/src/class-brute-force-protection.php

1,200 lines 33.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class used to define Brute Force Protection.
4 *
5 * @package automattic/jetpack-waf
6 */
7
8 namespace Automattic\Jetpack\Waf\Brute_Force_Protection;
9
10 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11 use Automattic\Jetpack\Constants;
12 use Automattic\Jetpack\CookieState;
13 use Automattic\Jetpack\IP\Utils as IP_Utils;
14 use Automattic\Jetpack\Modules;
15 use Automattic\Jetpack\Waf\Waf_Compatibility;
16 use Automattic\Jetpack\Waf\Waf_Constants;
17 use Automattic\Jetpack\Waf\Waf_Rules_Manager;
18 use Jetpack_IXR_Client;
19 use Jetpack_Options;
20 use WP_Error;
21
22 /**
23 * Brute Force Protection class.
24 *
25 * @phan-constructor-used-for-side-effects
26 */
27 class Brute_Force_Protection {
28
29 /**
30 * Instance of the class.
31 *
32 * @var Brute_Force_Protection
33 */
34 private static $instance = null;
35
36 /**
37 * API Key.
38 *
39 * @var string
40 */
41 public $api_key;
42
43 /**
44 * API Key error.
45 *
46 * @var string
47 */
48 public $api_key_error;
49
50 /**
51 * IP allow list.
52 *
53 * @var array
54 */
55 public $allow_list;
56
57 /**
58 * IP allow list error.
59 *
60 * @var string
61 */
62 public $allow_list_error;
63
64 /**
65 * IP allow list saved
66 *
67 * @todo find out if this is even used.
68 *
69 * @var array
70 */
71 public $allow_list_saved;
72
73 /**
74 * The URI.
75 *
76 * @var string
77 */
78 private $local_host;
79
80 /**
81 * Last request.
82 *
83 * @todo find out if this is even used.
84 *
85 * @var string
86 */
87 public $last_request;
88
89 /**
90 * Response fetched from wp_remote_post()
91 *
92 * @var array
93 */
94 public $last_response_raw;
95
96 /**
97 * Last response.
98 *
99 * @todo find out if this is used.
100 * @var array
101 */
102 public $last_response;
103
104 /**
105 * Block login with math, default is 1.
106 *
107 * @var int
108 */
109 private $block_login_with_math;
110
111 /**
112 * Singleton implementation
113 *
114 * @return object
115 */
116 public static function instance() {
117 if ( ! is_a( self::$instance, 'Brute_Force_Protection' ) ) {
118 self::$instance = new Brute_Force_Protection();
119 }
120
121 return self::$instance;
122 }
123
124 /**
125 * Registers actions
126 */
127 private function __construct() {
128 // Older versions of Jetpack initialize brute force protection directly in the plugin.
129 // Return early to avoid running it twice.
130 if ( Waf_Compatibility::is_brute_force_running_in_jetpack() ) {
131 return;
132 }
133
134 add_action( 'jetpack_modules_loaded', array( $this, 'modules_loaded' ) );
135 add_action( 'login_form', array( $this, 'check_use_math' ), 0 );
136 add_filter( 'authenticate', array( $this, 'check_preauth' ), 10, 3 );
137 add_filter( 'jetpack_has_login_ability', array( $this, 'has_login_ability' ) );
138 add_action( 'wp_login', array( $this, 'log_successful_login' ), 10, 2 );
139 add_action( 'wp_login_failed', array( $this, 'log_failed_attempt' ), 10, 2 );
140 add_action( 'admin_init', array( $this, 'maybe_update_headers' ) );
141 add_action( 'admin_init', array( $this, 'maybe_display_security_warning' ) );
142
143 // This is a backup in case $pagenow fails for some reason.
144 add_action( 'login_form', array( $this, 'check_login_ability' ), 1 );
145
146 // Runs a script every day to clean up expired transients so they don't
147 // clog up our users' databases.
148 add_action( 'admin_init', array( '\Automattic\Jetpack\Waf\Brute_Force_Protection\Brute_Force_Protection_Transient_Cleanup', 'jp_purge_transients_activation' ) );
149 add_action( 'jp_purge_transients_cron', array( '\Automattic\Jetpack\Waf\Brute_Force_Protection\Brute_Force_Protection_Transient_Cleanup', 'jp_purge_transients' ) );
150
151 // Load math fallback after math page form submission.
152 if ( isset( $_POST['jetpack_protect_process_math_form'] ) ) {
153
154 new Brute_Force_Protection_Math_Authenticate();
155 }
156 }
157
158 /**
159 * Run brute force protection.
160 *
161 * @return void
162 */
163 public static function initialize() {
164 // Older versions of Jetpack initialize brute force protection directly in the plugin.
165 // Return early to avoid running it twice.
166 if ( Waf_Compatibility::is_brute_force_running_in_jetpack() ) {
167 return;
168 }
169
170 $brute_force_protection_is_enabled = self::is_enabled();
171 if ( $brute_force_protection_is_enabled && ( new Connection_Manager() )->is_connected() ) {
172 global $pagenow;
173 $brute_force_protection = self::instance();
174
175 if ( isset( $pagenow ) && 'wp-login.php' === $pagenow ) {
176 $brute_force_protection->check_login_ability();
177 }
178 }
179 }
180
181 /**
182 * On module activation, try to get an api key
183 */
184 public function on_activation() {
185 if ( is_multisite() && is_main_site() && get_site_option( 'jetpack_protect_active', 0 ) == 0 ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
186 update_site_option( 'jetpack_protect_active', 1 );
187 }
188
189 update_site_option( 'jetpack_protect_activating', 'activating' );
190
191 // Get BruteProtect's counter number.
192 $this->protect_call( 'check_key' );
193 }
194
195 /**
196 * On module deactivation, unset protect_active
197 */
198 public function on_deactivation() {
199 if ( is_multisite() && is_main_site() ) {
200 update_site_option( 'jetpack_protect_active', 0 );
201 }
202 }
203
204 /**
205 * Determines if the brute force protection module is enabled on the site.
206 *
207 * @return bool
208 */
209 public static function is_enabled() {
210 return ( new Modules() )->is_active( 'protect' );
211 }
212
213 /**
214 * Enables the brute force protection module.
215 *
216 * @return bool
217 */
218 public static function enable() {
219 // Return true if already enabled.
220 if ( self::is_enabled() ) {
221 return true;
222 }
223 return ( new Modules() )->activate( 'protect', false, false );
224 }
225
226 /**
227 * Disables the brute force protection module.
228 *
229 * @return bool
230 */
231 public static function disable() {
232 // Return true if already disabled.
233 if ( ! self::is_enabled() ) {
234 return true;
235 }
236 return ( new Modules() )->deactivate( 'protect' );
237 }
238
239 /**
240 * Get the protect key,
241 */
242 public function maybe_get_protect_key() {
243 if ( get_site_option( 'jetpack_protect_activating', false ) && ! get_site_option( 'jetpack_protect_key', false ) ) {
244 $key = $this->get_protect_key();
245
246 if ( ! empty( $key ) ) {
247 delete_site_option( 'jetpack_protect_activating' );
248 }
249
250 return $key;
251 }
252
253 return get_site_option( 'jetpack_protect_key' );
254 }
255
256 /**
257 * Sends a "check_key" API call once a day. This call allows us to track IP-related
258 * headers for this server via the Protect API, in order to better identify the source
259 * IP for login attempts
260 *
261 * @param bool $force - if we're forcing the request.
262 */
263 public function maybe_update_headers( $force = false ) {
264 $updated_recently = $this->get_transient( 'jpp_headers_updated_recently' );
265
266 if ( ! $force ) {
267 if ( isset( $_GET['protect_update_headers'] ) ) {
268 $force = true;
269 }
270 }
271
272 // check that current user is admin so we prevent a lower level user from adding
273 // a trusted header, allowing them to brute force an admin account.
274 if ( ( $updated_recently && ! $force ) || ! current_user_can( 'update_plugins' ) ) {
275 return;
276 }
277
278 $response = self::protect_call( 'check_key' );
279 $this->set_transient( 'jpp_headers_updated_recently', 1, DAY_IN_SECONDS );
280
281 if ( isset( $response['msg'] ) && $response['msg'] ) {
282 update_site_option( 'trusted_ip_header', json_decode( $response['msg'] ) );
283 }
284 }
285
286 /**
287 * Handle displaying a security warning.
288 */
289 public function maybe_display_security_warning() {
290 if ( is_multisite() && current_user_can( 'manage_network' ) ) {
291 if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
292 require_once ABSPATH . '/wp-admin/includes/plugin.php';
293 }
294
295 // This warning is only relevant if either Jetpack or Jetpack Protect is active.
296 if ( defined( 'JETPACK__PLUGIN_FILE' ) ) {
297 $plugin_root_file = JETPACK__PLUGIN_FILE;
298 } elseif ( defined( 'JETPACK_PROTECT_ROOT_FILE' ) ) {
299 $plugin_root_file = JETPACK_PROTECT_ROOT_FILE;
300 } else {
301 return;
302 }
303
304 if ( ! is_plugin_active_for_network( plugin_basename( $plugin_root_file ) ) ) {
305 add_action( 'load-index.php', array( $this, 'prepare_jetpack_protect_multisite_notice' ) );
306 add_action( 'wp_ajax_jetpack-protect-dismiss-multisite-banner', array( $this, 'ajax_dismiss_handler' ) );
307 }
308 }
309 }
310
311 /**
312 * Handles preparing the multisite notice.
313 */
314 public function prepare_jetpack_protect_multisite_notice() {
315 $dismissed = get_site_option( 'jetpack_dismissed_protect_multisite_banner' );
316 if ( $dismissed ) {
317 return;
318 }
319
320 add_action( 'admin_notices', array( $this, 'admin_jetpack_manage_notice' ) );
321 }
322
323 /**
324 * Handle dismissing the multisite banner.
325 */
326 public function ajax_dismiss_handler() {
327 check_ajax_referer( 'jetpack_protect_multisite_banner_opt_out' );
328
329 if ( ! current_user_can( 'manage_network' ) ) {
330 // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
331 wp_send_json_error( new WP_Error( 'insufficient_permissions' ), null, JSON_UNESCAPED_SLASHES );
332 }
333
334 update_site_option( 'jetpack_dismissed_protect_multisite_banner', true );
335
336 // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
337 wp_send_json_success( null, null, JSON_UNESCAPED_SLASHES );
338 }
339
340 /**
341 * Displays a warning about Brute Force Protection's network activation requirement.
342 * Attaches some custom JS to Core's `is-dismissible` UI to save the dismissed state.
343 */
344 public function admin_jetpack_manage_notice() {
345 ?>
346 <div class="jetpack-protect-warning notice notice-warning is-dismissible" data-dismiss-nonce="<?php echo esc_attr( wp_create_nonce( 'jetpack_protect_multisite_banner_opt_out' ) ); ?>">
347 <h2><?php esc_html_e( 'Brute Force Protection cannot keep your site secure', 'jetpack-waf' ); ?></h2>
348
349 <p>
350 <?php
351 printf(
352 /* Translators: placeholder is a plugin name (Jetpack Protect or Jetpack). */
353 esc_html__( 'Thanks for activating the Brute Force Protection feature! To start protecting your whole WordPress Multisite Network, please network activate the %1$s plugin. Due to the way logins are handled on WordPress Multisite Networks, %1$s must be network activated in order for the Brute Force Protection feature to work properly.', 'jetpack-waf' ),
354 defined( 'JETPACK_PROTECT_NAME' ) ? esc_html( JETPACK_PROTECT_NAME ) : 'Jetpack'
355 );
356 ?>
357 </p>
358
359 <p>
360 <a class="button-primary" href="<?php echo esc_url( network_admin_url( 'plugins.php' ) ); ?>">
361 <?php esc_html_e( 'View Network Admin', 'jetpack-waf' ); ?>
362 </a>
363 <a class="button" href="<?php echo esc_url( __( 'https://jetpack.com/support/multisite-protect', 'jetpack-waf' ) ); ?>" target="_blank">
364 <?php esc_html_e( 'Learn More', 'jetpack-waf' ); ?>
365 </a>
366 </p>
367 </div>
368 <script>
369 jQuery( function( $ ) {
370 $( '.jetpack-protect-warning' ).on( 'click', 'button.notice-dismiss', function( event ) {
371 event.preventDefault();
372
373 wp.ajax.post(
374 'jetpack-protect-dismiss-multisite-banner',
375 {
376 _wpnonce: $( event.delegateTarget ).data( 'dismiss-nonce' ),
377 }
378 ).fail( function( error ) {
379 <?php
380 // A failure here is really strange, and there's not really anything a site owner can do to fix one.
381 // Just log the error for now to help debugging.
382 ?>
383
384 if ( 'function' === typeof error.done && '-1' === error.responseText ) {
385 console.error( 'Notice dismissal failed: check_ajax_referer' );
386 } else {
387 console.error( 'Notice dismissal failed: ' + JSON.stringify( error ) );
388 }
389 } )
390 } );
391 } );
392 </script>
393 <?php
394 }
395
396 /**
397 * Gets all plugins currently active in values, regardless of whether they're
398 * traditionally activated or network activated.
399 *
400 * Forked from Jetpack::get_active_plugins from the Jetpack plugin.
401 *
402 * @return string[]
403 */
404 public static function get_active_plugins() {
405 $active_plugins = (array) get_option( 'active_plugins', array() );
406
407 if ( is_multisite() ) {
408 // Due to legacy code, active_sitewide_plugins stores them in the keys,
409 // whereas active_plugins stores them in the values.
410 $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) );
411 if ( $network_plugins ) {
412 $active_plugins = array_merge( $active_plugins, $network_plugins );
413 }
414 }
415
416 sort( $active_plugins );
417
418 return array_unique( $active_plugins );
419 }
420
421 /**
422 * Deactivate a plugin.
423 *
424 * @param string $probable_file Expected plugin file.
425 * @param string $probable_title Expected plugin title.
426 *
427 * @return void
428 */
429 public static function deactivate_plugin( $probable_file, $probable_title ) {
430 include_once ABSPATH . 'wp-admin/includes/plugin.php';
431 if ( is_plugin_active( $probable_file ) ) {
432 deactivate_plugins( $probable_file );
433 } else {
434 // If the plugin is not in the usual place, try looking through all active plugins.
435 $active_plugins = get_option( 'active_plugins' );
436 foreach ( $active_plugins as $plugin ) {
437 $data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin );
438 if ( $data['Name'] === $probable_title ) {
439 deactivate_plugins( $plugin );
440 }
441 }
442 }
443 }
444
445 /**
446 * Request an api key from wordpress.com
447 *
448 * @return bool | string
449 */
450 public function get_protect_key() {
451
452 $protect_blog_id = self::get_main_blog_jetpack_id();
453
454 // If we can't find the the blog id, that means we are on multisite, and the main site never connected
455 // the protect api key is linked to the main blog id - instruct the user to connect their main blog.
456 if ( ! $protect_blog_id ) {
457 $this->api_key_error = __( 'Your main blog is not connected to WordPress.com. Please connect to get an API key.', 'jetpack-waf' );
458
459 return false;
460 }
461
462 $request = array(
463 'jetpack_blog_id' => $protect_blog_id,
464 'bruteprotect_api_key' => get_site_option( 'bruteprotect_api_key' ),
465 'multisite' => '0',
466 );
467
468 // Send the number of blogs on the network if we are on multisite.
469 if ( is_multisite() ) {
470 $request['multisite'] = get_blog_count();
471 if ( ! $request['multisite'] ) {
472 global $wpdb;
473 $request['multisite'] = $wpdb->get_var( "SELECT COUNT(blog_id) as c FROM $wpdb->blogs WHERE spam = '0' AND deleted = '0' and archived = '0'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
474 }
475 }
476
477 // Request the key.
478 $xml = new Jetpack_IXR_Client();
479 $xml->query( 'jetpack.protect.requestKey', $request );
480
481 // Hmm, can't talk to wordpress.com.
482 if ( $xml->isError() ) {
483 $code = $xml->getErrorCode();
484 $message = $xml->getErrorMessage();
485 // Translators: The xml error code, and the xml error message.
486 $this->api_key_error = sprintf( __( 'Error connecting to WordPress.com. Code: %1$s, %2$s', 'jetpack-waf' ), $code, $message );
487
488 return false;
489 }
490
491 $response = $xml->getResponse();
492
493 // Hmm, can't talk to the protect servers ( api.bruteprotect.com ).
494 if ( ! isset( $response['data'] ) ) {
495 $this->api_key_error = __( 'No reply from Jetpack servers', 'jetpack-waf' );
496
497 return false;
498 }
499
500 // There was an issue generating the key.
501 if ( empty( $response['success'] ) ) {
502 $this->api_key_error = $response['data'];
503
504 return false;
505 }
506
507 // Key generation successful!
508 $active_plugins = self::get_active_plugins();
509
510 // We only want to deactivate BruteProtect if we successfully get a key.
511 if ( in_array( 'bruteprotect/bruteprotect.php', $active_plugins, true ) ) {
512 self::deactivate_plugin( 'bruteprotect/bruteprotect.php', 'BruteProtect' );
513 }
514
515 $key = $response['data'];
516 update_site_option( 'jetpack_protect_key', $key );
517
518 return $key;
519 }
520
521 /**
522 * Called via WP action wp_login_failed to log failed attempt with the api
523 *
524 * Fires custom, plugable action jpp_log_failed_attempt with the IP
525 *
526 * @param string|null $username - The username or email address attempting to log in.
527 * @param \WP_Error|string|null $error - A WP_Error object or error message with the authentication failure details.
528 *
529 * @return void
530 */
531 public function log_failed_attempt( $username, $error = null ) {
532 $username ??= '';
533
534 // Skip if Account protection password validation error.
535 if ( is_object( $error ) && isset( $error->errors['password_detection_validation_error'] ) ) {
536 return;
537 }
538
539 /**
540 * Fires before every failed login attempt.
541 *
542 * @module protect
543 *
544 * @since 3.4.0
545 *
546 * @param array Information about failed login attempt
547 * [
548 * 'login' => (string) Username or email used in failed login attempt
549 * ]
550 */
551 do_action( 'jpp_log_failed_attempt', array( 'login' => $username ) );
552
553 if ( isset( $_COOKIE['jpp_math_pass'] ) ) {
554
555 $transient = $this->get_transient( 'jpp_math_pass_' . sanitize_key( $_COOKIE['jpp_math_pass'] ) );
556 if ( is_int( $transient ) ) {
557 --$transient;
558 }
559
560 if ( ! is_int( $transient ) || $transient < 1 ) {
561 $this->delete_transient( 'jpp_math_pass_' . sanitize_key( $_COOKIE['jpp_math_pass'] ) );
562 // This is a cop out for the tests on some PHP versions
563 if ( ! headers_sent() ) {
564 setcookie( 'jpp_math_pass', '0', time() - DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, false, true );
565 }
566 } else {
567 $this->set_transient( 'jpp_math_pass_' . sanitize_key( $_COOKIE['jpp_math_pass'] ), $transient, DAY_IN_SECONDS );
568 }
569 }
570 $this->protect_call( 'failed_attempt' );
571 }
572
573 /**
574 * Set up the Brute Force Protection configuration page
575 */
576 public function modules_loaded() {
577 add_filter( 'jetpack_module_configurable_protect', '__return_true' );
578 }
579
580 /**
581 * Logs a successful login back to our servers, this allows us to make sure we're not blocking
582 * a busy IP that has a lot of good logins along with some forgotten passwords. Also saves current user's ip
583 * to the ip address allow list
584 *
585 * @param string $user_login - the user logging in.
586 * @param \WP_User $user - the user.
587 */
588 public function log_successful_login( $user_login, $user = null ) {
589 if ( ! $user ) { // For do_action( 'wp_login' ) calls that lacked passing the 2nd arg.
590 $user = get_user_by( 'login', $user_login );
591 }
592
593 $roles = $user instanceof \WP_User ? $user->roles : array();
594 $this->protect_call( 'successful_login', array( 'roles' => $roles ) );
595 }
596
597 /**
598 * Checks for loginability BEFORE authentication so that bots don't get to go around the log in form.
599 *
600 * If we are using our math fallback, authenticate via math-fallback.php
601 *
602 * @param string $user - the user.
603 * @param string $username - the username.
604 * @param string $password - the password.
605 *
606 * @return string $user
607 */
608 public function check_preauth( $user = 'Not Used By Protect', $username = 'Not Used By Protect', $password = 'Not Used By Protect' ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
609 $allow_login = $this->check_login_ability( true );
610 $use_math = $this->get_transient( 'brute_use_math' );
611
612 if ( ! $allow_login ) {
613 $this->block_with_math();
614 }
615
616 if ( ( 1 == $use_math || 1 == $this->block_login_with_math ) && isset( $_POST['log'] ) ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual, WordPress.Security.NonceVerification.Missing -- POST request just determines if we use math authentication.
617
618 Brute_Force_Protection_Math_Authenticate::math_authenticate();
619 }
620
621 return $user;
622 }
623
624 /**
625 * Get all IP headers so that we can process on our server...
626 *
627 * @return array
628 */
629 public function get_headers() {
630 $output = array();
631 $ip_related_headers = array(
632 'GD_PHP_HANDLER',
633 'HTTP_AKAMAI_ORIGIN_HOP',
634 'HTTP_CF_CONNECTING_IP',
635 'HTTP_CLIENT_IP',
636 'HTTP_FASTLY_CLIENT_IP',
637 'HTTP_FORWARDED',
638 'HTTP_FORWARDED_FOR',
639 'HTTP_INCAP_CLIENT_IP',
640 'HTTP_TRUE_CLIENT_IP',
641 'HTTP_X_CLIENTIP',
642 'HTTP_X_CLUSTER_CLIENT_IP',
643 'HTTP_X_FORWARDED',
644 'HTTP_X_FORWARDED_FOR',
645 'HTTP_X_IP_TRAIL',
646 'HTTP_X_REAL_IP',
647 'HTTP_X_VARNISH',
648 'REMOTE_ADDR',
649 );
650
651 foreach ( $ip_related_headers as $header ) {
652 if ( ! empty( $_SERVER[ $header ] ) ) {
653 $output[ $header ] = wp_unslash( $_SERVER[ $header ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
654 }
655 }
656
657 return $output;
658 }
659
660 /**
661 * Whether or not the IP allow list is enabled.
662 *
663 * @return bool
664 */
665 public static function ip_allow_list_enabled() {
666 return get_option( Waf_Rules_Manager::IP_ALLOW_LIST_ENABLED_OPTION_NAME, true );
667 }
668
669 /**
670 * Checks if the IP address is in the allow list.
671 *
672 * @deprecated 0.11.0 Use ip_is_allowed()
673 *
674 * @param string $ip - the IP address.
675 */
676 public static function ip_is_whitelisted( $ip ) {
677 _deprecated_function( __METHOD__, 'waf-0.11.0', __CLASS__ . '::ip_is_allowed' );
678 return self::ip_is_allowed( $ip );
679 }
680
681 /**
682 * Checks if the IP address is in the allow list.
683 *
684 * @param string $ip - the IP address.
685 *
686 * @return bool
687 */
688 public function ip_is_allowed( $ip ) {
689 // If we found an exact match in wp-config.
690 if ( defined( 'JETPACK_IP_ADDRESS_OK' ) && JETPACK_IP_ADDRESS_OK === $ip ) {
691 return true;
692 }
693
694 // Allow list must be enabled.
695 if ( ! $this->ip_allow_list_enabled() ) {
696 return false;
697 }
698
699 $allow_list = Brute_Force_Protection_Shared_Functions::get_local_allow_list();
700
701 if ( is_multisite() ) {
702 $allow_list = array_merge( $allow_list, get_site_option( 'jetpack_protect_global_whitelist', array() ) );
703 }
704
705 if ( ! empty( $allow_list ) ) :
706 foreach ( $allow_list as $item ) :
707 // If the IPs are an exact match.
708 if ( ! $item->range && isset( $item->ip_address ) && $item->ip_address === $ip ) {
709 return true;
710 }
711
712 if ( $item->range && isset( $item->range_low ) && isset( $item->range_high ) ) {
713 if ( IP_Utils::ip_address_is_in_range( $ip, $item->range_low, $item->range_high ) ) {
714 return true;
715 }
716 }
717 endforeach;
718 endif;
719
720 return false;
721 }
722
723 /**
724 * Checks the status for a given IP. API results are cached as transients
725 *
726 * @param bool $preauth - Whether or not we are checking prior to authorization.
727 *
728 * @return bool Either returns true, fires $this->kill_login, or includes a math fallback and returns false
729 */
730 public function check_login_ability( $preauth = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
731
732 /**
733 * JETPACK_ALWAYS_PROTECT_LOGIN will always disable the login page, and use a page provided by Jetpack.
734 */
735 if ( Constants::is_true( 'JETPACK_ALWAYS_PROTECT_LOGIN' ) ) {
736 $this->kill_login();
737 }
738
739 if ( $this->is_current_ip_allowed() ) {
740 return true;
741 }
742
743 $status = $this->get_cached_status();
744
745 if ( empty( $status ) ) {
746 // If we've reached this point, this means that the IP isn't cached.
747 // Now we check with the Protect API to see if we should allow login.
748 $response = $this->protect_call( $action = 'check_ip' ); // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found
749
750 if ( isset( $response['math'] ) && ! function_exists( 'brute_math_authenticate' ) ) {
751 new Brute_Force_Protection_Math_Authenticate();
752
753 return false;
754 }
755
756 $status = $response['status'];
757 }
758
759 if ( 'blocked' === $status ) {
760 $this->block_with_math();
761 }
762
763 if ( 'blocked-hard' === $status ) {
764 $this->kill_login();
765 }
766
767 return true;
768 }
769
770 /**
771 * Check if the user's IP is in the allow list.
772 *
773 * @deprecated 0.11.0 Use is_current_ip_allowed()
774 */
775 public static function is_current_ip_whitelisted() {
776 _deprecated_function( __METHOD__, 'waf-0.11.0', __CLASS__ . '::is_current_ip_allowed' );
777 return self::is_current_ip_allowed();
778 }
779
780 /**
781 * Check if the user's IP is in the allow list.
782 */
783 public function is_current_ip_allowed() {
784 $ip = IP_Utils::get_ip();
785
786 // Server is misconfigured and we can't get an IP.
787 if ( ! $ip ) {
788 self::disable();
789 ob_start();
790 ( new CookieState() )->state( 'message', 'protect_misconfigured_ip' );
791 ob_end_clean();
792 return true;
793 }
794
795 /**
796 * Short-circuit check_login_ability.
797 *
798 * If there is an alternate way to validate the current IP such as
799 * a hard-coded list of IP addresses, we can short-circuit the rest
800 * of the login ability checks and return true here.
801 *
802 * @module protect
803 *
804 * @since 4.4.0
805 *
806 * @param bool false Should we allow all logins for the current ip? Default: false
807 */
808 if ( apply_filters( 'jpp_allow_login', false, $ip ) ) {
809 return true;
810 }
811
812 if ( IP_Utils::ip_is_private( $ip ) ) {
813 return true;
814 }
815
816 if ( $this->ip_is_allowed( $ip ) ) {
817 return true;
818 }
819 }
820
821 /**
822 * Check if someone is able to login based on IP.
823 */
824 public function has_login_ability() {
825 if ( $this->is_current_ip_allowed() ) {
826 return true;
827 }
828 $status = $this->get_cached_status();
829 if ( empty( $status ) || 'ok' === $status ) {
830 return true;
831 }
832 return false;
833 }
834
835 /**
836 * Check the status of the cached transient.
837 */
838 public function get_cached_status() {
839 $transient_name = $this->get_transient_name();
840 $value = $this->get_transient( $transient_name );
841 if ( isset( $value['status'] ) ) {
842 return $value['status'];
843 }
844 return '';
845 }
846
847 /**
848 * Check if we need to block with a math question to continue logging in.
849 */
850 public function block_with_math() {
851 /**
852 * By default, Protect will allow a user who has been blocked for too
853 * many failed logins to start answering math questions to continue logging in
854 *
855 * For added security, you can disable this.
856 *
857 * @module protect
858 *
859 * @since 3.6.0
860 *
861 * @param bool Whether to allow math for blocked users or not.
862 */
863
864 $this->block_login_with_math = 1;
865 /**
866 * Allow Math fallback for blocked IPs.
867 *
868 * @module protect
869 *
870 * @since 3.6.0
871 *
872 * @param bool true Should we fallback to the Math questions when an IP is blocked. Default to true.
873 */
874 $allow_math_fallback_on_fail = apply_filters( 'jpp_use_captcha_when_blocked', true );
875 if ( ! $allow_math_fallback_on_fail ) {
876 $this->kill_login();
877 }
878
879 new Brute_Force_Protection_Math_Authenticate();
880
881 return false;
882 }
883
884 /**
885 * Kill a login attempt
886 */
887 public function kill_login() {
888 if (
889 isset( $_GET['action'] ) && isset( $_GET['_wpnonce'] ) &&
890 'logout' === $_GET['action'] &&
891 wp_verify_nonce( $_GET['_wpnonce'], 'log-out' ) && // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
892 wp_get_current_user()
893
894 ) {
895 // Allow users to logout.
896 return;
897 }
898
899 $ip = IP_Utils::get_ip();
900 /**
901 * Fires before every killed login.
902 *
903 * @module protect
904 *
905 * @since 3.4.0
906 *
907 * @param string $ip IP flagged by Protect.
908 */
909 do_action( 'jpp_kill_login', $ip );
910
911 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
912 // translators: variable is the IP address that was flagged.
913 $die_string = sprintf( __( 'Your IP (%1$s) has been flagged for potential security violations.', 'jetpack-waf' ), str_replace( 'http://', '', esc_url( 'http://' . $ip ) ) );
914 wp_die(
915 $die_string,
916 esc_html__( 'Login Blocked by Jetpack', 'jetpack-waf' ),
917 array( 'response' => 403 )
918 );
919 }
920
921 $blocked_login_page = Brute_Force_Protection_Blocked_Login_Page::instance( $ip );
922
923 if ( $blocked_login_page->is_blocked_user_valid() ) {
924 return;
925 }
926
927 $blocked_login_page->render_and_die();
928 }
929
930 /**
931 * Checks if the protect API call has failed, and if so initiates the math captcha fallback.
932 */
933 public function check_use_math() {
934 $use_math = $this->get_transient( 'brute_use_math' );
935 if ( $use_math ) {
936 new Brute_Force_Protection_Math_Authenticate();
937 }
938 }
939
940 /**
941 * If we're in a multisite network, return the blog ID of the primary blog
942 *
943 * @return int
944 */
945 public function get_main_blog_id() {
946 if ( ! is_multisite() ) {
947 return false;
948 }
949
950 global $current_site;
951 $primary_blog_id = $current_site->blog_id;
952
953 return $primary_blog_id;
954 }
955
956 /**
957 * Get jetpack blog id, or the jetpack blog id of the main blog in the main network
958 *
959 * @return int
960 */
961 public function get_main_blog_jetpack_id() {
962 if ( ! is_main_site() ) {
963 switch_to_blog( $this->get_main_blog_id() );
964 $id = Jetpack_Options::get_option( 'id', false );
965 restore_current_blog();
966 } else {
967 $id = Jetpack_Options::get_option( 'id' );
968 }
969
970 return $id;
971 }
972
973 /**
974 * Checks the API key.
975 */
976 public function check_api_key() {
977 $response = $this->protect_call( 'check_key' );
978
979 if ( isset( $response['ckval'] ) ) {
980 return true;
981 }
982
983 if ( isset( $response['error'] ) ) {
984
985 if ( 'Invalid API Key' === $response['error'] ) {
986 $this->api_key_error = __( 'Your API key is invalid', 'jetpack-waf' );
987 }
988
989 if ( 'API Key Required' === $response['error'] ) {
990 $this->api_key_error = __( 'No API key', 'jetpack-waf' );
991 }
992 }
993
994 $this->api_key_error = __( 'There was an error contacting Jetpack servers.', 'jetpack-waf' );
995
996 return false;
997 }
998
999 /**
1000 * Calls over to the api using wp_remote_post
1001 *
1002 * @param string $action - 'check_ip', 'check_key', or 'failed_attempt'.
1003 * @param array $request - Any custom data to post to the api.
1004 *
1005 * @return array
1006 */
1007 public function protect_call( $action = 'check_ip', $request = array() ) {
1008 global $wp_version;
1009
1010 $api_key = $this->maybe_get_protect_key();
1011
1012 $user_agent = "WordPress/{$wp_version}";
1013
1014 $request['action'] = $action;
1015 $request['ip'] = IP_Utils::get_ip();
1016 $request['host'] = $this->get_local_host();
1017 $request['headers'] = wp_json_encode( $this->get_headers(), JSON_UNESCAPED_SLASHES );
1018 $request['jetpack_version'] = null;
1019 $request['wordpress_version'] = (string) $wp_version;
1020 $request['api_key'] = $api_key;
1021 $request['multisite'] = '0';
1022
1023 if ( defined( 'JETPACK__VERSION' ) ) {
1024 $request['jetpack_version'] = constant( 'JETPACK__VERSION' );
1025 $user_agent .= ' | Jetpack/' . constant( 'JETPACK__VERSION' );
1026 }
1027
1028 if ( defined( 'JETPACK_PROTECT_VERSION' ) && ! defined( 'JETPACK__VERSION' ) ) {
1029 $request['jetpack_version'] = '12.1';
1030 $user_agent .= ' | JetpackProtect/' . constant( 'JETPACK_PROTECT_VERSION' );
1031 }
1032
1033 if ( is_multisite() ) {
1034 $request['multisite'] = get_blog_count();
1035 }
1036
1037 /**
1038 * Filter controls maximum timeout in waiting for reponse from Protect servers.
1039 *
1040 * @module protect
1041 *
1042 * @since 4.0.4
1043 *
1044 * @param int $timeout Max time (in seconds) to wait for a response.
1045 */
1046 $timeout = apply_filters( 'jetpack_protect_connect_timeout', 30 );
1047
1048 $args = array(
1049 'body' => $request,
1050 'user-agent' => $user_agent,
1051 'httpversion' => '1.0',
1052 'timeout' => absint( $timeout ),
1053 );
1054
1055 Waf_Constants::define_brute_force_api_host();
1056
1057 $response_json = wp_remote_post( JETPACK_PROTECT__API_HOST, $args );
1058 $this->last_response_raw = $response_json;
1059
1060 $transient_name = $this->get_transient_name();
1061 $this->delete_transient( $transient_name );
1062
1063 if ( is_array( $response_json ) ) {
1064 $response = json_decode( $response_json['body'], true );
1065 }
1066
1067 if ( isset( $response['blocked_attempts'] ) && $response['blocked_attempts'] ) {
1068 update_site_option( 'jetpack_protect_blocked_attempts', $response['blocked_attempts'] );
1069 }
1070
1071 if ( isset( $response['status'] ) && ! isset( $response['error'] ) ) {
1072 $response['expire'] = time() + $response['seconds_remaining'];
1073 $this->set_transient( $transient_name, $response, $response['seconds_remaining'] );
1074 $this->delete_transient( 'brute_use_math' );
1075 } else { // Fallback to Math Captcha if no response from API host.
1076 $this->set_transient( 'brute_use_math', 1, 600 );
1077 $response['status'] = 'ok';
1078 $response['math'] = true;
1079 }
1080
1081 if ( isset( $response['error'] ) ) {
1082 update_site_option( 'jetpack_protect_error', $response['error'] );
1083 } else {
1084 delete_site_option( 'jetpack_protect_error' );
1085 }
1086
1087 return $response;
1088 }
1089
1090 /**
1091 * Gets the transient name.
1092 */
1093 public function get_transient_name() {
1094 $headers = $this->get_headers();
1095 $header_hash = md5( wp_json_encode( $headers, JSON_UNESCAPED_SLASHES ) );
1096
1097 return 'jpp_li_' . $header_hash;
1098 }
1099
1100 /**
1101 * Wrapper for WordPress set_transient function, our version sets
1102 * the transient on the main site in the network if this is a multisite network
1103 *
1104 * We do it this way (instead of set_site_transient) because of an issue where
1105 * sitewide transients are always autoloaded
1106 * https://core.trac.wordpress.org/ticket/22846
1107 *
1108 * @param string $transient Transient name. Expected to not be SQL-escaped. Must be
1109 * 45 characters or fewer in length.
1110 * @param mixed $value Transient value. Must be serializable if non-scalar.
1111 * Expected to not be SQL-escaped.
1112 * @param int $expiration Optional. Time until expiration in seconds. Default 0.
1113 *
1114 * @return bool False if value was not set and true if value was set.
1115 */
1116 public function set_transient( $transient, $value, $expiration ) {
1117 if ( is_multisite() && ! is_main_site() ) {
1118 switch_to_blog( $this->get_main_blog_id() );
1119 $return = set_transient( $transient, $value, $expiration );
1120 restore_current_blog();
1121
1122 return $return;
1123 }
1124
1125 return set_transient( $transient, $value, $expiration );
1126 }
1127
1128 /**
1129 * Wrapper for WordPress delete_transient function, our version deletes
1130 * the transient on the main site in the network if this is a multisite network
1131 *
1132 * @param string $transient Transient name. Expected to not be SQL-escaped.
1133 *
1134 * @return bool true if successful, false otherwise
1135 */
1136 public function delete_transient( $transient ) {
1137 if ( is_multisite() && ! is_main_site() ) {
1138 switch_to_blog( $this->get_main_blog_id() );
1139 $return = delete_transient( $transient );
1140 restore_current_blog();
1141
1142 return $return;
1143 }
1144
1145 return delete_transient( $transient );
1146 }
1147
1148 /**
1149 * Wrapper for WordPress get_transient function, our version gets
1150 * the transient on the main site in the network if this is a multisite network
1151 *
1152 * @param string $transient Transient name. Expected to not be SQL-escaped.
1153 *
1154 * @return mixed Value of transient.
1155 */
1156 public function get_transient( $transient ) {
1157 if ( is_multisite() && ! is_main_site() ) {
1158 switch_to_blog( $this->get_main_blog_id() );
1159 $return = get_transient( $transient );
1160 restore_current_blog();
1161
1162 return $return;
1163 }
1164
1165 return get_transient( $transient );
1166 }
1167
1168 /**
1169 * Returns the local host.
1170 */
1171 public function get_local_host() {
1172 if ( isset( $this->local_host ) ) {
1173 return $this->local_host;
1174 }
1175
1176 $uri = 'http://' . strtolower( isset( $_SERVER['HTTP_HOST'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' );
1177
1178 if ( is_multisite() ) {
1179 $uri = network_home_url();
1180 }
1181
1182 $domain = '';
1183 $uridata = wp_parse_url( $uri );
1184 if ( false !== $uridata ) {
1185 $domain = $uridata['host'];
1186 }
1187
1188 // If we still don't have the site_url, get it.
1189 if ( ! $domain ) {
1190 $uri = get_site_url( 1 );
1191 $uridata = wp_parse_url( $uri );
1192 $domain = $uridata['host'];
1193 }
1194
1195 $this->local_host = $domain;
1196
1197 return $this->local_host;
1198 }
1199 }
1200