Set up the Brute Force Protection configuration page */ public function modules_loaded() { add_filter( 'jetpack_module_configurable_protect', '__return_true' ); } /** * Logs a successful login back to our servers, this allows us to make sure we're not blocking * a busy IP that has a lot of good logins along with some forgotten passwords. Also saves current user's ip * to the ip address allow list * * @param string $user_login - the user loggign in. * @param string $user - the user. */ public function log_successful_login( $user_login, $user = null ) { if ( ! $user ) { // For do_action( 'wp_login' ) calls that lacked passing the 2nd arg. $user = get_user_by( 'login', $user_login ); } $this->protect_call( 'successful_login', array( 'roles' => $user->roles ) ); } /** * Checks for loginability BEFORE authentication so that bots don't get to go around the log in form. * * If we are using our math fallback, authenticate via math-fallback.php * * @param string $user - the user. * @param string $username - the username. * @param string $password - the password. * * @return string $user */ 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 $allow_login = $this->check_login_ability( true ); $use_math = $this->get_transient( 'brute_use_math' ); if ( ! $allow_login ) { $this->block_with_math(); } 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. Brute_Force_Protection_Math_Authenticate::math_authenticate(); } return $user; } /** * Get all IP headers so that we can process on our server... * * @return array */ public function get_headers() { $output = array(); $ip_related_headers = array( 'GD_PHP_HANDLER', 'HTTP_AKAMAI_ORIGIN_HOP', 'HTTP_CF_CONNECTING_IP', 'HTTP_CLIENT_IP', 'HTTP_FASTLY_CLIENT_IP', 'HTTP_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_INCAP_CLIENT_IP', 'HTTP_TRUE_CLIENT_IP', 'HTTP_X_CLIENTIP', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_X_FORWARDED', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_IP_TRAIL', 'HTTP_X_REAL_IP', 'HTTP_X_VARNISH', 'REMOTE_ADDR', ); foreach ( $ip_related_headers as $header ) { if ( ! empty( $_SERVER[ $header ] ) ) { $output[ $header ] = wp_unslash( $_SERVER[ $header ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } } return $output; } /** * Checks if the IP address is in the allow list. * * @deprecated 0.11.0 Use ip_is_allowed() * * @param string $ip - the IP address. */ public static function ip_is_whitelisted( $ip ) { _deprecated_function( __METHOD__, 'waf-0.11.0', __CLASS__ . '::ip_is_allowed' ); return self::ip_is_allowed( $ip ); } /** * Checks if the IP address is in the allow list. * * @param string $ip - the IP address. * * @return bool */ public function ip_is_allowed( $ip ) { // If we found an exact match in wp-config. if ( defined( 'JETPACK_IP_ADDRESS_OK' ) && JETPACK_IP_ADDRESS_OK === $ip ) { return true; } $allow_list = Brute_Force_Protection_Shared_Functions::get_local_allow_list(); if ( is_multisite() ) { $allow_list = array_merge( $allow_list, get_site_option( 'jetpack_protect_global_whitelist', array() ) ); } if ( ! empty( $allow_list ) ) : foreach ( $allow_list as $item ) : // If the IPs are an exact match. if ( ! $item->range && isset( $item->ip_address ) && $item->ip_address === $ip ) { return true; } if ( $item->range && isset( $item->range_low ) && isset( $item->range_high ) ) { if ( IP_Utils::ip_address_is_in_range( $ip, $item->range_low, $item->range_high ) ) { return true; } } endforeach; endif; return false; } /** * Checks the status for a given IP. API results are cached as transients * * @param bool $preauth - Whether or not we are checking prior to authorization. * * @return bool Either returns true, fires $this->kill_login, or includes a math fallback and returns false */ public function check_login_ability( $preauth = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable /** * JETPACK_ALWAYS_PROTECT_LOGIN will always disable the login page, and use a page provided by Jetpack. */ if ( Constants::is_true( 'JETPACK_ALWAYS_PROTECT_LOGIN' ) ) { $this->kill_login(); } if ( $this->is_current_ip_allowed() ) { return true; } $status = $this->get_cached_status(); if ( empty( $status ) ) { // If we've reached this point, this means that the IP isn't cached. // Now we check with the Protect API to see if we should allow login. $response = $this->protect_call( $action = 'check_ip' ); // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found if ( isset( $response['math'] ) && ! function_exists( 'brute_math_authenticate' ) ) { new Brute_Force_Protection_Math_Authenticate(); return false; } $status = $response['status']; } if ( 'blocked' === $status ) { $this->block_with_math(); } if ( 'blocked-hard' === $status ) { $this->kill_login(); } return true; } /** * Check if the user's IP is in the allow list. * * @deprecated 0.11.0 Use is_current_ip_allowed() */ public static function is_current_ip_whitelisted() { _deprecated_function( __METHOD__, 'waf-0.11.0', __CLASS__ . '::is_current_ip_allowed' ); return self::is_current_ip_allowed(); } /** * Check if the user's IP is in the allow list. */ public function is_current_ip_allowed() { $ip = IP_Utils::get_ip(); // Server is misconfigured and we can't get an IP. if ( ! $ip ) { self::disable(); ob_start(); ( new CookieState() )->state( 'message', 'protect_misconfigured_ip' ); ob_end_clean(); return true; } /** * Short-circuit check_login_ability. * * If there is an alternate way to validate the current IP such as * a hard-coded list of IP addresses, we can short-circuit the rest * of the login ability checks and return true here. * * @module protect * * @since 4.4.0 * * @param bool false Should we allow all logins for the current ip? Default: false */ if ( apply_filters( 'jpp_allow_login', false, $ip ) ) { return true; } if ( IP_Utils::ip_is_private( $ip ) ) { return true; } if ( $this->ip_is_allowed( $ip ) ) { return true; } } /** * Check if someone is able to login based on IP. */ public function has_login_ability() { if ( $this->is_current_ip_allowed() ) { return true; } $status = $this->get_cached_status(); if ( empty( $status ) || 'ok' === $status ) { return true; } return false; } /** * Check the status of the cached transient. */ public function get_cached_status() { $transient_name = $this->get_transient_name(); $value = $this->get_transient( $transient_name ); if ( isset( $value['status'] ) ) { return $value['status']; } return ''; } /** * Check if we need to block with a math question to continue logging in. */ public function block_with_math() { /** * By default, Protect will allow a user who has been blocked for too * many failed logins to start answering math questions to continue logging in * * For added security, you can disable this. * * @module protect * * @since 3.6.0 * * @param bool Whether to allow math for blocked users or not. */ $this->block_login_with_math = 1; /** * Allow Math fallback for blocked IPs. * * @module protect * * @since 3.6.0 * * @param bool true Should we fallback to the Math questions when an IP is blocked. Default to true. */ $allow_math_fallback_on_fail = apply_filters( 'jpp_use_captcha_when_blocked', true ); if ( ! $allow_math_fallback_on_fail ) { $this->kill_login(); } new Brute_Force_Protection_Math_Authenticate(); return false; } /** * Kill a login attempt */ public function kill_login() { if ( isset( $_GET['action'] ) && isset( $_GET['_wpnonce'] ) && 'logout' === $_GET['action'] && wp_verify_nonce( $_GET['_wpnonce'], 'log-out' ) && // phpcs:ignore WordPress.Security.ValidatedSanitizedInput wp_get_current_user() ) { // Allow users to logout. return; } $ip = IP_Utils::get_ip(); /** * Fires before every killed login. * * @module protect * * @since 3.4.0 * * @param string $ip IP flagged by Protect. */ do_action( 'jpp_kill_login', $ip ); if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { // translators: variable is the IP address that was flagged. $die_string = sprintf( __( 'Your IP (%1$s) has been flagged for potential security violations.', 'jetpack-waf' ), str_replace( 'http://', '', esc_url( 'http://' . $ip ) ) ); wp_die( $die_string, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_url used when forming string. esc_html__( 'Login Blocked by Jetpack', 'jetpack-waf' ), array( 'response' => 403 ) ); } $blocked_login_page = Brute_Force_Protection_Blocked_Login_Page::instance( $ip ); if ( $blocked_login_page->is_blocked_user_valid() ) { return; } $blocked_login_page->render_and_die(); } /** * Checks if the protect API call has failed, and if so initiates the math captcha fallback. */ public function check_use_math() { $use_math = $this->get_transient( 'brute_use_math' ); if ( $use_math ) { new Brute_Force_Protection_Math_Authenticate(); } } /** * If we're in a multisite network, return the blog ID of the primary blog * * @return int */ public function get_main_blog_id() { if ( ! is_multisite() ) { return false; } global $current_site; $primary_blog_id = $current_site->blog_id; return $primary_blog_id; } /** * Get jetpack blog id, or the jetpack blog id of the main blog in the main network * * @return int */ public function get_main_blog_jetpack_id() { if ( ! is_main_site() ) { switch_to_blog( $this->get_main_blog_id() ); $id = Jetpack_Options::get_option( 'id', false ); restore_current_blog(); } else { $id = Jetpack_Options::get_option( 'id' ); } return $id; } /** * Checks the API key. */ public function check_api_key() { $response = $this->protect_call( 'check_key' ); if ( isset( $response['ckval'] ) ) { return true; } if ( isset( $response['error'] ) ) { if ( 'Invalid API Key' === $response['error'] ) { $this->api_key_error = __( 'Your API key is invalid', 'jetpack-waf' ); } if ( 'API Key Required' === $response['error'] ) { $this->api_key_error = __( 'No API key', 'jetpack-waf' ); } } $this->api_key_error = __( 'There was an error contacting Jetpack servers.', 'jetpack-waf' ); return false; } /** * Calls over to the api using wp_remote_post * * @param string $action - 'check_ip', 'check_key', or 'failed_attempt'. * @param array $request - Any custom data to post to the api. * * @return array */ public function protect_call( $action = 'check_ip', $request = array() ) { global $wp_version; $api_key = $this->maybe_get_protect_key(); $user_agent = "WordPress/{$wp_version}"; $request['action'] = $action; $request['ip'] = IP_Utils::get_ip(); $request['host'] = $this->get_local_host(); $request['headers'] = wp_json_encode( $this->get_headers() ); $request['jetpack_version'] = null; $request['wordpress_version'] = (string) $wp_version; $request['api_key'] = $api_key; $request['multisite'] = '0'; if ( defined( 'JETPACK__VERSION' ) ) { $request['jetpack_version'] = constant( 'JETPACK__VERSION' ); $user_agent .= ' | Jetpack/' . constant( 'JETPACK__VERSION' ); } if ( defined( 'JETPACK_PROTECT_VERSION' ) && ! defined( 'JETPACK__VERSION' ) ) { $request['jetpack_version'] = '12.1'; $user_agent .= ' | JetpackProtect/' . constant( 'JETPACK_PROTECT_VERSION' ); } if ( is_multisite() ) { $request['multisite'] = get_blog_count(); } /** * Filter controls maximum timeout in waiting for reponse from Protect servers. * * @module protect * * @since 4.0.4 * * @param int $timeout Max time (in seconds) to wait for a response. */ $timeout = apply_filters( 'jetpack_protect_connect_timeout', 30 ); $args = array( 'body' => $request, 'user-agent' => $user_agent, 'httpversion' => '1.0', 'timeout' => absint( $timeout ), ); Waf_Constants::define_brute_force_api_host(); $response_json = wp_remote_post( JETPACK_PROTECT__API_HOST, $args ); $this->last_response_raw = $response_json; $transient_name = $this->get_transient_name(); $this->delete_transient( $transient_name ); if ( is_array( $response_json ) ) { $response = json_decode( $response_json['body'], true ); } if ( isset( $response['blocked_attempts'] ) && $response['blocked_attempts'] ) { update_site_option( 'jetpack_protect_blocked_attempts', $response['blocked_attempts'] ); } if ( isset( $response['status'] ) && ! isset( $response['error'] ) ) { $response['expire'] = time() + $response['seconds_remaining']; $this->set_transient( $transient_name, $response, $response['seconds_remaining'] ); $this->delete_transient( 'brute_use_math' ); } else { // Fallback to Math Captcha if no response from API host. $this->set_transient( 'brute_use_math', 1, 600 ); $response['status'] = 'ok'; $response['math'] = true; } if ( isset( $response['error'] ) ) { update_site_option( 'jetpack_protect_error', $response['error'] ); } else { delete_site_option( 'jetpack_protect_error' ); } return $response; } /** * Gets the transient name. */ public function get_transient_name() { $headers = $this->get_headers(); $header_hash = md5( wp_json_encode( $headers ) ); return 'jpp_li_' . $header_hash; } /** * Wrapper for WordPress set_transient function, our version sets * the transient on the main site in the network if this is a multisite network * * We do it this way (instead of set_site_transient) because of an issue where * sitewide transients are always autoloaded * https://core.trac.wordpress.org/ticket/22846 * * @param string $transient Transient name. Expected to not be SQL-escaped. Must be * 45 characters or fewer in length. * @param mixed $value Transient value. Must be serializable if non-scalar. * Expected to not be SQL-escaped. * @param int $expiration Optional. Time until expiration in seconds. Default 0. * * @return bool False if value was not set and true if value was set. */ public function set_transient( $transient, $value, $expiration ) { if ( is_multisite() && ! is_main_site() ) { switch_to_blog( $this->get_main_blog_id() ); $return = set_transient( $transient, $value, $expiration ); restore_current_blog(); return $return; } return set_transient( $transient, $value, $expiration ); } /** * Wrapper for WordPress delete_transient function, our version deletes * the transient on the main site in the network if this is a multisite network * * @param string $transient Transient name. Expected to not be SQL-escaped. * * @return bool true if successful, false otherwise */ public function delete_transient( $transient ) { if ( is_multisite() && ! is_main_site() ) { switch_to_blog( $this->get_main_blog_id() ); $return = delete_transient( $transient ); restore_current_blog(); return $return; } return delete_transient( $transient ); } /** * Wrapper for WordPress get_transient function, our version gets * the transient on the main site in the network if this is a multisite network * * @param string $transient Transient name. Expected to not be SQL-escaped. * * @return mixed Value of transient. */ public function get_transient( $transient ) { if ( is_multisite() && ! is_main_site() ) { switch_to_blog( $this->get_main_blog_id() ); $return = get_transient( $transient ); restore_current_blog(); return $return; } return get_transient( $transient ); } /** * Returns the local host. */ public function get_local_host() { if ( isset( $this->local_host ) ) { return $this->local_host; } $uri = 'http://' . strtolower( isset( $_SERVER['HTTP_HOST'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' ); if ( is_multisite() ) { $uri = network_home_url(); } $uridata = wp_parse_url( $uri ); $domain = $uridata['host']; // If we still don't have the site_url, get it. if ( ! $domain ) { $uri = get_site_url( 1 ); $uridata = wp_parse_url( $uri ); $domain = $uridata['host']; } $this->local_host = $domain; return $this->local_host; } }