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-blacklist-lockout.php

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

559 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles IP and country-based access restrictions.
4 *
5 * @package WP_Defender\Component
6 */
7
8 namespace WP_Defender\Component;
9
10 use PharData;
11 use WP_Defender\Component;
12 use WP_Defender\Traits\IP;
13 use Calotes\Helper\Array_Cache;
14 use WP_Defender\Traits\Country;
15 use WP_Defender\Traits\Continent;
16 use WP_Defender\Model\Lockout_Log;
17 use WP_DEFENDER_VENDOR\MaxMind\Db\Reader\InvalidDatabaseException;
18 use WP_Defender\Integrations\MaxMind_Geolocation;
19 use WP_Defender\Model\Setting\Blacklist_Lockout as Model_Blacklist_Lockout;
20 use WP_Defender\Integrations\Main_Wp;
21 use WP_Filesystem_Base;
22
23 /**
24 * Handles operations related to IP and country-based blacklisting and whitelisting.
25 */
26 class Blacklist_Lockout extends Component {
27
28 use Country;
29 use IP;
30 use Continent;
31
32 // Define the transient key.
33 const IP_LIST_KEY = 'wpmu_dev_ip_list';
34
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 /**
64 * Checks if a given IP is whitelisted based on the country.
65 *
66 * @param string $ip The IP address to check.
67 *
68 * @return bool True if the IP is whitelisted based on the country, false otherwise.
69 * @throws InvalidDatabaseException Thrown for unexpected data is found in DB.
70 */
71 public function is_country_whitelist( $ip ): bool {
72 // Check Firewall > IP Banning > Locations section is activated or not.
73 $country = $this->get_current_country( $ip );
74 if ( false === $country ) {
75 return false;
76 }
77 $model = new Model_Blacklist_Lockout();
78 $whitelist = $model->get_country_whitelist();
79 if ( array() === $whitelist ) {
80 return false;
81 }
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 }
89 }
90
91 return false;
92 }
93
94 /**
95 * Return the default ips need to whitelisted, e.g. HUB ips.
96 *
97 * @return array
98 */
99 private function get_default_ip_whitelisted(): array {
100 $server_addr = defender_get_data_from_request( 'SERVER_ADDR', 's' );
101 $remote_addr = defender_get_data_from_request( 'REMOTE_ADDR', 's' );
102 $ips = array(
103 ...self::fetch_latest_ips(),
104 '127.0.0.1',
105 $server_addr ?? $remote_addr,
106 );
107
108 $whitelist_ips = apply_filters( 'ip_lockout_default_whitelist_ip', $ips );
109 return is_array( $whitelist_ips ) ? $whitelist_ips : (array) $whitelist_ips;
110 }
111
112 /**
113 * Fetch the latest IP list from the API.
114 *
115 * @return array List of IPs or an empty array on failure.
116 */
117 public static function fetch_latest_ips(): array {
118 // Attempt to retrieve IPs from the cache.
119 $cached_ips = get_site_transient( self::IP_LIST_KEY );
120 if ( is_array( $cached_ips ) && array() !== $cached_ips ) {
121 return $cached_ips;
122 }
123
124 // Fetch the IP list from the remote API.
125 $response = wp_remote_get(
126 'https://gist.githubusercontent.com/wpmu-docs/568114153e93eaf28f908a724b313b6f/raw',
127 array(
128 'timeout' => 10,
129 )
130 );
131
132 // Check for errors in the API response.
133 if ( is_wp_error( $response ) ) {
134 return array();
135 }
136
137 // Retrieve the response body.
138 $body = wp_remote_retrieve_body( $response );
139 if ( '' === $body ) {
140 return array();
141 }
142
143 // Process the response body into an array of IPs.
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 );
153
154 // Cache the IP list for a week if it is not empty.
155 if ( array() !== $ip_list ) {
156 set_site_transient( self::IP_LIST_KEY, $ip_list, WEEK_IN_SECONDS );
157 }
158
159 // Return the fetched IP list.
160 return $ip_list;
161 }
162
163 /**
164 * Checks if an IP is whitelisted.
165 *
166 * @param string $ip The IP address to check.
167 *
168 * @return bool True if the IP is whitelisted, false otherwise.
169 */
170 public function is_ip_whitelisted( $ip ): bool {
171 if ( in_array( $ip, $this->get_default_ip_whitelisted(), true ) ) {
172 return true;
173 }
174
175 // If the IP is the server public IP.
176 $server_public_ip = wd_di()->get( Firewall::class )->get_whitelist_server_public_ip();
177 if ( '' !== $server_public_ip && $server_public_ip === $ip ) {
178 return true;
179 }
180
181 // If the IP is the MainWP Dashboard public IP.
182 $mainwp_dashboard_public_ip = wd_di()->get( Main_Wp::class )->get_whitelist_dashboard_public_ip();
183 if ( array() !== $mainwp_dashboard_public_ip && in_array( $ip, $mainwp_dashboard_public_ip, true ) ) {
184 return true;
185 }
186
187 $blacklist_settings = new Model_Blacklist_Lockout();
188
189 return $this->is_ip_in_format( $ip, $blacklist_settings->get_list( 'allowlist' ) );
190 }
191
192 /**
193 * Checks if an IP is blacklisted.
194 *
195 * @param string $ip The IP address to check.
196 *
197 * @return bool True if the IP is blacklisted, false otherwise.
198 */
199 public function is_blacklist( $ip ) {
200 $blacklist_settings = new Model_Blacklist_Lockout();
201
202 return $this->is_ip_in_format( $ip, $blacklist_settings->get_list( 'blocklist' ) );
203 }
204
205 /**
206 * Checks if a country is blacklisted based on the IP address.
207 *
208 * @param string $ip The IP address to check.
209 *
210 * @return bool True if the country is blacklisted, false otherwise.
211 * @throws InvalidDatabaseException Thrown for unexpected data is found in DB.
212 */
213 public function is_country_blacklist( $ip ): bool {
214 // Check Firewall > IP Banning > Locations section is activated or not.
215 $country = $this->get_current_country( $ip );
216 if ( false === $country ) {
217 return false;
218 }
219 $blacklist_settings = new Model_Blacklist_Lockout();
220 $blacklisted = $blacklist_settings->get_country_blacklist();
221 if ( array() === $blacklisted ) {
222 return false;
223 }
224 if ( in_array( 'all', $blacklisted, true ) ) {
225 return true;
226 }
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 }
234 }
235
236 return false;
237 }
238
239 /**
240 * Verifies the format of an imported file for IP lockout settings.
241 *
242 * @param string $file Path to the file to verify.
243 *
244 * @return array|bool Array of data if the file is valid, false otherwise.
245 */
246 public function verify_import_file( $file ) {
247 global $wp_filesystem;
248 // Initialize the WP filesystem, no more using 'file-put-contents' function.
249 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
250 require_once ABSPATH . '/wp-admin/includes/file.php';
251 WP_Filesystem();
252 }
253 $lines = $wp_filesystem->get_contents_array( $file );
254 $data = array();
255 foreach ( $lines as $line ) {
256 $line = str_getcsv( $line, ',', '"', '\\' );
257 if ( count( $line ) !== 2 ) {
258 return false;
259 }
260 if ( ! in_array( $line[1], array( 'allowlist', 'blocklist' ), true ) ) {
261 return false;
262 }
263 if ( false === $this->validate_ip( $line[0] ) ) {
264 continue;
265 }
266 $data[] = $line;
267 }
268 return $data;
269 }
270
271 /**
272 * Adds a default whitelisted country to the model.
273 *
274 * @param Model_Blacklist_Lockout $model The model to update.
275 * @param string $country_iso The ISO code of the country to whitelist.
276 *
277 * @return Model_Blacklist_Lockout The updated model.
278 * @since 2.8.0
279 */
280 public function add_default_whitelisted_country( Model_Blacklist_Lockout $model, $country_iso ) {
281 if ( array() === $model->country_whitelist ) {
282 $model->country_whitelist[] = $country_iso;
283 } elseif ( ! in_array( $country_iso, $model->country_whitelist, true ) ) {
284 $model->country_whitelist[] = $country_iso;
285 }
286
287 return $model;
288 }
289
290 /**
291 * Check downloaded GeoDB.
292 *
293 * @return bool
294 * @throws InvalidDatabaseException Thrown for unexpected data is found in DB.
295 */
296 public function is_geodb_downloaded(): bool {
297 $model = new Model_Blacklist_Lockout();
298 $license_key = $model->maxmind_license_key;
299 // Using Geo DB without a license key is not advisable.
300 if ( '' === $license_key && is_string( $model->geodb_path ) && is_file( $model->geodb_path ) ) {
301 $service_geo = wd_di()->get( MaxMind_Geolocation::class );
302 $service_geo->delete_database();
303 $model->geodb_path = '';
304 $model->save();
305
306 return false;
307 }
308
309 // Likely the case after the config import with the existed MaxMind license key.
310 if (
311 '' !== $license_key
312 && ( ! is_string( $model->geodb_path ) || ! is_file( $model->geodb_path ) )
313 ) {
314 $service_geo = wd_di()->get( MaxMind_Geolocation::class );
315 $tmp = $service_geo->get_downloaded_url( $license_key );
316 if ( ! is_wp_error( $tmp ) ) {
317 $phar = new PharData( $tmp );
318 $path = $service_geo->get_db_base_path();
319 if ( ! is_dir( $path ) ) {
320 wp_mkdir_p( $path );
321 }
322 $phar->extractTo( $path, null, true );
323 $model->geodb_path = $path . DIRECTORY_SEPARATOR . $phar->current()->getFileName() . DIRECTORY_SEPARATOR . $service_geo->get_db_full_name();
324 // Save because we'll check for a saved path.
325 $model->save();
326
327 if ( file_exists( $tmp ) ) {
328 wp_delete_file( $tmp );
329 }
330
331 if ( array() === $model->country_whitelist ) {
332 $is_country = false;
333 foreach ( $this->get_user_ip() as $ip ) {
334 $country = $this->get_current_country( $ip );
335
336 if ( isset( $country['iso'] ) && '' !== $country['iso'] ) {
337 $model = $this->add_default_whitelisted_country( $model, $country['iso'] );
338 $is_country = true;
339 }
340 }
341
342 if ( false === $is_country ) {
343 return false;
344 }
345 }
346 $model->save();
347 }
348 }
349
350 // Check again.
351 if ( ! is_string( $model->geodb_path ) || ! is_file( $model->geodb_path ) ) {
352 return false;
353 }
354
355 // Check if the file exists on the site. The file can exist on the same server but for different sites.
356 // For example, after config importing.
357 $path_parts = pathinfo( $model->geodb_path );
358 if ( preg_match( '/(\/wp-content\/.+)/', $path_parts['dirname'], $matches ) ) {
359 $rel_path = $matches[1];
360 $rel_path = ltrim( $rel_path, '/' );
361 $abs_path = ABSPATH . $rel_path;
362 if ( ! is_dir( $abs_path ) ) {
363 wp_mkdir_p( $abs_path );
364 }
365
366 $rel_path = $abs_path . DIRECTORY_SEPARATOR . $path_parts['basename'];
367 if ( file_exists( $rel_path ) ) {
368 return true;
369 } elseif ( '' !== $model->geodb_path && file_exists( $model->geodb_path ) ) {
370 // The case if ABSPATH was changed e.g. in wp-config.php.
371 return true;
372 }
373
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 }
386 } else {
387 return false;
388 }
389 }
390
391 return true;
392 }
393
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 /**
440 * Retrieves the top countries from which IPs have been blocked.
441 *
442 * @param int $limit The maximum number of countries to return.
443 * @param int $max_age_days The maximum age of log entries to consider.
444 *
445 * @return array List of countries and their blocked IP count.
446 */
447 public function get_top_countries_blocked( $limit = 10, $max_age_days = 7 ) {
448 $result = Array_Cache::get( 'countries', 'ip_lockout', array() );
449 if ( array() === $result ) {
450 global $wpdb;
451
452 $result = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
453 $wpdb->prepare(
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",
455 Lockout_Log::LOCKOUT_404,
456 Lockout_Log::AUTH_LOCK,
457 Lockout_Log::LOCKOUT_UA,
458 // todo: add other 404- and UA-lockouts.
459 strtotime( '-' . $max_age_days . ' days', time() ),
460 $limit
461 ),
462 ARRAY_A
463 );
464 // Get data from cache.
465 Array_Cache::set( 'countries', $result, 'ip_lockout' );
466 }
467
468 return array() !== $result ? $result : array();
469 }
470
471 /**
472 * Is IP on BLC Whitelist?
473 *
474 * @return bool
475 * @since 4.2.0
476 */
477 public function is_blc_ip_whitelisted(): bool {
478 $ips = $this->get_user_ip();
479 $blc_ips = array(
480 '165.227.127.103',
481 '64.176.196.23',
482 '144.202.86.106',
483 );
484 $diff = array_diff( $ips, $blc_ips );
485
486 return array() === $diff;
487 }
488
489 /**
490 * Checks if a list of IPs are all whitelisted.
491 *
492 * @param array $ips List of IPs to check.
493 *
494 * @return bool True if all IPs are whitelisted, false otherwise.
495 * @since 4.4.2
496 */
497 public function are_ips_whitelisted( array $ips ): bool {
498 foreach ( $ips as $ip ) {
499 if ( ! $this->is_ip_whitelisted( $ip ) ) {
500 return false;
501 }
502 }
503
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;
557 }
558 }
559