PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.1
Defender Security – Malware Scanner, Login Security & Firewall v6.2.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 6.2.1, at src/component/class-fake-bot-detection.php

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