PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.7.0
Defender Security – Malware Scanner, Login Security & Firewall v5.7.0
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 5.7.0, at src/component/class-notfound-lockout.php

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