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