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

428 lines 13.5 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 use WP_Defender\Component\Known_Bots\Known_Bots_Factory;
15
16 /**
17 * Handles the detection of 404 errors and manages lockouts based on configured settings.
18 */
19 class Notfound_Lockout extends Component {
20
21 use Country;
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';
31 /**
32 * Use for cache.
33 *
34 * @var \WP_Defender\Model\Setting\Notfound_Lockout
35 */
36 public $model;
37
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 /**
46 * Constructor for Notfound_Lockout.
47 */
48 public function __construct() {
49 $this->model = wd_di()->get( \WP_Defender\Model\Setting\Notfound_Lockout::class );
50 add_action( 'init', array( $this, 'maybe_detect_suspicious_request' ), 1 );
51 }
52 /**
53 * Run 404 intelligence.
54 */
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;
59 }
60 if ( wp_doing_cron() ) {
61 return;
62 }
63 if ( wp_doing_ajax() ) {
64 return;
65 }
66 if ( false === $this->model->detect_logged && is_user_logged_in() ) {
67 return;
68 }
69
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 }
79 }
80 }
81
82 /**
83 * Detect if the current request:
84 * -refers to a non-existent plugin or theme,
85 * -contains XSS attempts.
86 *
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.
90 *
91 * @return void
92 */
93 protected function detect_suspicious_request( $ip, $uri, $query ) {
94 $lockout_type = '';
95
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;
106
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;
115 }
116 }
117 }
118
119 if ( '' !== $lockout_type ) {
120 // Detect it.
121 $model = Lockout_Ip::get( $ip );
122 $this->log_event( $ip, $slug, $attempt_type );
123
124 // Avoid the duplicate with the standard 404 case.
125 $this->is_intelligence = true;
126
127 $model = $this->record_fail_attempt( $ip, $model );
128 // Count the attempt.
129 $window = strtotime( '- ' . $this->model->timeframe . ' seconds' );
130
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 );
134
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 }
145 }
146 }
147
148 /**
149 * Queue hooks when this class init.
150 */
151 public function add_hooks() {
152 add_action( 'template_redirect', array( $this, 'process_404_detect_multiple' ) );
153 }
154
155 /**
156 * Processes 404 detection for a single IP address.
157 *
158 * @param string $ip The IP address to process.
159 */
160 public function process_404_detect( string $ip ): void {
161 // Check if request is from a legitimate known bot (Google, Bing, Facebook).
162 if ( Known_Bots_Factory::create()->is_known_bot( $ip ) ) {
163 return;
164 }
165
166 $uri = defender_get_data_from_request( 'REQUEST_URI', 's' );
167 // Strip encode.
168 $uri = urldecode( $uri );
169 $model = Lockout_Ip::get( $ip );
170 $model = $this->record_fail_attempt( $ip, $model );
171
172 $ext = pathinfo( $uri, PATHINFO_EXTENSION );
173 $ext = trim( $ext );
174 // Downfall from match URL to extension.
175 foreach ( $this->model->get_lockout_list( 'allowlist' ) as $pattern ) {
176 $pattern = preg_quote( $pattern, '/' );
177 if ( preg_match( '/' . $pattern . '$/i', $uri ) ) {
178 // Whitelisted, just return.
179 return;
180 }
181 }
182
183 foreach ( $this->model->get_lockout_list( 'blocklist' ) as $pattern ) {
184 $pattern = preg_quote( $pattern, '/' );
185 if ( preg_match( '/' . $pattern . '$/i', $uri ) ) {
186 $this->lock( $model, 'blacklist', $uri );
187 $this->log_event( $ip, $uri, self::SCENARIO_LOCKOUT_404 );
188
189 return;
190 }
191 }
192
193 if ( strlen( $ext ) ) {
194 // If ext isn't null.
195 foreach ( $this->model->get_lockout_list( 'allowlist' ) as $whitelist_ext ) {
196 if ( str_replace( '.', '', strtolower( $whitelist_ext ) ) === $ext ) {
197 // Ext is whitelist, log and return.
198 $this->log_event( $ip, $uri, self::SCENARIO_ERROR_404_IGNORE );
199
200 return;
201 }
202 }
203
204 foreach ( $this->model->get_lockout_list( 'blocklist' ) as $blacklist_ext ) {
205 if ( str_replace( '.', '', strtolower( $blacklist_ext ) ) === $ext ) {
206 // Block it.
207 $this->lock( $model, 'blacklist', $uri );
208 $this->log_event( $ip, $uri, self::SCENARIO_LOCKOUT_404 );
209
210 return;
211 }
212 }
213 }
214
215 $this->log_event( $ip, $uri, self::SCENARIO_ERROR_404 );
216
217 // Count the attempt.
218 $window = strtotime( '- ' . $this->model->timeframe . ' seconds' );
219
220 $model = $this->check_meta_data( $model );
221 // We will get the latest till oldest, limit by attempt.
222 $checks = array_slice( $model->meta['nf'], $this->model->attempt * - 1 );
223
224 if ( count( $checks ) < $this->model->attempt ) {
225 return;
226 }
227 // If the last time is larger.
228 $check = min( $checks );
229 if ( $check >= $window ) {
230 // then lock it.
231 $this->lock( $model, 'normal', $uri );
232 $this->log_event( $ip, $uri, self::SCENARIO_LOCKOUT_404 );
233 }
234 }
235
236 /**
237 * Locks the IP and updates the model accordingly.
238 *
239 * @param Lockout_Ip $model The Lockout_Ip model instance.
240 * @param string $scenario The scenario under which the lock is being applied.
241 * @param string $uri The URI that triggered the lockout.
242 */
243 private function lock( Lockout_Ip $model, $scenario = 'normal', $uri = '' ) {
244 if ( 'permanent' === $this->model->lockout_type ) {
245 $scenario = 'blacklist';
246 }
247
248 if ( 'blacklist' === $scenario ) {
249 $model->attempt_404 = 0;
250 $model->meta['nf'] = array();
251 } else {
252 $model->status = Lockout_Ip::STATUS_BLOCKED;
253 // @since 3.7.0. The lock_time column is used for temporary lockouts.
254 $model->lock_time = time();
255 $model->release_time = strtotime( '+ ' . $this->model->duration . ' ' . $this->model->duration_unit );
256 $model->lockout_message = $this->model->lockout_message;
257 }
258
259 $model->save();
260
261 if ( 'blacklist' === $scenario ) {
262 do_action( 'wd_blacklist_this_ip', $model->ip );
263 }
264
265 /**
266 * Action hook triggered when a user is locked due to a 404 lockout.
267 *
268 * @param Lockout_Ip $model The Lockout_Ip object representing the IP address being locked.
269 * @param string $scenario The scenario of the IP lockout ("normal" or "blacklist").
270 * @param string $uri The URI associated with the 404 lockout.
271 *
272 * @since 4.3.0 The `$uri` parameter was added.
273 */
274 do_action( 'wd_404_lockout', $model, $scenario, $uri );
275 }
276
277 /**
278 * Store the fail attempt of current IP.
279 *
280 * @param string $ip The IP address.
281 * @param Lockout_Ip $model The Lockout_Ip model instance.
282 *
283 * @return Lockout_Ip
284 */
285 protected function record_fail_attempt( $ip, $model ): Lockout_Ip {
286 // Fix warning with a non-numeric value.
287 if ( ! is_numeric( $model->attempt_404 ) ) {
288 $model->attempt_404 = 1;
289 } else {
290 ++$model->attempt_404;
291 }
292 $model->ip = $ip;
293
294 $model = $this->check_meta_data( $model );
295 // Cache the time here, so it consumes less memory than query the logs.
296 $model->meta['nf'][] = time();
297 $model->save();
298
299 return $model;
300 }
301
302 /**
303 * Ensures the metadata for the model is correctly initialized and formatted.
304 *
305 * @param Lockout_Ip $model The model to check and update.
306 *
307 * @return Lockout_Ip The checked and potentially modified model.
308 */
309 protected function check_meta_data( &$model ): Lockout_Ip {
310 if (
311 ! isset( $model->meta['nf'] ) ||
312 ( isset( $model->meta['nf'] ) && ! is_array( $model->meta['nf'] ) )
313 ) {
314 $model->meta['nf'] = array();
315 }
316
317 return $model;
318 }
319
320 /**
321 * Log the event into db, we will use the data in logs page later.
322 *
323 * @param string $ip The IP address involved in the event.
324 * @param string $uri The URI that was accessed.
325 * @param string $scenario The scenario under which the event is logged.
326 */
327 public function log_event( $ip, $uri, $scenario ) {
328 $model = Lockout_Log::create( $ip, $uri );
329
330 switch ( $scenario ) {
331 case self::SCENARIO_ERROR_404:
332 $model->type = Lockout_Log::ERROR_404;
333 $model->log = sprintf(
334 /* translators: %s: URI. */
335 esc_html__( 'Request for file %s which doesn`t exist', 'defender-security' ),
336 $uri
337 );
338 break;
339 case self::SCENARIO_ERROR_404_IGNORE:
340 $model->type = Lockout_Log::ERROR_404_IGNORE;
341 $model->log = sprintf(
342 /* translators: %s: URI. */
343 esc_html__( 'Request for file %s which doesn`t exist', 'defender-security' ),
344 $uri
345 );
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;
386 case self::SCENARIO_LOCKOUT_404:
387 default:
388 $model->type = Lockout_Log::LOCKOUT_404;
389 $model->log = sprintf(
390 /* translators: %s: URI. */
391 esc_html__( 'Lockout occurred: Too many 404 requests for %s', 'defender-security' ),
392 $uri
393 );
394 break;
395 }
396 $model->save();
397 // @since 5.4.0 Action hook triggered after a failed 404 attempt.
398 do_action( 'wd_404_attempt', $model, $scenario, $uri );
399
400 if ( in_array( $model->type, Lockout_Log::get_404_lockout_types(), true ) ) {
401 do_action( 'defender_notify', 'firewall-notification', $model );
402 }
403 }
404
405 /**
406 * Process 404 detection for multiple IPs.
407 *
408 * @return void
409 * @since 4.4.2
410 */
411 public function process_404_detect_multiple(): void {
412 if ( $this->is_intelligence || ! is_404() ) {
413 return;
414 }
415
416 if ( false === $this->model->detect_logged && is_user_logged_in() ) {
417 return;
418 }
419
420 $service = wd_di()->get( Blacklist_Lockout::class );
421 foreach ( $this->get_user_ip() as $ip ) {
422 if ( ! $service->is_ip_whitelisted( $ip ) ) {
423 $this->process_404_detect( $ip );
424 }
425 }
426 }
427 }
428