PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.1
Defender Security – Malware Scanner, Login Security & Firewall v6.2.1
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / component / class-notfound-lockout.php

class-notfound-lockout.php in Defender Security – Malware Scanner, Login Security & Firewall 6.2.1, at src/component/class-notfound-lockout.php

558 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Responsible for handling 404 error detections and lockouts.
4 *
5 * @package WP_Defender\Component
6 */
7
8 namespace WP_Defender\Component;
9
10 use WP_Defender\Component;
11 use WP_Defender\Traits\Country;
12 use WP_Defender\Model\Lockout_Ip;
13 use WP_Defender\Model\Lockout_Log;
14
15 /**
16 * Handles the detection of 404 errors and manages lockouts based on configured settings.
17 */
18 class Notfound_Lockout extends Component {
19
20 use Country;
21
22 public const SCENARIO_ERROR_404 = 'error_404', SCENARIO_ERROR_404_IGNORE = 'error_404_ignore', SCENARIO_LOCKOUT_404 = '404_lockout';
23 // New log types for 404_INTELLIGENCE.
24 public const SCENARIO_404_INTELLIGENCE_XSS_ATTEMPT = '404_xss_attempt';
25 public const SCENARIO_404_INTELLIGENCE_XSS_LOCKOUT = '404_xss_lockout';
26 public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_ATTEMPT = '404_non_existent_plugin_attempt';
27 public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_LOCKOUT = '404_non_existent_plugin_lockout';
28 public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_ATTEMPT = '404_non_existent_theme_attempt';
29 public const SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_LOCKOUT = '404_non_existent_theme_lockout';
30 /**
31 * Use for cache.
32 *
33 * @var \WP_Defender\Model\Setting\Notfound_Lockout
34 */
35 public $model;
36
37 /**
38 * Mark to separate standard logs from intelligence ones and avoid duplicate.
39 *
40 * @var bool
41 */
42 private $is_intelligence = false;
43
44 /**
45 * Constructor for Notfound_Lockout.
46 */
47 public function __construct() {
48 $this->model = wd_di()->get( \WP_Defender\Model\Setting\Notfound_Lockout::class );
49 add_action( 'init', array( $this, 'maybe_detect_suspicious_request' ), 1 );
50 }
51 /**
52 * Run 404 intelligence.
53 */
54 public function maybe_detect_suspicious_request() {
55 // Don't interfere with WP-CLI, Cron, AJAX and legit internal requests.
56 if ( defender_is_wp_cli() ) {
57 return;
58 }
59 if ( wp_doing_cron() ) {
60 return;
61 }
62 if ( wp_doing_ajax() ) {
63 return;
64 }
65 if ( false === $this->model->detect_logged && is_user_logged_in() ) {
66 return;
67 }
68
69 $uri = defender_get_data_from_request( 'REQUEST_URI', 's' );
70 $uri = urldecode( $uri );
71 $query = defender_get_data_from_request( 'QUERY_STRING', 's' );
72
73 $service = wd_di()->get( Blacklist_Lockout::class );
74 foreach ( $this->get_user_ip() as $ip ) {
75 if ( ! $service->is_ip_whitelisted( $ip ) ) {
76 $this->detect_suspicious_request( $ip, $uri, $query );
77 }
78 }
79 }
80
81 /**
82 * Detect if the current request:
83 * -refers to a non-existent plugin or theme,
84 * -contains XSS attempts.
85 *
86 * @param string $ip The IP address to process.
87 * @param string $uri The URI that was accessed.
88 * @param string $query The query that was accessed.
89 *
90 * @return void
91 */
92 protected function detect_suspicious_request( $ip, $uri, $query ) {
93 $lockout_type = '';
94
95 $xss_pattern = '#(?i)(<script|javascript:|onerror=|onload=|onmouseover=|onclick=|alert\s*\(|prompt\s*\(|confirm\s*\(|document\.cookie|document\.write|eval\s*\()#';
96 if ( preg_match( $xss_pattern, $uri ) || preg_match( $xss_pattern, $query ) ) {
97 $slug = $uri;
98 $attempt_type = self::SCENARIO_404_INTELLIGENCE_XSS_ATTEMPT;
99 $lockout_type = self::SCENARIO_404_INTELLIGENCE_XSS_LOCKOUT;
100 } elseif ( preg_match( '#/wp-content/(plugins|themes)/([^/]+)/#i', $uri, $m ) ) {
101 // Looking for requests for plugins/themes.
102 $entity = strtolower( $m[1] );
103 $slug = sanitize_file_name( $m[2] );
104 $path = ( 'themes' === $entity ? get_theme_root() : WP_PLUGIN_DIR ) . '/' . $slug;
105
106 // Check for the existence of the specified file path. If it doesn't exist, only then continue working on the request.
107 if ( ! file_exists( $path ) ) {
108 if ( 'themes' === $entity ) {
109 $attempt_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_ATTEMPT;
110 $lockout_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_LOCKOUT;
111 } else {
112 $attempt_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_ATTEMPT;
113 $lockout_type = self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_LOCKOUT;
114 }
115 }
116 }
117
118 if ( '' !== $lockout_type ) {
119 // Detect it.
120 $model = Lockout_Ip::get( $ip );
121 $this->log_event( $ip, $slug, $attempt_type );
122
123 // Avoid the duplicate with the standard 404 case.
124 $this->is_intelligence = true;
125
126 $model = $this->record_fail_attempt( $ip, $model );
127 // Count the attempt.
128 $window = strtotime( '- ' . $this->model->timeframe . ' seconds' );
129
130 $model = $this->check_meta_data( $model );
131 // We will get the latest till oldest, limit by attempt.
132 $checks = array_slice( $model->meta['nf'], $this->model->attempt * - 1 );
133
134 if ( count( $checks ) < $this->model->attempt ) {
135 return;
136 }
137 // If the last time is larger.
138 $check = min( $checks );
139 if ( $check >= $window ) {
140 // then lock it.
141 $this->lock( $model, 'normal', $uri );
142 $this->log_event( $ip, $uri, $lockout_type );
143 }
144 }
145 }
146
147 /**
148 * Queue hooks when this class init.
149 */
150 public function add_hooks() {
151 add_action( 'template_redirect', array( $this, 'process_404_detect_multiple' ) );
152 }
153
154 /**
155 * Check if user-agent is looks like from googlebot.
156 *
157 * @param string $user_agent The user agent string to check.
158 *
159 * @return bool
160 */
161 private function is_google_ua( $user_agent = '' ): bool {
162 if ( '' === $user_agent ) {
163 $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
164 if ( '' === $user_agent ) {
165 return false;
166 }
167 $user_agent = User_Agent::fast_cleaning( $user_agent );
168 }
169 if ( function_exists( 'mb_strtolower' ) ) {
170 $user_agent = mb_strtolower( $user_agent, 'UTF-8' );
171 } else {
172 $user_agent = strtolower( $user_agent );
173 }
174
175 if ( false !== stristr( $user_agent, 'googlebot' ) ) {
176 return true;
177 }
178
179 return false;
180 }
181
182 /**
183 * Check if IP is from Google, base on https://support.google.com/webmasters/answer/80553?hl=en.
184 *
185 * @param string $ip The IP address to check.
186 *
187 * @return bool
188 */
189 private function is_google_ip( $ip ): bool {
190 $hostname = gethostbyaddr( $ip );
191 // Check if this hostname has googlebot or google.com.
192 if ( preg_match( '/\.googlebot|google\.com$/i', $hostname ) ) {
193 $hosts = gethostbynamel( $hostname );
194
195 if ( ! is_array( $hosts ) ) {
196 return false;
197 }
198
199 // Check if this match the original ip.
200 foreach ( $hosts as $host ) {
201 if ( $ip === $host ) {
202 return true;
203 }
204 }
205 }
206
207 return false;
208 }
209
210 /**
211 * Checks if the user agent belongs to Bing.
212 *
213 * @param string $user_agent The user agent string to check.
214 *
215 * @return bool Returns true if the user agent is identified as belonging to Bing, false otherwise.
216 */
217 private function is_bing_ua( $user_agent = '' ): bool {
218
219 if ( '' === $user_agent ) {
220 $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
221 if ( '' === $user_agent ) {
222 return false;
223 }
224 $user_agent = User_Agent::fast_cleaning( $user_agent );
225 }
226
227 if ( function_exists( 'mb_strtolower' ) ) {
228 $user_agent = mb_strtolower( $user_agent, 'UTF-8' );
229 } else {
230 $user_agent = strtolower( $user_agent );
231 }
232 // MSN Bot Useragent https://www.bing.com/webmaster/help/which-crawlers-does-bing-use-8c184ec0.
233 $msn_ua = 'Bingbot|MSNBot|MSNBot-Media|AdIdxBot|BingPreview';
234
235 if ( preg_match( '/' . $msn_ua . '/i', $user_agent ) ) {
236 return true;
237 }
238
239 return false;
240 }
241
242 /**
243 * Check if IP is from Bing, base on https://www.bing.com/webmaster/help/how-to-verify-bingbot-3905dc26.
244 *
245 * @param string $ip The IP address to check.
246 *
247 * @return bool
248 */
249 private function is_bing_ip( $ip ): bool {
250 $hostname = gethostbyaddr( $ip );
251 if ( preg_match( '/\.msnbot|msn\.com$/i', $hostname ) ) {
252 $hosts = gethostbynamel( $hostname );
253
254 if ( ! is_array( $hosts ) ) {
255 return false;
256 }
257
258 // Check if this match the original ip.
259 foreach ( $hosts as $host ) {
260 if ( $ip === $host ) {
261 return true;
262 }
263 }
264 }
265
266 return false;
267 }
268
269 /**
270 * Processes 404 detection for a single IP address.
271 *
272 * @param string $ip The IP address to process.
273 */
274 public function process_404_detect( string $ip ): void {
275 // Check if this from Google.
276 if ( $this->is_google_ua() && $this->is_google_ip( $ip ) ) {
277 return;
278 }
279 // or bing.
280 if ( $this->is_bing_ua() && $this->is_bing_ip( $ip ) ) {
281 return;
282 }
283
284 $uri = defender_get_data_from_request( 'REQUEST_URI', 's' );
285 // Strip encode.
286 $uri = urldecode( $uri );
287 $model = Lockout_Ip::get( $ip );
288 $model = $this->record_fail_attempt( $ip, $model );
289
290 $ext = pathinfo( $uri, PATHINFO_EXTENSION );
291 $ext = trim( $ext );
292 // Downfall from match URL to extension.
293 foreach ( $this->model->get_lockout_list( 'allowlist' ) as $pattern ) {
294 $pattern = preg_quote( $pattern, '/' );
295 if ( preg_match( '/' . $pattern . '$/i', $uri ) ) {
296 // Whitelisted, just return.
297 return;
298 }
299 }
300
301 foreach ( $this->model->get_lockout_list( 'blocklist' ) as $pattern ) {
302 $pattern = preg_quote( $pattern, '/' );
303 if ( preg_match( '/' . $pattern . '$/i', $uri ) ) {
304 $this->lock( $model, 'blacklist', $uri );
305 $this->log_event( $ip, $uri, self::SCENARIO_LOCKOUT_404 );
306
307 return;
308 }
309 }
310
311 if ( strlen( $ext ) ) {
312 // If ext isn't null.
313 foreach ( $this->model->get_lockout_list( 'allowlist' ) as $whitelist_ext ) {
314 if ( str_replace( '.', '', strtolower( $whitelist_ext ) ) === $ext ) {
315 // Ext is whitelist, log and return.
316 $this->log_event( $ip, $uri, self::SCENARIO_ERROR_404_IGNORE );
317
318 return;
319 }
320 }
321
322 foreach ( $this->model->get_lockout_list( 'blocklist' ) as $blacklist_ext ) {
323 if ( str_replace( '.', '', strtolower( $blacklist_ext ) ) === $ext ) {
324 // Block it.
325 $this->lock( $model, 'blacklist', $uri );
326 $this->log_event( $ip, $uri, self::SCENARIO_LOCKOUT_404 );
327
328 return;
329 }
330 }
331 }
332
333 $this->log_event( $ip, $uri, self::SCENARIO_ERROR_404 );
334
335 // Count the attempt.
336 $window = strtotime( '- ' . $this->model->timeframe . ' seconds' );
337
338 $model = $this->check_meta_data( $model );
339 // We will get the latest till oldest, limit by attempt.
340 $checks = array_slice( $model->meta['nf'], $this->model->attempt * - 1 );
341
342 if ( count( $checks ) < $this->model->attempt ) {
343 return;
344 }
345 // If the last time is larger.
346 $check = min( $checks );
347 if ( $check >= $window ) {
348 // then lock it.
349 $this->lock( $model, 'normal', $uri );
350 $this->log_event( $ip, $uri, self::SCENARIO_LOCKOUT_404 );
351 }
352 }
353
354 /**
355 * Locks the IP and updates the model accordingly.
356 *
357 * @param Lockout_Ip $model The Lockout_Ip model instance.
358 * @param string $scenario The scenario under which the lock is being applied.
359 * @param string $uri The URI that triggered the lockout.
360 */
361 private function lock( Lockout_Ip $model, $scenario = 'normal', $uri = '' ) {
362 if ( 'permanent' === $this->model->lockout_type ) {
363 $scenario = 'blacklist';
364 }
365
366 if ( 'blacklist' === $scenario ) {
367 $model->attempt_404 = 0;
368 $model->meta['nf'] = array();
369 } else {
370 $model->status = Lockout_Ip::STATUS_BLOCKED;
371 // @since 3.7.0. The lock_time column is used for temporary lockouts.
372 $model->lock_time = time();
373 $model->release_time = strtotime( '+ ' . $this->model->duration . ' ' . $this->model->duration_unit );
374 $model->lockout_message = $this->model->lockout_message;
375 }
376
377 $model->save();
378
379 if ( 'blacklist' === $scenario ) {
380 do_action( 'wd_blacklist_this_ip', $model->ip );
381 }
382
383 /**
384 * Action hook triggered when a user is locked due to a 404 lockout.
385 *
386 * @param Lockout_Ip $model The Lockout_Ip object representing the IP address being locked.
387 * @param string $scenario The scenario of the IP lockout ("normal" or "blacklist").
388 * @param string $uri The URI associated with the 404 lockout.
389 *
390 * @since 4.3.0 The `$uri` parameter was added.
391 */
392 do_action( 'wd_404_lockout', $model, $scenario, $uri );
393 }
394
395 /**
396 * Store the fail attempt of current IP.
397 *
398 * @param string $ip The IP address.
399 * @param Lockout_Ip $model The Lockout_Ip model instance.
400 *
401 * @return Lockout_Ip
402 */
403 protected function record_fail_attempt( $ip, $model ): Lockout_Ip {
404 // Fix warning with a non-numeric value.
405 if ( ! is_numeric( $model->attempt_404 ) ) {
406 $model->attempt_404 = 1;
407 } else {
408 ++$model->attempt_404;
409 }
410 $model->ip = $ip;
411
412 $model = $this->check_meta_data( $model );
413 // Cache the time here, so it consumes less memory than query the logs.
414 $model->meta['nf'][] = time();
415 $model->save();
416
417 return $model;
418 }
419
420 /**
421 * Ensures the metadata for the model is correctly initialized and formatted.
422 *
423 * @param Lockout_Ip $model The model to check and update.
424 *
425 * @return Lockout_Ip The checked and potentially modified model.
426 */
427 protected function check_meta_data( &$model ): Lockout_Ip {
428 if (
429 ! isset( $model->meta['nf'] ) ||
430 ( isset( $model->meta['nf'] ) && ! is_array( $model->meta['nf'] ) )
431 ) {
432 $model->meta['nf'] = array();
433 }
434
435 return $model;
436 }
437
438 /**
439 * Log the event into db, we will use the data in logs page later.
440 *
441 * @param string $ip The IP address involved in the event.
442 * @param string $uri The URI that was accessed.
443 * @param string $scenario The scenario under which the event is logged.
444 */
445 public function log_event( $ip, $uri, $scenario ) {
446 $model = new Lockout_Log();
447 $model->ip = $ip;
448 $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
449 $model->user_agent = isset( $user_agent ) ? User_Agent::fast_cleaning( $user_agent ) : null;
450 $model->date = time();
451 $model->tried = $uri;
452 $model->blog_id = get_current_blog_id();
453
454 $ip_to_country = $this->ip_to_country( $ip );
455
456 if ( isset( $ip_to_country['iso'] ) ) {
457 $model->country_iso_code = $ip_to_country['iso'];
458 }
459
460 switch ( $scenario ) {
461 case self::SCENARIO_ERROR_404:
462 $model->type = Lockout_Log::ERROR_404;
463 $model->log = sprintf(
464 /* translators: %s: URI. */
465 esc_html__( 'Request for file %s which doesn`t exist', 'defender-security' ),
466 $uri
467 );
468 break;
469 case self::SCENARIO_ERROR_404_IGNORE:
470 $model->type = Lockout_Log::ERROR_404_IGNORE;
471 $model->log = sprintf(
472 /* translators: %s: URI. */
473 esc_html__( 'Request for file %s which doesn`t exist', 'defender-security' ),
474 $uri
475 );
476 break;
477 // New log cases since v5.7.0.
478 case self::SCENARIO_404_INTELLIGENCE_XSS_ATTEMPT:
479 $model->type = Lockout_Log::ERROR_404_XSS;
480 $model->log = sprintf(
481 /* translators: %s: URI. */
482 esc_html__( 'Possible XSS attempt detected in request: %s', 'defender-security' ),
483 $uri
484 );
485 break;
486 case self::SCENARIO_404_INTELLIGENCE_XSS_LOCKOUT:
487 $model->type = Lockout_Log::LOCKOUT_404_XSS;
488 $model->log = esc_html__( 'Lockout occurred: Multiple XSS attempt requests detected', 'defender-security' );
489 break;
490 case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_ATTEMPT:
491 $model->type = Lockout_Log::ERROR_404_NON_EXISTENT_PLUGIN;
492 $model->log = sprintf(
493 /* translators: %s: URI. */
494 esc_html__( 'Attempt to access non-installed plugin: %s', 'defender-security' ),
495 $uri
496 );
497 break;
498 case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_PLUGIN_LOCKOUT:
499 $model->type = Lockout_Log::LOCKOUT_404_NON_EXISTENT_PLUGIN;
500 $model->log =
501 esc_html__( 'Lockout occurred: Multiple requests for non-installed plugin', 'defender-security' );
502 break;
503 case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_ATTEMPT:
504 $model->type = Lockout_Log::ERROR_404_NON_EXISTENT_THEME;
505 $model->log = sprintf(
506 /* translators: %s: URI. */
507 esc_html__( 'Attempt to access non-installed theme: %s', 'defender-security' ),
508 $uri
509 );
510 break;
511 case self::SCENARIO_404_INTELLIGENCE_NON_EXISTENT_THEME_LOCKOUT:
512 $model->type = Lockout_Log::LOCKOUT_404_NON_EXISTENT_THEME;
513 $model->log =
514 esc_html__( 'Lockout occurred: Multiple requests for non-installed theme', 'defender-security' );
515 break;
516 case self::SCENARIO_LOCKOUT_404:
517 default:
518 $model->type = Lockout_Log::LOCKOUT_404;
519 $model->log = sprintf(
520 /* translators: %s: URI. */
521 esc_html__( 'Lockout occurred: Too many 404 requests for %s', 'defender-security' ),
522 $uri
523 );
524 break;
525 }
526 $model->save();
527 // @since 5.4.0 Action hook triggered after a failed 404 attempt.
528 do_action( 'wd_404_attempt', $model, $scenario, $uri );
529
530 if ( in_array( $model->type, Lockout_Log::get_404_lockout_types(), true ) ) {
531 do_action( 'defender_notify', 'firewall-notification', $model );
532 }
533 }
534
535 /**
536 * Process 404 detection for multiple IPs.
537 *
538 * @return void
539 * @since 4.4.2
540 */
541 public function process_404_detect_multiple(): void {
542 if ( $this->is_intelligence || ! is_404() ) {
543 return;
544 }
545
546 if ( false === $this->model->detect_logged && is_user_logged_in() ) {
547 return;
548 }
549
550 $service = wd_di()->get( Blacklist_Lockout::class );
551 foreach ( $this->get_user_ip() as $ip ) {
552 if ( ! $service->is_ip_whitelisted( $ip ) ) {
553 $this->process_404_detect( $ip );
554 }
555 }
556 }
557 }
558