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

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

513 lines 14.4 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 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 ( empty( $whitelist ) ) {
80 return false;
81 }
82
83 if ( ! empty( $country['iso'] ) ) {
84 $country_iso = strtoupper( $country['iso'] );
85
86 // Check if the specific country is in the whitelist.
87 if ( in_array( $country_iso, $whitelist, true ) ) {
88 return true;
89 }
90
91 // Check if any continent containing this country is in the whitelist.
92 foreach ( $whitelist as $allowed_code ) {
93 if ( $this->is_country_in_continent( $country_iso, $allowed_code ) ) {
94 return true;
95 }
96 }
97 }
98
99 return false;
100 }
101
102 /**
103 * Return the default ips need to whitelisted, e.g. HUB ips.
104 *
105 * @return array
106 */
107 private function get_default_ip_whitelisted(): array {
108 $server_addr = defender_get_data_from_request( 'SERVER_ADDR', 's' );
109 $remote_addr = defender_get_data_from_request( 'REMOTE_ADDR', 's' );
110 $ips = array(
111 ...$this->fetch_latest_ips(),
112 '127.0.0.1',
113 isset( $server_addr ) ? $server_addr : $remote_addr,
114 );
115
116 return (array) apply_filters( 'ip_lockout_default_whitelist_ip', $ips );
117 }
118
119 /**
120 * Fetch the latest IP list from the API.
121 *
122 * @return array List of IPs or an empty array on failure.
123 */
124 public static function fetch_latest_ips(): array {
125 // Attempt to retrieve IPs from the cache.
126 $cached_ips = get_site_transient( self::IP_LIST_KEY );
127 if ( $cached_ips && is_array( $cached_ips ) ) {
128 return $cached_ips;
129 }
130
131 // Fetch the IP list from the remote API.
132 $response = wp_remote_get(
133 'https://gist.githubusercontent.com/wpmu-docs/568114153e93eaf28f908a724b313b6f/raw',
134 array(
135 'timeout' => 10,
136 )
137 );
138
139 // Check for errors in the API response.
140 if ( is_wp_error( $response ) ) {
141 return array();
142 }
143
144 // Retrieve the response body.
145 $body = wp_remote_retrieve_body( $response );
146 if ( empty( $body ) ) {
147 return array();
148 }
149
150 // Process the response body into an array of IPs.
151 $ip_list = array_filter( array_map( 'sanitize_text_field', explode( "\n", $body ) ) );
152
153 // Cache the IP list for a week if it is not empty.
154 if ( ! empty( $ip_list ) ) {
155 set_site_transient( self::IP_LIST_KEY, $ip_list, WEEK_IN_SECONDS );
156 }
157
158 // Return the fetched IP list.
159 return $ip_list;
160 }
161
162 /**
163 * Checks if an IP is whitelisted.
164 *
165 * @param string $ip The IP address to check.
166 *
167 * @return bool True if the IP is whitelisted, false otherwise.
168 */
169 public function is_ip_whitelisted( $ip ): bool {
170 if ( in_array( $ip, $this->get_default_ip_whitelisted(), true ) ) {
171 return true;
172 }
173
174 // If the IP is the server public IP.
175 $server_public_ip = wd_di()->get( Firewall::class )->get_whitelist_server_public_ip();
176 if ( ! empty( $server_public_ip ) && $server_public_ip === $ip ) {
177 return true;
178 }
179
180 // If the IP is the MainWP Dashboard public IP.
181 $mainwp_dashboard_public_ip = wd_di()->get( Main_Wp::class )->get_whitelist_dashboard_public_ip();
182 if ( ! empty( $mainwp_dashboard_public_ip ) && in_array( $ip, $mainwp_dashboard_public_ip, true ) ) {
183 return true;
184 }
185
186 $blacklist_settings = new Model_Blacklist_Lockout();
187
188 return $this->is_ip_in_format( $ip, $blacklist_settings->get_list( 'allowlist' ) );
189 }
190
191 /**
192 * Checks if an IP is blacklisted.
193 *
194 * @param string $ip The IP address to check.
195 *
196 * @return bool True if the IP is blacklisted, false otherwise.
197 */
198 public function is_blacklist( $ip ) {
199 $blacklist_settings = new Model_Blacklist_Lockout();
200
201 return $this->is_ip_in_format( $ip, $blacklist_settings->get_list( 'blocklist' ) );
202 }
203
204 /**
205 * Checks if a country is blacklisted based on the IP address.
206 *
207 * @param string $ip The IP address to check.
208 *
209 * @return bool True if the country is blacklisted, false otherwise.
210 * @throws InvalidDatabaseException Thrown for unexpected data is found in DB.
211 */
212 public function is_country_blacklist( $ip ): bool {
213 // Check Firewall > IP Banning > Locations section is activated or not.
214 $country = $this->get_current_country( $ip );
215 if ( false === $country ) {
216 return false;
217 }
218 $blacklist_settings = new Model_Blacklist_Lockout();
219 $blacklisted = $blacklist_settings->get_country_blacklist();
220 if ( empty( $blacklisted ) ) {
221 return false;
222 }
223 if ( in_array( 'all', $blacklisted, true ) ) {
224 return true;
225 }
226
227 if ( ! empty( $country['iso'] ) ) {
228 $country_iso = strtoupper( $country['iso'] );
229
230 // Check if the specific country is in the blacklist.
231 if ( in_array( $country_iso, $blacklisted, true ) ) {
232 return true;
233 }
234
235 // Check if any continent containing this country is in the blacklist.
236 foreach ( $blacklisted as $blocked_code ) {
237 if ( $this->is_country_in_continent( $country_iso, $blocked_code ) ) {
238 return true;
239 }
240 }
241 }
242
243 return false;
244 }
245
246 /**
247 * Verifies the format of an imported file for IP lockout settings.
248 *
249 * @param string $file Path to the file to verify.
250 *
251 * @return array|bool Array of data if the file is valid, false otherwise.
252 */
253 public function verify_import_file( $file ) {
254 global $wp_filesystem;
255 // Initialize the WP filesystem, no more using 'file-put-contents' function.
256 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
257 require_once ABSPATH . '/wp-admin/includes/file.php';
258 WP_Filesystem();
259 }
260 $lines = $wp_filesystem->get_contents_array( $file );
261 $data = array();
262 foreach ( $lines as $line ) {
263 $line = str_getcsv( $line, ',', '"', '\\' );
264 if ( count( $line ) !== 2 ) {
265 return false;
266 }
267 if ( ! in_array( $line[1], array( 'allowlist', 'blocklist' ), true ) ) {
268 return false;
269 }
270 if ( false === $this->validate_ip( $line[0] ) ) {
271 continue;
272 }
273 $data[] = $line;
274 }
275 return $data;
276 }
277
278 /**
279 * Adds a default whitelisted country to the model.
280 *
281 * @param Model_Blacklist_Lockout $model The model to update.
282 * @param string $country_iso The ISO code of the country to whitelist.
283 *
284 * @return Model_Blacklist_Lockout The updated model.
285 * @since 2.8.0
286 */
287 public function add_default_whitelisted_country( Model_Blacklist_Lockout $model, $country_iso ) {
288 if ( empty( $model->country_whitelist ) ) {
289 $model->country_whitelist[] = $country_iso;
290 } elseif ( ! in_array( $country_iso, $model->country_whitelist, true ) ) {
291 $model->country_whitelist[] = $country_iso;
292 }
293
294 return $model;
295 }
296
297 /**
298 * Check downloaded GeoDB.
299 *
300 * @return bool
301 * @throws InvalidDatabaseException Thrown for unexpected data is found in DB.
302 */
303 public function is_geodb_downloaded(): bool {
304 $model = new Model_Blacklist_Lockout();
305 $license_key = $model->maxmind_license_key;
306 // Using Geo DB without a license key is not advisable.
307 if ( empty( $license_key ) && is_file( $model->geodb_path ) ) {
308 $service_geo = wd_di()->get( MaxMind_Geolocation::class );
309 $service_geo->delete_database();
310 $model->geodb_path = '';
311 $model->save();
312
313 return false;
314 }
315
316 // Likely the case after the config import with the existed MaxMind license key.
317 if (
318 ! empty( $license_key )
319 && ( is_null( $model->geodb_path ) || ! is_file( $model->geodb_path ) )
320 ) {
321 $service_geo = wd_di()->get( MaxMind_Geolocation::class );
322 $tmp = $service_geo->get_downloaded_url( $license_key );
323 if ( ! is_wp_error( $tmp ) ) {
324 $phar = new PharData( $tmp );
325 $path = $service_geo->get_db_base_path();
326 if ( ! is_dir( $path ) ) {
327 wp_mkdir_p( $path );
328 }
329 $phar->extractTo( $path, null, true );
330 $model->geodb_path = $path . DIRECTORY_SEPARATOR . $phar->current()->getFileName() . DIRECTORY_SEPARATOR . $service_geo->get_db_full_name();
331 // Save because we'll check for a saved path.
332 $model->save();
333
334 if ( file_exists( $tmp ) ) {
335 wp_delete_file( $tmp );
336 }
337
338 if ( empty( $model->country_whitelist ) ) {
339 $is_country = false;
340 foreach ( $this->get_user_ip() as $ip ) {
341 $country = $this->get_current_country( $ip );
342
343 if ( ! empty( $country['iso'] ) ) {
344 $model = $this->add_default_whitelisted_country( $model, $country['iso'] );
345 $is_country = true;
346 }
347 }
348
349 if ( false === $is_country ) {
350 return false;
351 }
352 }
353 $model->save();
354 }
355 }
356
357 // Check again.
358 if ( is_null( $model->geodb_path ) || ! is_file( $model->geodb_path ) ) {
359 return false;
360 }
361
362 // Check if the file exists on the site. The file can exist on the same server but for different sites.
363 // For example, after config importing.
364 $path_parts = pathinfo( $model->geodb_path );
365 if ( preg_match( '/(\/wp-content\/.+)/', $path_parts['dirname'], $matches ) ) {
366 $rel_path = $matches[1];
367 $rel_path = ltrim( $rel_path, '/' );
368 $abs_path = ABSPATH . $rel_path;
369 if ( ! is_dir( $abs_path ) ) {
370 wp_mkdir_p( $abs_path );
371 }
372
373 $rel_path = $abs_path . DIRECTORY_SEPARATOR . $path_parts['basename'];
374 if ( file_exists( $rel_path ) ) {
375 return true;
376 } elseif ( ! empty( $model->geodb_path ) && file_exists( $model->geodb_path ) ) {
377 // The case if ABSPATH was changed e.g. in wp-config.php.
378 return true;
379 }
380
381 if ( $this->validate_geodb_file( $model->geodb_path ) ) {
382 global $wp_filesystem;
383 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
384 require_once ABSPATH . '/wp-admin/includes/file.php';
385 WP_Filesystem();
386 }
387
388 if ( $wp_filesystem->move( $model->geodb_path, $rel_path ) ) {
389 $wp_filesystem->chmod( $rel_path, 0644 );
390 $model->geodb_path = $rel_path;
391 $model->save();
392 }
393 } else {
394 return false;
395 }
396 }
397
398 return true;
399 }
400
401 /**
402 * Validates GeoDB file for security before processing.
403 *
404 * @param string $file_path Path to the file to validate.
405 * @return bool True if file is valid, false otherwise.
406 */
407 private function validate_geodb_file( $file_path ): bool {
408 if ( ! file_exists( $file_path ) ) {
409 return false;
410 }
411
412 $allowed_extensions = array( 'mmdb' );
413 $file_extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
414
415 if ( ! in_array( $file_extension, $allowed_extensions, true ) ) {
416 return false;
417 }
418
419 $file_size = filesize( $file_path );
420 $max_size = 100 * 1024 * 1024; // 100MB.
421
422 if ( $max_size < $file_size || 0 === $file_size ) {
423 return false;
424 }
425
426 global $wp_filesystem;
427 if ( empty( $wp_filesystem ) ) {
428 require_once ABSPATH . 'wp-admin/includes/file.php';
429 WP_Filesystem();
430 }
431
432 // Read only first 4 bytes for header validation.
433 $header = $wp_filesystem->get_contents( $file_path );
434 if ( false === $header || strlen( $header ) < 4 ) {
435 return false;
436 }
437 $header = substr( $header, 0, 4 );
438 // MaxMind DB format magic bytes: 0xABCDEF00 .
439 if ( "\xab\xcd\xef\x00" !== $header ) {
440 return false;
441 }
442
443 return true;
444 }
445
446 /**
447 * Retrieves the top countries from which IPs have been blocked.
448 *
449 * @param int $limit The maximum number of countries to return.
450 * @param int $max_age_days The maximum age of log entries to consider.
451 *
452 * @return array List of countries and their blocked IP count.
453 */
454 public function get_top_countries_blocked( $limit = 10, $max_age_days = 7 ) {
455 $result = Array_Cache::get( 'countries', 'ip_lockout', array() );
456 if ( empty( $result ) ) {
457 global $wpdb;
458
459 $result = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
460 $wpdb->prepare(
461 "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",
462 Lockout_Log::LOCKOUT_404,
463 Lockout_Log::AUTH_LOCK,
464 Lockout_Log::LOCKOUT_UA,
465 strtotime( '-' . $max_age_days . ' days', time() ),
466 $limit
467 ),
468 ARRAY_A
469 );
470 // Get data from cache.
471 Array_Cache::set( 'countries', $result, 'ip_lockout' );
472 }
473
474 return ! empty( $result ) ? $result : array();
475 }
476
477 /**
478 * Is IP on BLC Whitelist?
479 *
480 * @return bool
481 * @since 4.2.0
482 */
483 public function is_blc_ip_whitelisted(): bool {
484 $ips = $this->get_user_ip();
485 $blc_ips = array(
486 '165.227.127.103',
487 '64.176.196.23',
488 '144.202.86.106',
489 );
490 $diff = array_diff( $ips, $blc_ips );
491
492 return empty( $diff );
493 }
494
495 /**
496 * Checks if a list of IPs are all whitelisted.
497 *
498 * @param array $ips List of IPs to check.
499 *
500 * @return bool True if all IPs are whitelisted, false otherwise.
501 * @since 4.4.2
502 */
503 public function are_ips_whitelisted( array $ips ): bool {
504 foreach ( $ips as $ip ) {
505 if ( ! $this->is_ip_whitelisted( $ip ) ) {
506 return false;
507 }
508 }
509
510 return true;
511 }
512 }
513