| @@ -10,8 +10,9 @@ | ||
| 10 | 10 | use WP_Defender\Component; |
| 11 | 11 | use WP_Defender\Traits\Country; |
| 12 | 12 | use WP_Defender\Model\Lockout_Ip; |
| 13 | 13 | use WP_Defender\Model\Lockout_Log; |
| 14 | +use WP_Defender\Component\Known_Bots\Known_Bots_Factory; | |
| 14 | 15 | |
| 15 | 16 | /** |
| 16 | 17 | * Handles the detection of 404 errors and manages lockouts based on configured settings. |
| 17 | 18 | */ |
| @@ -19,8 +20,15 @@ | ||
| 19 | 20 | |
| 20 | 21 | use Country; |
| 21 | 22 | |
| 22 | 23 | public const SCENARIO_ERROR_404 = 'error_404', SCENARIO_ERROR_404_IGNORE = 'error_404_ignore', SCENARIO_LOCKOUT_404 = '404_lockout'; |
| 24 | + // New log types for 404_INTELLIGENCE. | |
| 25 | + public const SCENARIO_404_INTELLIGENCE_XSS_ATTEMPT = '404_xss_attempt'; | |
| 26 | + public const SCENARIO_404_INTELLIGENCE_XSS_LOCKOUT = '404_xss_lockout'; | |
| 27 | + public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_ATTEMPT = '404_non_existent_plugin_attempt'; | |
| 28 | + public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_LOCKOUT = '404_non_existent_plugin_lockout'; | |
| 29 | + public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_ATTEMPT = '404_non_existent_theme_attempt'; | |
| 30 | + public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_LOCKOUT = '404_non_existent_theme_lockout'; | |
| 23 | 31 | /** |
| 24 | 32 | * Use for cache. |
| 25 | 33 | * |
| 26 | 34 | * @var \WP_Defender\Model\Setting\Notfound_Lockout |
| @@ -27,134 +35,122 @@ | ||
| 27 | 35 | */ |
| 28 | 36 | public $model; |
| 29 | 37 | |
| 30 | 38 | /** |
| 39 | + * Mark to separate standard logs from intelligence ones and avoid duplicate. | |
| 40 | + * | |
| 41 | + * @var bool | |
| 42 | + */ | |
| 43 | + private $is_intelligence = false; | |
| 44 | + | |
| 45 | + /** | |
| 31 | 46 | * Constructor for Notfound_Lockout. |
| 32 | 47 | */ |
| 33 | 48 | public function __construct() { |
| 34 | 49 | $this->model = wd_di()->get( \WP_Defender\Model\Setting\Notfound_Lockout::class ); |
| 50 | + add_action( 'init', array( $this, 'maybe_detect_suspicious_request' ), 1 ); | |
| 35 | 51 | } |
| 36 | - | |
| 37 | 52 | /** |
| 38 | - * Queue hooks when this class init. | |
| 53 | + * Run 404 intelligence. | |
| 39 | 54 | */ |
| 40 | - public function add_hooks() { | |
| 41 | - add_action( 'template_redirect', array( $this, 'process_404_detect_multiple' ) ); | |
| 42 | - } | |
| 43 | - | |
| 44 | - /** | |
| 45 | - * Check if useragent is looks like from googlebot. | |
| 46 | - * | |
| 47 | - * @param string $user_agent The user agent string to check. | |
| 48 | - * | |
| 49 | - * @return bool | |
| 50 | - */ | |
| 51 | - private function is_google_ua( $user_agent = '' ): bool { | |
| 52 | - if ( empty( $user_agent ) ) { | |
| 53 | - $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' ); | |
| 54 | - if ( empty( $user_agent ) ) { | |
| 55 | - return false; | |
| 56 | - } | |
| 57 | - $user_agent = User_Agent::fast_cleaning( $user_agent ); | |
| 55 | + public function maybe_detect_suspicious_request() { | |
| 56 | + // Don't interfere with WP-CLI, Cron, AJAX and legit internal requests. | |
| 57 | + if ( defender_is_wp_cli() ) { | |
| 58 | + return; | |
| 58 | 59 | } |
| 59 | - if ( function_exists( 'mb_strtolower' ) ) { | |
| 60 | - $user_agent = mb_strtolower( $user_agent, 'UTF-8' ); | |
| 61 | - } else { | |
| 62 | - $user_agent = strtolower( $user_agent ); | |
| 60 | + if ( wp_doing_cron() ) { | |
| 61 | + return; | |
| 63 | 62 | } |
| 63 | + if ( wp_doing_ajax() ) { | |
| 64 | + return; | |
| 65 | + } | |
| 66 | + if ( false === $this->model->detect_logged && is_user_logged_in() ) { | |
| 67 | + return; | |
| 68 | + } | |
| 64 | 69 | |
| 65 | - if ( false !== stristr( $user_agent, 'googlebot' ) ) { | |
| 66 | - return true; | |
| 70 | + $uri = defender_get_data_from_request( 'REQUEST_URI', 's' ); | |
| 71 | + $uri = urldecode( $uri ); | |
| 72 | + $query = defender_get_data_from_request( 'QUERY_STRING', 's' ); | |
| 73 | + | |
| 74 | + $service = wd_di()->get( Blacklist_Lockout::class ); | |
| 75 | + foreach ( $this->get_user_ip() as $ip ) { | |
| 76 | + if ( ! $service->is_ip_whitelisted( $ip ) ) { | |
| 77 | + $this->detect_suspicious_request( $ip, $uri, $query ); | |
| 78 | + } | |
| 67 | 79 | } |
| 68 | - | |
| 69 | - return false; | |
| 70 | 80 | } |
| 71 | 81 | |
| 72 | 82 | /** |
| 73 | - * Check if IP is from Google, base on https://support.google.com/webmasters/answer/80553?hl=en. | |
| 83 | + * Detect if the current request: | |
| 84 | + * -refers to a non-existent plugin or theme, | |
| 85 | + * -contains XSS attempts. | |
| 74 | 86 | * |
| 75 | - * @param string $ip The IP address to check. | |
| 87 | + * @param string $ip The IP address to process. | |
| 88 | + * @param string $uri The URI that was accessed. | |
| 89 | + * @param string $query The query that was accessed. | |
| 76 | 90 | * |
| 77 | - * @return bool | |
| 91 | + * @return void | |
| 78 | 92 | */ |
| 79 | - private function is_google_ip( $ip ): bool { | |
| 80 | - $hostname = gethostbyaddr( $ip ); | |
| 81 | - // Check if this hostname has googlebot or google.com. | |
| 82 | - if ( preg_match( '/\.googlebot|google\.com$/i', $hostname ) ) { | |
| 83 | - $hosts = gethostbynamel( $hostname ); | |
| 93 | + protected function detect_suspicious_request( $ip, $uri, $query ) { | |
| 94 | + $lockout_type = ''; | |
| 84 | 95 | |
| 85 | - if ( ! is_array( $hosts ) ) { | |
| 86 | - return false; | |
| 87 | - } | |
| 96 | + $xss_pattern = '#(?i)(<script|javascript:|onerror=|onload=|onmouseover=|onclick=|alert\s*\(|prompt\s*\(|confirm\s*\(|document\.cookie|document\.write|eval\s*\()#'; | |
| 97 | + if ( preg_match( $xss_pattern, $uri ) || preg_match( $xss_pattern, $query ) ) { | |
| 98 | + $slug = $uri; | |
| 99 | + $attempt_type = self::SCENARIO_404_INTELLIGENCE_XSS_ATTEMPT; | |
| 100 | + $lockout_type = self::SCENARIO_404_INTELLIGENCE_XSS_LOCKOUT; | |
| 101 | + } elseif ( preg_match( '#/wp-content/(plugins|themes)/([^/]+)/#i', $uri, $m ) ) { | |
| 102 | + // Looking for requests for plugins/themes. | |
| 103 | + $entity = strtolower( $m[1] ); | |
| 104 | + $slug = sanitize_file_name( $m[2] ); | |
| 105 | + $path = ( 'themes' === $entity ? get_theme_root() : WP_PLUGIN_DIR ) . '/' . $slug; | |
| 88 | 106 | |
| 89 | - // Check if this match the original ip. | |
| 90 | - foreach ( $hosts as $host ) { | |
| 91 | - if ( $ip === $host ) { | |
| 92 | - return true; | |
| 107 | + // Check for the existence of the specified file path. If it doesn't exist, only then continue working on the request. | |
| 108 | + if ( ! file_exists( $path ) ) { | |
| 109 | + if ( 'themes' === $entity ) { | |
| 110 | + $attempt_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_ATTEMPT; | |
| 111 | + $lockout_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_LOCKOUT; | |
| 112 | + } else { | |
| 113 | + $attempt_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_ATTEMPT; | |
| 114 | + $lockout_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_LOCKOUT; | |
| 93 | 115 | } |
| 94 | 116 | } |
| 95 | 117 | } |
| 96 | 118 | |
| 97 | - return false; | |
| 98 | - } | |
| 119 | + if ( '' !== $lockout_type ) { | |
| 120 | + // Detect it. | |
| 121 | + $model = Lockout_Ip::get( $ip ); | |
| 122 | + $this->log_event( $ip, $slug, $attempt_type ); | |
| 99 | 123 | |
| 100 | - /** | |
| 101 | - * Checks if the user agent belongs to Bing. | |
| 102 | - * | |
| 103 | - * @param string $user_agent The user agent string to check. | |
| 104 | - * | |
| 105 | - * @return bool Returns true if the user agent is identified as belonging to Bing, false otherwise. | |
| 106 | - */ | |
| 107 | - private function is_bing_ua( $user_agent = '' ): bool { | |
| 124 | + // Avoid the duplicate with the standard 404 case. | |
| 125 | + $this->is_intelligence = true; | |
| 108 | 126 | |
| 109 | - if ( empty( $user_agent ) ) { | |
| 110 | - $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' ); | |
| 111 | - if ( empty( $user_agent ) ) { | |
| 112 | - return false; | |
| 113 | - } | |
| 114 | - $user_agent = User_Agent::fast_cleaning( $user_agent ); | |
| 115 | - } | |
| 127 | + $model = $this->record_fail_attempt( $ip, $model ); | |
| 128 | + // Count the attempt. | |
| 129 | + $window = strtotime( '- ' . $this->model->timeframe . ' seconds' ); | |
| 116 | 130 | |
| 117 | - if ( function_exists( 'mb_strtolower' ) ) { | |
| 118 | - $user_agent = mb_strtolower( $user_agent, 'UTF-8' ); | |
| 119 | - } else { | |
| 120 | - $user_agent = strtolower( $user_agent ); | |
| 121 | - } | |
| 122 | - // MSN Bot Useragent https://www.bing.com/webmaster/help/which-crawlers-does-bing-use-8c184ec0. | |
| 123 | - $msn_ua = 'Bingbot|MSNBot|MSNBot-Media|AdIdxBot|BingPreview'; | |
| 131 | + $model = $this->check_meta_data( $model ); | |
| 132 | + // We will get the latest till oldest, limit by attempt. | |
| 133 | + $checks = array_slice( $model->meta['nf'], $this->model->attempt * - 1 ); | |
| 124 | 134 | |
| 125 | - if ( preg_match( '/' . $msn_ua . '/i', $user_agent ) ) { | |
| 126 | - return true; | |
| 135 | + if ( count( $checks ) < $this->model->attempt ) { | |
| 136 | + return; | |
| 137 | + } | |
| 138 | + // If the last time is larger. | |
| 139 | + $check = min( $checks ); | |
| 140 | + if ( $check >= $window ) { | |
| 141 | + // then lock it. | |
| 142 | + $this->lock( $model, 'normal', $uri ); | |
| 143 | + $this->log_event( $ip, $uri, $lockout_type ); | |
| 144 | + } | |
| 127 | 145 | } |
| 128 | - | |
| 129 | - return false; | |
| 130 | 146 | } |
| 131 | 147 | |
| 132 | 148 | /** |
| 133 | - * Check if IP is from Bing, base on https://www.bing.com/webmaster/help/how-to-verify-bingbot-3905dc26. | |
| 134 | - * | |
| 135 | - * @param string $ip The IP address to check. | |
| 136 | - * | |
| 137 | - * @return bool | |
| 149 | + * Queue hooks when this class init. | |
| 138 | 150 | */ |
| 139 | - private function is_bing_ip( $ip ): bool { | |
| 140 | - $hostname = gethostbyaddr( $ip ); | |
| 141 | - if ( preg_match( '/\.msnbot|msn\.com$/i', $hostname ) ) { | |
| 142 | - $hosts = gethostbynamel( $hostname ); | |
| 143 | - | |
| 144 | - if ( ! is_array( $hosts ) ) { | |
| 145 | - return false; | |
| 146 | - } | |
| 147 | - | |
| 148 | - // Check if this match the original ip. | |
| 149 | - foreach ( $hosts as $host ) { | |
| 150 | - if ( $ip === $host ) { | |
| 151 | - return true; | |
| 152 | - } | |
| 153 | - } | |
| 154 | - } | |
| 155 | - | |
| 156 | - return false; | |
| 151 | + public function add_hooks() { | |
| 152 | + add_action( 'template_redirect', array( $this, 'process_404_detect_multiple' ) ); | |
| 157 | 153 | } |
| 158 | 154 | |
| 159 | 155 | /** |
| 160 | 156 | * Processes 404 detection for a single IP address. |
| @@ -161,16 +157,12 @@ | ||
| 161 | 157 | * |
| 162 | 158 | * @param string $ip The IP address to process. |
| 163 | 159 | */ |
| 164 | 160 | public function process_404_detect( string $ip ): void { |
| 165 | - // Check if this from Google. | |
| 166 | - if ( $this->is_google_ua() && $this->is_google_ip( $ip ) ) { | |
| 161 | + // Check if request is from a legitimate known bot (Google, Bing, Facebook). | |
| 162 | + if ( Known_Bots_Factory::create()->is_known_bot( $ip ) ) { | |
| 167 | 163 | return; |
| 168 | 164 | } |
| 169 | - // or bing. | |
| 170 | - if ( $this->is_bing_ua() && $this->is_bing_ip( $ip ) ) { | |
| 171 | - return; | |
| 172 | - } | |
| 173 | 165 | |
| 174 | 166 | $uri = defender_get_data_from_request( 'REQUEST_URI', 's' ); |
| 175 | 167 | // Strip encode. |
| 176 | 168 | $uri = urldecode( $uri ); |
| @@ -332,22 +324,10 @@ | ||
| 332 | 324 | * @param string $uri The URI that was accessed. |
| 333 | 325 | * @param string $scenario The scenario under which the event is logged. |
| 334 | 326 | */ |
| 335 | 327 | public function log_event( $ip, $uri, $scenario ) { |
| 336 | - $model = new Lockout_Log(); | |
| 337 | - $model->ip = $ip; | |
| 338 | - $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' ); | |
| 339 | - $model->user_agent = isset( $user_agent ) ? User_Agent::fast_cleaning( $user_agent ) : null; | |
| 340 | - $model->date = time(); | |
| 341 | - $model->tried = $uri; | |
| 342 | - $model->blog_id = get_current_blog_id(); | |
| 328 | + $model = Lockout_Log::create( $ip, $uri ); | |
| 343 | 329 | |
| 344 | - $ip_to_country = $this->ip_to_country( $ip ); | |
| 345 | - | |
| 346 | - if ( isset( $ip_to_country['iso'] ) ) { | |
| 347 | - $model->country_iso_code = $ip_to_country['iso']; | |
| 348 | - } | |
| 349 | - | |
| 350 | 330 | switch ( $scenario ) { |
| 351 | 331 | case self::SCENARIO_ERROR_404: |
| 352 | 332 | $model->type = Lockout_Log::ERROR_404; |
| 353 | 333 | $model->log = sprintf( |
| @@ -363,8 +343,47 @@ | ||
| 363 | 343 | esc_html__( 'Request for file %s which doesn`t exist', 'defender-security' ), |
| 364 | 344 | $uri |
| 365 | 345 | ); |
| 366 | 346 | break; |
| 347 | + // New log cases since v5.7.0. | |
| 348 | + case self::SCENARIO_404_INTELLIGENCE_XSS_ATTEMPT: | |
| 349 | + $model->type = Lockout_Log::ERROR_404_XSS; | |
| 350 | + $model->log = sprintf( | |
| 351 | + /* translators: %s: URI. */ | |
| 352 | + esc_html__( 'Possible XSS attempt detected in request: %s', 'defender-security' ), | |
| 353 | + $uri | |
| 354 | + ); | |
| 355 | + break; | |
| 356 | + case self::SCENARIO_404_INTELLIGENCE_XSS_LOCKOUT: | |
| 357 | + $model->type = Lockout_Log::LOCKOUT_404_XSS; | |
| 358 | + $model->log = esc_html__( 'Lockout occurred: Multiple XSS attempt requests detected', 'defender-security' ); | |
| 359 | + break; | |
| 360 | + case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_ATTEMPT: | |
| 361 | + $model->type = Lockout_Log::ERROR_404_NON_EXISTENT_PLUGIN; | |
| 362 | + $model->log = sprintf( | |
| 363 | + /* translators: %s: URI. */ | |
| 364 | + esc_html__( 'Attempt to access non-installed plugin: %s', 'defender-security' ), | |
| 365 | + $uri | |
| 366 | + ); | |
| 367 | + break; | |
| 368 | + case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_LOCKOUT: | |
| 369 | + $model->type = Lockout_Log::LOCKOUT_404_NON_EXISTENT_PLUGIN; | |
| 370 | + $model->log = | |
| 371 | + esc_html__( 'Lockout occurred: Multiple requests for non-installed plugin', 'defender-security' ); | |
| 372 | + break; | |
| 373 | + case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_ATTEMPT: | |
| 374 | + $model->type = Lockout_Log::ERROR_404_NON_EXISTENT_THEME; | |
| 375 | + $model->log = sprintf( | |
| 376 | + /* translators: %s: URI. */ | |
| 377 | + esc_html__( 'Attempt to access non-installed theme: %s', 'defender-security' ), | |
| 378 | + $uri | |
| 379 | + ); | |
| 380 | + break; | |
| 381 | + case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_LOCKOUT: | |
| 382 | + $model->type = Lockout_Log::LOCKOUT_404_NON_EXISTENT_THEME; | |
| 383 | + $model->log = | |
| 384 | + esc_html__( 'Lockout occurred: Multiple requests for non-installed theme', 'defender-security' ); | |
| 385 | + break; | |
| 367 | 386 | case self::SCENARIO_LOCKOUT_404: |
| 368 | 387 | default: |
| 369 | 388 | $model->type = Lockout_Log::LOCKOUT_404; |
| 370 | 389 | $model->log = sprintf( |
| @@ -377,9 +396,9 @@ | ||
| 377 | 396 | $model->save(); |
| 378 | 397 | // @since 5.4.0 Action hook triggered after a failed 404 attempt. |
| 379 | 398 | do_action( 'wd_404_attempt', $model, $scenario, $uri ); |
| 380 | 399 | |
| 381 | - if ( Lockout_Log::LOCKOUT_404 === $model->type ) { | |
| 400 | + if ( in_array( $model->type, Lockout_Log::get_404_lockout_types(), true ) ) { | |
| 382 | 401 | do_action( 'defender_notify', 'firewall-notification', $model ); |
| 383 | 402 | } |
| 384 | 403 | } |
| 385 | 404 | |
| @@ -389,9 +408,9 @@ | ||
| 389 | 408 | * @return void |
| 390 | 409 | * @since 4.4.2 |
| 391 | 410 | */ |
| 392 | 411 | public function process_404_detect_multiple(): void { |
| 393 | - if ( ! is_404() ) { | |
| 412 | + if ( $this->is_intelligence || ! is_404() ) { | |
| 394 | 413 | return; |
| 395 | 414 | } |
| 396 | 415 | |
| 397 | 416 | if ( false === $this->model->detect_logged && is_user_logged_in() ) { |