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
← All changes | src/component/class-blacklist-lockout.php +188 -32 5.3.1 → 6.3.0 View file →
@@ -11,14 +11,15 @@
11 11 use WP_Defender\Component;
12 12 use WP_Defender\Traits\IP;
13 13 use Calotes\Helper\Array_Cache;
14 14 use WP_Defender\Traits\Country;
15 +use WP_Defender\Traits\Continent;
15 16 use WP_Defender\Model\Lockout_Log;
16 -use MaxMind\Db\Reader\InvalidDatabaseException;
17 +use WP_DEFENDER_VENDOR\MaxMind\Db\Reader\InvalidDatabaseException;
17 18 use WP_Defender\Integrations\MaxMind_Geolocation;
18 19 use WP_Defender\Model\Setting\Blacklist_Lockout as Model_Blacklist_Lockout;
19 -use WP_Defender\Component\Firewall;
20 20 use WP_Defender\Integrations\Main_Wp;
21 +use WP_Filesystem_Base;
21 22
22 23 /**
23 24 * Handles operations related to IP and country-based blacklisting and whitelisting.
24 25 */
@@ -25,13 +26,42 @@
25 26 class Blacklist_Lockout extends Component {
26 27
27 28 use Country;
28 29 use IP;
30 + use Continent;
29 31
30 32 // Define the transient key.
31 33 const IP_LIST_KEY = 'wpmu_dev_ip_list';
32 34
33 35 /**
36 + * Check if a country code belongs to a continent.
37 + *
38 + * @param string $country_iso The country ISO code to check.
39 + * @param string $continent_code The continent code (EU, AS, AF, AM, OC).
40 + * @return bool True if the country belongs to the continent, false otherwise.
41 + */
42 + private function is_country_in_continent( $country_iso, $continent_code ): bool {
43 + $countries_with_continents = $this->get_countries_with_continents();
44 +
45 + if ( ! isset( $countries_with_continents[ $continent_code ] ) ) {
46 + return false;
47 + }
48 +
49 + $continent = $countries_with_continents[ $continent_code ];
50 + if ( ! isset( $continent['area'] ) ) {
51 + return false;
52 + }
53 +
54 + foreach ( $continent['area'] as $area ) {
55 + if ( isset( $area['countries'][ $country_iso ] ) ) {
56 + return true;
57 + }
58 + }
59 +
60 + return false;
61 + }
62 +
63 + /**
34 64 * Checks if a given IP is whitelisted based on the country.
35 65 *
36 66 * @param string $ip The IP address to check.
37 67 *
@@ -45,13 +75,18 @@
45 75 return false;
46 76 }
47 77 $model = new Model_Blacklist_Lockout();
48 78 $whitelist = $model->get_country_whitelist();
49 - if ( empty( $whitelist ) ) {
79 + if ( array() === $whitelist ) {
50 80 return false;
51 81 }
52 - if ( ! empty( $country['iso'] ) && in_array( strtoupper( $country['iso'] ), $whitelist, true ) ) {
53 - return true;
82 +
83 + if ( isset( $country['iso'] ) && '' !== $country['iso'] ) {
84 + $country_iso = strtoupper( $country['iso'] );
85 +
86 + if ( $this->is_country_iso_in_list( $country_iso, $whitelist ) ) {
87 + return true;
88 + }
54 89 }
55 90
56 91 return false;
57 92 }
@@ -64,14 +99,15 @@
64 99 private function get_default_ip_whitelisted(): array {
65 100 $server_addr = defender_get_data_from_request( 'SERVER_ADDR', 's' );
66 101 $remote_addr = defender_get_data_from_request( 'REMOTE_ADDR', 's' );
67 102 $ips = array(
68 - ...$this->fetch_latest_ips(),
103 + ...self::fetch_latest_ips(),
69 104 '127.0.0.1',
70 - isset( $server_addr ) ? $server_addr : $remote_addr,
105 + $server_addr ?? $remote_addr,
71 106 );
72 107
73 - return (array) apply_filters( 'ip_lockout_default_whitelist_ip', $ips );
108 + $whitelist_ips = apply_filters( 'ip_lockout_default_whitelist_ip', $ips );
109 + return is_array( $whitelist_ips ) ? $whitelist_ips : (array) $whitelist_ips;
74 110 }
75 111
76 112 /**
77 113 * Fetch the latest IP list from the API.
@@ -80,9 +116,9 @@
80 116 */
81 117 public static function fetch_latest_ips(): array {
82 118 // Attempt to retrieve IPs from the cache.
83 119 $cached_ips = get_site_transient( self::IP_LIST_KEY );
84 - if ( $cached_ips && is_array( $cached_ips ) ) {
120 + if ( is_array( $cached_ips ) && array() !== $cached_ips ) {
85 121 return $cached_ips;
86 122 }
87 123
88 124 // Fetch the IP list from the remote API.
@@ -99,17 +135,25 @@
99 135 }
100 136
101 137 // Retrieve the response body.
102 138 $body = wp_remote_retrieve_body( $response );
103 - if ( empty( $body ) ) {
139 + if ( '' === $body ) {
104 140 return array();
105 141 }
106 142
107 143 // Process the response body into an array of IPs.
108 - $ip_list = array_filter( array_map( 'sanitize_text_field', explode( "\n", $body ) ) );
144 + $ip_list = array_filter(
145 + array_map(
146 + 'sanitize_text_field',
147 + explode( "\n", $body )
148 + ),
149 + function ( $value ) {
150 + return '' !== trim( $value );
151 + }
152 + );
109 153
110 154 // Cache the IP list for a week if it is not empty.
111 - if ( ! empty( $ip_list ) ) {
155 + if ( array() !== $ip_list ) {
112 156 set_site_transient( self::IP_LIST_KEY, $ip_list, WEEK_IN_SECONDS );
113 157 }
114 158
115 159 // Return the fetched IP list.
@@ -129,15 +173,15 @@
129 173 }
130 174
131 175 // If the IP is the server public IP.
132 176 $server_public_ip = wd_di()->get( Firewall::class )->get_whitelist_server_public_ip();
133 - if ( ! empty( $server_public_ip ) && $server_public_ip === $ip ) {
177 + if ( '' !== $server_public_ip && $server_public_ip === $ip ) {
134 178 return true;
135 179 }
136 180
137 181 // If the IP is the MainWP Dashboard public IP.
138 182 $mainwp_dashboard_public_ip = wd_di()->get( Main_Wp::class )->get_whitelist_dashboard_public_ip();
139 - if ( ! empty( $mainwp_dashboard_public_ip ) && in_array( $ip, $mainwp_dashboard_public_ip, true ) ) {
183 + if ( array() !== $mainwp_dashboard_public_ip && in_array( $ip, $mainwp_dashboard_public_ip, true ) ) {
140 184 return true;
141 185 }
142 186
143 187 $blacklist_settings = new Model_Blacklist_Lockout();
@@ -173,16 +217,21 @@
173 217 return false;
174 218 }
175 219 $blacklist_settings = new Model_Blacklist_Lockout();
176 220 $blacklisted = $blacklist_settings->get_country_blacklist();
177 - if ( empty( $blacklisted ) ) {
221 + if ( array() === $blacklisted ) {
178 222 return false;
179 223 }
180 224 if ( in_array( 'all', $blacklisted, true ) ) {
181 225 return true;
182 226 }
183 - if ( ! empty( $country['iso'] ) && in_array( strtoupper( $country['iso'] ), $blacklisted, true ) ) {
184 - return true;
227 +
228 + if ( isset( $country['iso'] ) && '' !== $country['iso'] ) {
229 + $country_iso = strtoupper( $country['iso'] );
230 +
231 + if ( $this->is_country_iso_in_list( $country_iso, $blacklisted ) ) {
232 + return true;
233 + }
185 234 }
186 235
187 236 return false;
188 237 }
@@ -196,9 +245,9 @@
196 245 */
197 246 public function verify_import_file( $file ) {
198 247 global $wp_filesystem;
199 248 // Initialize the WP filesystem, no more using 'file-put-contents' function.
200 - if ( empty( $wp_filesystem ) ) {
249 + if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
201 250 require_once ABSPATH . '/wp-admin/includes/file.php';
202 251 WP_Filesystem();
203 252 }
204 253 $lines = $wp_filesystem->get_contents_array( $file );
@@ -228,9 +277,9 @@
228 277 * @return Model_Blacklist_Lockout The updated model.
229 278 * @since 2.8.0
230 279 */
231 280 public function add_default_whitelisted_country( Model_Blacklist_Lockout $model, $country_iso ) {
232 - if ( empty( $model->country_whitelist ) ) {
281 + if ( array() === $model->country_whitelist ) {
233 282 $model->country_whitelist[] = $country_iso;
234 283 } elseif ( ! in_array( $country_iso, $model->country_whitelist, true ) ) {
235 284 $model->country_whitelist[] = $country_iso;
236 285 }
@@ -247,9 +296,9 @@
247 296 public function is_geodb_downloaded(): bool {
248 297 $model = new Model_Blacklist_Lockout();
249 298 $license_key = $model->maxmind_license_key;
250 299 // Using Geo DB without a license key is not advisable.
251 - if ( empty( $license_key ) && is_file( $model->geodb_path ) ) {
300 + if ( '' === $license_key && is_string( $model->geodb_path ) && is_file( $model->geodb_path ) ) {
252 301 $service_geo = wd_di()->get( MaxMind_Geolocation::class );
253 302 $service_geo->delete_database();
254 303 $model->geodb_path = '';
255 304 $model->save();
@@ -258,10 +307,10 @@
258 307 }
259 308
260 309 // Likely the case after the config import with the existed MaxMind license key.
261 310 if (
262 - ! empty( $license_key )
263 - && ( is_null( $model->geodb_path ) || ! is_file( $model->geodb_path ) )
311 + '' !== $license_key
312 + && ( ! is_string( $model->geodb_path ) || ! is_file( $model->geodb_path ) )
264 313 ) {
265 314 $service_geo = wd_di()->get( MaxMind_Geolocation::class );
266 315 $tmp = $service_geo->get_downloaded_url( $license_key );
267 316 if ( ! is_wp_error( $tmp ) ) {
@@ -278,14 +327,14 @@
278 327 if ( file_exists( $tmp ) ) {
279 328 wp_delete_file( $tmp );
280 329 }
281 330
282 - if ( empty( $model->country_whitelist ) ) {
331 + if ( array() === $model->country_whitelist ) {
283 332 $is_country = false;
284 333 foreach ( $this->get_user_ip() as $ip ) {
285 334 $country = $this->get_current_country( $ip );
286 335
287 - if ( ! empty( $country['iso'] ) ) {
336 + if ( isset( $country['iso'] ) && '' !== $country['iso'] ) {
288 337 $model = $this->add_default_whitelisted_country( $model, $country['iso'] );
289 338 $is_country = true;
290 339 }
291 340 }
@@ -298,9 +347,9 @@
298 347 }
299 348 }
300 349
301 350 // Check again.
302 - if ( is_null( $model->geodb_path ) || ! is_file( $model->geodb_path ) ) {
351 + if ( ! is_string( $model->geodb_path ) || ! is_file( $model->geodb_path ) ) {
303 352 return false;
304 353 }
305 354
306 355 // Check if the file exists on the site. The file can exist on the same server but for different sites.
@@ -316,16 +365,25 @@
316 365
317 366 $rel_path = $abs_path . DIRECTORY_SEPARATOR . $path_parts['basename'];
318 367 if ( file_exists( $rel_path ) ) {
319 368 return true;
320 - } elseif ( ! empty( $model->geodb_path ) && file_exists( $model->geodb_path ) ) {
369 + } elseif ( '' !== $model->geodb_path && file_exists( $model->geodb_path ) ) {
321 370 // The case if ABSPATH was changed e.g. in wp-config.php.
322 371 return true;
323 372 }
324 373
325 - if ( move_uploaded_file( $model->geodb_path, $rel_path ) ) {
326 - $model->geodb_path = $rel_path;
327 - $model->save();
374 + if ( $this->validate_geodb_file( $model->geodb_path ) ) {
375 + global $wp_filesystem;
376 + if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
377 + require_once ABSPATH . '/wp-admin/includes/file.php';
378 + WP_Filesystem();
379 + }
380 +
381 + if ( $wp_filesystem->move( $model->geodb_path, $rel_path ) ) {
382 + $wp_filesystem->chmod( $rel_path, 0644 );
383 + $model->geodb_path = $rel_path;
384 + $model->save();
385 + }
328 386 } else {
329 387 return false;
330 388 }
331 389 }
@@ -333,8 +391,53 @@
333 391 return true;
334 392 }
335 393
336 394 /**
395 + * Validates GeoDB file for security before processing.
396 + *
397 + * @param string $file_path Path to the file to validate.
398 + * @return bool True if file is valid, false otherwise.
399 + */
400 + private function validate_geodb_file( $file_path ): bool {
401 + if ( ! file_exists( $file_path ) ) {
402 + return false;
403 + }
404 +
405 + $allowed_extensions = array( 'mmdb' );
406 + $file_extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
407 +
408 + if ( ! in_array( $file_extension, $allowed_extensions, true ) ) {
409 + return false;
410 + }
411 +
412 + $file_size = filesize( $file_path );
413 + $max_size = 100 * 1024 * 1024; // 100MB.
414 +
415 + if ( $max_size < $file_size || 0 === $file_size ) {
416 + return false;
417 + }
418 +
419 + global $wp_filesystem;
420 + if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
421 + require_once ABSPATH . 'wp-admin/includes/file.php';
422 + WP_Filesystem();
423 + }
424 +
425 + // Read only first 4 bytes for header validation.
426 + $header = $wp_filesystem->get_contents( $file_path );
427 + if ( false === $header || strlen( $header ) < 4 ) {
428 + return false;
429 + }
430 + $header = substr( $header, 0, 4 );
431 + // MaxMind DB format magic bytes: 0xABCDEF00 .
432 + if ( "\xab\xcd\xef\x00" !== $header ) {
433 + return false;
434 + }
435 +
436 + return true;
437 + }
438 +
439 + /**
337 440 * Retrieves the top countries from which IPs have been blocked.
338 441 *
339 442 * @param int $limit The maximum number of countries to return.
340 443 * @param int $max_age_days The maximum age of log entries to consider.
@@ -342,9 +445,9 @@
342 445 * @return array List of countries and their blocked IP count.
343 446 */
344 447 public function get_top_countries_blocked( $limit = 10, $max_age_days = 7 ) {
345 448 $result = Array_Cache::get( 'countries', 'ip_lockout', array() );
346 - if ( empty( $result ) ) {
449 + if ( array() === $result ) {
347 450 global $wpdb;
348 451
349 452 $result = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
350 453 $wpdb->prepare(
@@ -351,8 +454,9 @@
351 454 "SELECT country_iso_code, COUNT(ip) AS ip_count FROM {$wpdb->base_prefix}defender_lockout_log WHERE (type = %s OR type = %s OR type = %s) AND date >= %d AND country_iso_code IS NOT NULL GROUP BY country_iso_code ORDER BY ip_count DESC LIMIT %d",
352 455 Lockout_Log::LOCKOUT_404,
353 456 Lockout_Log::AUTH_LOCK,
354 457 Lockout_Log::LOCKOUT_UA,
458 + // todo: add other 404- and UA-lockouts.
355 459 strtotime( '-' . $max_age_days . ' days', time() ),
356 460 $limit
357 461 ),
358 462 ARRAY_A
@@ -360,9 +464,9 @@
360 464 // Get data from cache.
361 465 Array_Cache::set( 'countries', $result, 'ip_lockout' );
362 466 }
363 467
364 - return ! empty( $result ) ? $result : array();
468 + return array() !== $result ? $result : array();
365 469 }
366 470
367 471 /**
368 472 * Is IP on BLC Whitelist?
@@ -378,9 +482,9 @@
378 482 '144.202.86.106',
379 483 );
380 484 $diff = array_diff( $ips, $blc_ips );
381 485
382 - return empty( $diff );
486 + return array() === $diff;
383 487 }
384 488
385 489 /**
386 490 * Checks if a list of IPs are all whitelisted.
@@ -397,6 +501,58 @@
397 501 }
398 502 }
399 503
400 504 return true;
505 + }
506 +
507 + /**
508 + * Check whether a code identifies a continent without also identifying a country.
509 + *
510 + * @param string $code The code to check.
511 + * @return bool True when the code identifies only a continent.
512 + */
513 + private function is_continent_code( $code ): bool {
514 + $countries_with_continents = $this->get_countries_with_continents();
515 +
516 + if ( ! isset( $countries_with_continents[ $code ] ) ) {
517 + return false;
518 + }
519 +
520 + foreach ( $countries_with_continents as $continent ) {
521 + if ( ! isset( $continent['area'] ) ) {
522 + continue;
523 + }
524 +
525 + foreach ( $continent['area'] as $area ) {
526 + if ( isset( $area['countries'][ $code ] ) ) {
527 + return false;
528 + }
529 + }
530 + }
531 +
532 + return true;
533 + }
534 +
535 + /**
536 + * Checks whether a country ISO code (or any continent it belongs to) is present in the given list.
537 + *
538 + * @param string $country_iso The upcased two-letter ISO country code.
539 + * @param array $iso_list The list of country/continent codes to check against.
540 + *
541 + * @return bool True if the country or one of its continents is found in the list.
542 + */
543 + private function is_country_iso_in_list( string $country_iso, array $iso_list ): bool {
544 + // Check if the specific country is in the list.
545 + if ( in_array( $country_iso, $iso_list, true ) ) {
546 + return true;
547 + }
548 +
549 + // Check if any continent containing this country is in the list.
550 + foreach ( $iso_list as $code ) {
551 + if ( $this->is_continent_code( $code ) && $this->is_country_in_continent( $country_iso, $code ) ) {
552 + return true;
553 + }
554 + }
555 +
556 + return false;
401 557 }
402 558 }