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