PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.6.1
Defender Security – Malware Scanner, Login Security & Firewall v5.6.1
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-fake-bot-detection.php

class-fake-bot-detection.php in Defender Security – Malware Scanner, Login Security & Firewall 5.6.1, at src/component/class-fake-bot-detection.php

432 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles fake bot detection functionality.
4 *
5 * @package WP_Defender\Component
6 */
7
8 namespace WP_Defender\Component;
9
10 use WP_Defender\Component;
11 use WP_Defender\Controller\Firewall;
12 use WP_Defender\Model\Lockout_Log;
13 use WP_Defender\Model\Setting\User_Agent_Lockout;
14 use WP_Defender\Model\Lockout_Ip;
15 use WP_Defender\Traits\Country;
16
17 /**
18 * Class Fake_Bot_Detection
19 *
20 * Detects whether the current HTTP request comes from a legitimate search engine
21 * or monitoring crawler (Googlebot, Bingbot, Yandex, etc.).
22 */
23 class Fake_Bot_Detection extends Component {
24 use Country;
25
26 public const SCENARIO_FAKE_BOT = 'fake_bot';
27
28 /**
29 * Remote JSON URL containing crawler definitions.
30 *
31 * @var string
32 */
33 public const REMOTE_URL = 'https://gist.githubusercontent.com/incsub/8ab5815980fc8af44fe0223f8dcddb66/raw/3ddcc5be3b007afc8cd4f4af4b45f36d5c60508e/crawlers.json';
34
35 /**
36 * Local fallback path to crawler definitions JSON.
37 *
38 * @var string
39 */
40 public const LOCAL_FILE = 'includes/crawlers.json';
41
42 /**
43 * WP transient key for caching crawler data.
44 */
45 public const CACHE_KEY = 'wpmu_crawlers';
46
47 /**
48 * Cache expiration (12 hours).
49 */
50 public const CACHE_TTL = 12 * HOUR_IN_SECONDS;
51
52 /**
53 * Array of crawler definitions loaded from JSON.
54 *
55 * Format example:
56 * [
57 * "Google" => [
58 * "ips" => ["ipv4" => [], "ipv6" => []],
59 * "hostname_pattern" => "googlebot.com",
60 * "user_agents" => ["Googlebot"]
61 * ],
62 * ...
63 * ]
64 *
65 * @var array<string, array>
66 */
67 protected array $crawlers = array();
68
69 /**
70 * The model for handling the data.
71 *
72 * @var User_Agent_Lockout
73 */
74 protected $model;
75
76 /**
77 * API endpoints to query for IP info.
78 *
79 * @var string[]
80 */
81 public const IP_INFO_SERVICES = array(
82 'https://ipinfo.io/%s/json',
83 'https://ipwho.is/%s',
84 'http://ip-api.com/json/%s',
85 );
86
87 /**
88 * Prefix for site transient cache keys.
89 *
90 * @var string
91 */
92 public const FB_CACHE_KEY_PREFIX = 'wpdef_ip_is_fb_';
93
94 /**
95 * Cache lifetimes.
96 */
97 public const FB_CACHE_TTL_CONFIRMED = WEEK_IN_SECONDS;
98 public const FB_CACHE_TTL_UNCONFIRMED = HOUR_IN_SECONDS;
99
100 /**
101 * Fake_Bot_Detection constructor.
102 *
103 * @param User_Agent_Lockout $model The model instance for fake bot detection functionality.
104 */
105 public function __construct( User_Agent_Lockout $model ) {
106 $this->model = $model;
107 }
108
109 /**
110 * Check if the fake bot detection is enabled.
111 */
112 public function is_enabled(): bool {
113 return $this->model->enabled && $this->model->fake_bots_enabled;
114 }
115
116 /**
117 * Determine if the current HTTP request is from a legitimate crawler.
118 *
119 * The detection flow is:
120 * 1. Check User-Agent against known crawler UAs.
121 * 2. If UA matches, check the request IP against the crawler's allowlist.
122 * 3. If not in allowlist, perform reverse DNS lookup (IP → hostname).
123 * 4. Verify that the hostname matches known crawler domain patterns.
124 * 5. Perform forward DNS lookup (hostname → IP) and ensure the original IP matches.
125 *
126 * Crawler definitions (UA, IP ranges, hostname patterns) are loaded from
127 * a remote JSON file (e.g. GitHub Gist). If that fails, a local fallback
128 * JSON file bundled with the plugin is used.
129 *
130 * @return void
131 */
132 public function validate_legit_crawler(): void {
133 $ips = $this->get_user_ip();
134 $agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
135
136 if ( array() === $ips || '' === $agent ) {
137 return;
138 }
139
140 foreach ( $this->crawlers as $name => $data ) {
141 // 1. User-Agent check.
142 if ( ! $this->match_user_agent( $agent, $data['user_agents'] ?? array() ) ) {
143 continue;
144 }
145
146 // Check each IP against the allowed list.
147 foreach ( $ips as $ip ) {
148 // 2. IP allowlist check.
149 $allowed_ips = array_merge(
150 $data['ips']['ipv4'] ?? array(),
151 $data['ips']['ipv6'] ?? array()
152 );
153
154 if ( array() !== $allowed_ips && $this->is_ip_in_format( $ip, $allowed_ips ) ) {
155 return;
156 }
157
158 // Facebook specific IP verification.
159 if ( 'facebook' === strtolower( $name ) ) {
160 if ( $this->is_facebook_ip( $ip ) ) {
161 return; // Confirmed legit Facebook bot.
162 }
163
164 $this->block_ip( $ip, $agent, $name );
165 return;
166 }
167
168 // 3. Reverse DNS lookup.
169 $hostname = gethostbyaddr( $ip );
170 if ( ! $hostname || $hostname === $ip ) {
171 $this->block_ip( $ip, $agent, $name );
172 return;
173 }
174
175 $pattern = $data['hostname_pattern'] ?? '';
176 if ( '' === $pattern || ! preg_match( '#' . $pattern . '#i', $hostname ) ) {
177 $this->block_ip( $ip, $agent, $name );
178 return; // Hostname doesn't match.
179 }
180
181 // 4. Forward DNS lookup
182 $resolved_ips = gethostbynamel( $hostname );
183 if ( is_array( $resolved_ips ) && in_array( $ip, $resolved_ips, true ) ) {
184 return; // Verified by forward + reverse DNS.
185 }
186 }
187
188 $this->block_ip( $ip, $agent, $name );
189 return; // UA matched, but DNS validation failed.
190 }
191 }
192
193 /**
194 * Load crawler definitions from remote URL with local fallback.
195 *
196 * @return void
197 */
198 public function load_crawlers(): void {
199 // 1. Try cache first.
200 $cached = get_site_transient( self::CACHE_KEY );
201 if ( is_array( $cached ) ) {
202 $this->crawlers = $cached;
203 return;
204 }
205
206 // 2. Attempt remote fetch.
207 $response = wp_remote_get( self::REMOTE_URL, array( 'timeout' => 5 ) );
208
209 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
210 $json = wp_remote_retrieve_body( $response );
211 $data = json_decode( $json, true );
212
213 if ( is_array( $data ) ) {
214 $this->crawlers = $data;
215
216 // Cache remote result for 12h.
217 set_site_transient( self::CACHE_KEY, $this->crawlers, self::CACHE_TTL );
218 return;
219 }
220 }
221
222 // 3. Remote failed → fallback to local file (not cached).
223 $local_path = defender_path( self::LOCAL_FILE );
224 if ( file_exists( $local_path ) ) {
225 $json = file_get_contents( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
226 $data = json_decode( $json, true );
227 $this->crawlers = is_array( $data ) ? $data : array();
228 } else {
229 $this->crawlers = array();
230 }
231 }
232
233
234 /**
235 * Test User-Agent string against known crawler substrings.
236 *
237 * @param string $agent Full User-Agent string from request.
238 * @param array $patterns List of substrings to match against.
239 *
240 * @return bool True if UA contains one of the patterns, false otherwise.
241 */
242 protected function match_user_agent( string $agent, array $patterns ): bool {
243 foreach ( $patterns as $ua ) {
244 if ( stripos( $agent, $ua ) !== false ) {
245 return true;
246 }
247 }
248 return false;
249 }
250
251 /**
252 * Block the given IP address according to settings.
253 *
254 * @param string $ip The IP address to block.
255 * @param string $user_agent The User-Agent string from the request.
256 * @param string $bot_name The User-Agent label impersonated (e.g. "Googlebot").
257 */
258 protected function block_ip( string $ip, string $user_agent, string $bot_name ): void {
259 $lockout_model = Lockout_Ip::get( $ip );
260 $remaining_time = 0;
261 if ( 'permanent' === $this->model->fake_bots_lockout_type ) {
262 $lockout_model->attempt = 0;
263 $lockout_model->meta['login'] = array();
264 $lockout_model->meta['nf'] = array();
265 $lockout_model->save();
266 // We block IP here unlike other UA lockout cases.
267 do_action( 'wd_blacklist_this_ip', $ip );
268 } else {
269 $lockout_model->status = Lockout_Ip::STATUS_BLOCKED;
270 $lockout_model->lock_time = time();
271
272 $this->create_blocked_lockout(
273 $lockout_model,
274 $this->model->fake_bots_message,
275 strtotime( '+' . $this->model->fake_bots_lockout_duration . ' ' . $this->model->fake_bots_lockout_duration_unit )
276 );
277
278 $remaining_time = $lockout_model->remaining_release_time();
279 }
280
281 $this->log_event( $ip, self::SCENARIO_FAKE_BOT, $bot_name );
282
283 wd_di()->get( Firewall::class )->actions_for_blocked(
284 $this->model->fake_bots_message,
285 $remaining_time,
286 self::SCENARIO_FAKE_BOT,
287 $this->get_user_ip()
288 );
289 }
290
291 /**
292 * Log the event into db, we will use the data in logs page later.
293 *
294 * @param string $ip The IP address involved in the event.
295 * @param string $scenario The scenario under which the event is logged.
296 * @param string $bot_name The name of the bot being impersonated.
297 */
298 public function log_event( $ip, $scenario, $bot_name ) {
299 $model = new Lockout_Log();
300 $model->ip = $ip;
301 $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
302 $model->user_agent = isset( $user_agent ) ? User_Agent::fast_cleaning( $user_agent ) : null;
303 $model->date = time();
304 $model->tried = $user_agent;
305 $model->blog_id = get_current_blog_id();
306
307 $ip_to_country = $this->ip_to_country( $ip );
308
309 if ( ! empty( $ip_to_country ) && isset( $ip_to_country['iso'] ) ) {
310 $model->country_iso_code = $ip_to_country['iso'];
311 }
312
313 switch ( $scenario ) {
314 case self::SCENARIO_FAKE_BOT:
315 default:
316 $model->type = Lockout_Log::LOCKOUT_FAKE_BOT;
317 $model->log = sprintf(
318 /* translators: %s: The name of the bot being impersonated. */
319 esc_html__( 'Lockout occurred: Fake bot impersonated %s.', 'defender-security' ),
320 $bot_name
321 );
322 break;
323 }
324 $model->save();
325 }
326
327 /**
328 * Creates a lockout for a blocked IP.
329 *
330 * @param Lockout_Ip $model The lockout IP model.
331 * @param string $message The lockout message.
332 * @param int $time The timestamp when the lockout will be lifted.
333 */
334 protected function create_blocked_lockout( &$model, $message, $time ) {
335 $model->lockout_message = $message;
336 $model->release_time = $time;
337 $model->save();
338 }
339
340 /**
341 * Check via external APIs if the given IP belongs to Facebook.
342 *
343 * @param string $ip IPv4 or IPv6 address.
344 * @return bool True if owned by Facebook, false otherwise.
345 */
346 public function is_facebook_ip( string $ip ): bool {
347 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
348 return false;
349 }
350
351 $cache_key = self::FB_CACHE_KEY_PREFIX . md5( $ip );
352
353 $cached = get_site_transient( $cache_key );
354 if ( false !== $cached ) {
355 return (bool) $cached;
356 }
357
358 $is_fb = false;
359
360 // Fetch from APIs.
361 foreach ( self::IP_INFO_SERVICES as $endpoint ) {
362 $url = sprintf( $endpoint, $ip );
363
364 $response = wp_remote_get(
365 $url,
366 array(
367 'timeout' => 3,
368 'headers' => array( 'Accept' => 'application/json' ),
369 )
370 );
371
372 if ( is_wp_error( $response ) ) {
373 continue;
374 }
375
376 $body = wp_remote_retrieve_body( $response );
377 $data = json_decode( $body, true );
378
379 if ( ! is_array( $data ) ) {
380 continue;
381 }
382
383 $org = $data['org'] ?? $data['connection']['org'] ?? null;
384 $isp = $data['isp'] ?? $data['connection']['isp'] ?? null;
385
386 if (
387 preg_match( '/facebook|meta/i', $org ?? '' ) ||
388 preg_match( '/facebook|meta/i', $isp ?? '' )
389 ) {
390 $is_fb = true;
391 break;
392 }
393 }
394
395 set_site_transient(
396 $cache_key,
397 $is_fb ? 1 : 0,
398 $is_fb ? self::FB_CACHE_TTL_CONFIRMED : self::FB_CACHE_TTL_UNCONFIRMED
399 );
400
401 return $is_fb;
402 }
403
404 /**
405 * Clear all Facebook IP check transients.
406 */
407 public function clear_fb_transients(): void {
408 global $wpdb;
409
410 $cache_key = self::FB_CACHE_KEY_PREFIX;
411 $like_pattern = "_site_transient_{$cache_key}_%";
412 $timeout_pattern = "_site_transient_timeout_{$cache_key}_%";
413
414 if ( is_multisite() ) {
415 $table = $wpdb->sitemeta;
416 $key_column = 'meta_key';
417 } else {
418 $table = $wpdb->options;
419 $key_column = 'option_name';
420 }
421
422 // Delete Facebook IP check transients.
423 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
424 $wpdb->prepare(
425 "DELETE FROM {$table} WHERE {$key_column} LIKE %s OR {$key_column} LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
426 $like_pattern,
427 $timeout_pattern
428 )
429 );
430 }
431 }
432