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

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

436 lines 11.7 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 if ( false !== stripos( $agent, 'facebook' ) && false !== stripos( $agent, 'twitter' ) ) {
141 return;
142 }
143
144 foreach ( $this->crawlers as $name => $data ) {
145 // 1. User-Agent check.
146 if ( ! $this->match_user_agent( $agent, $data['user_agents'] ?? array() ) ) {
147 continue;
148 }
149
150 // Check each IP against the allowed list.
151 foreach ( $ips as $ip ) {
152 // 2. IP allowlist check.
153 $allowed_ips = array_merge(
154 $data['ips']['ipv4'] ?? array(),
155 $data['ips']['ipv6'] ?? array()
156 );
157
158 if ( array() !== $allowed_ips && $this->is_ip_in_format( $ip, $allowed_ips ) ) {
159 return;
160 }
161
162 // Facebook specific IP verification.
163 if ( 'facebook' === strtolower( $name ) ) {
164 if ( $this->is_facebook_ip( $ip ) ) {
165 return; // Confirmed legit Facebook bot.
166 }
167
168 $this->block_ip( $ip, $agent, $name );
169 return;
170 }
171
172 // 3. Reverse DNS lookup.
173 $hostname = gethostbyaddr( $ip );
174 if ( ! $hostname || $hostname === $ip ) {
175 $this->block_ip( $ip, $agent, $name );
176 return;
177 }
178
179 $pattern = $data['hostname_pattern'] ?? '';
180 if ( '' === $pattern || ! preg_match( '#' . $pattern . '#i', $hostname ) ) {
181 $this->block_ip( $ip, $agent, $name );
182 return; // Hostname doesn't match.
183 }
184
185 // 4. Forward DNS lookup
186 $resolved_ips = gethostbynamel( $hostname );
187 if ( is_array( $resolved_ips ) && in_array( $ip, $resolved_ips, true ) ) {
188 return; // Verified by forward + reverse DNS.
189 }
190 }
191
192 $this->block_ip( $ip, $agent, $name );
193 return; // UA matched, but DNS validation failed.
194 }
195 }
196
197 /**
198 * Load crawler definitions from remote URL with local fallback.
199 *
200 * @return void
201 */
202 public function load_crawlers(): void {
203 // 1. Try cache first.
204 $cached = get_site_transient( self::CACHE_KEY );
205 if ( is_array( $cached ) ) {
206 $this->crawlers = $cached;
207 return;
208 }
209
210 // 2. Attempt remote fetch.
211 $response = wp_remote_get( self::REMOTE_URL, array( 'timeout' => 5 ) );
212
213 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
214 $json = wp_remote_retrieve_body( $response );
215 $data = json_decode( $json, true );
216
217 if ( is_array( $data ) ) {
218 $this->crawlers = $data;
219
220 // Cache remote result for 12h.
221 set_site_transient( self::CACHE_KEY, $this->crawlers, self::CACHE_TTL );
222 return;
223 }
224 }
225
226 // 3. Remote failed → fallback to local file (not cached).
227 $local_path = defender_path( self::LOCAL_FILE );
228 if ( file_exists( $local_path ) ) {
229 $json = file_get_contents( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
230 $data = json_decode( $json, true );
231 $this->crawlers = is_array( $data ) ? $data : array();
232 } else {
233 $this->crawlers = array();
234 }
235 }
236
237
238 /**
239 * Test User-Agent string against known crawler substrings.
240 *
241 * @param string $agent Full User-Agent string from request.
242 * @param array $patterns List of substrings to match against.
243 *
244 * @return bool True if UA contains one of the patterns, false otherwise.
245 */
246 protected function match_user_agent( string $agent, array $patterns ): bool {
247 foreach ( $patterns as $ua ) {
248 if ( stripos( $agent, $ua ) !== false ) {
249 return true;
250 }
251 }
252 return false;
253 }
254
255 /**
256 * Block the given IP address according to settings.
257 *
258 * @param string $ip The IP address to block.
259 * @param string $user_agent The User-Agent string from the request.
260 * @param string $bot_name The User-Agent label impersonated (e.g. "Googlebot").
261 */
262 protected function block_ip( string $ip, string $user_agent, string $bot_name ): void {
263 $lockout_model = Lockout_Ip::get( $ip );
264 $remaining_time = 0;
265 if ( 'permanent' === $this->model->fake_bots_lockout_type ) {
266 $lockout_model->attempt = 0;
267 $lockout_model->meta['login'] = array();
268 $lockout_model->meta['nf'] = array();
269 $lockout_model->save();
270 // We block IP here unlike other UA lockout cases.
271 do_action( 'wd_blacklist_this_ip', $ip );
272 } else {
273 $lockout_model->status = Lockout_Ip::STATUS_BLOCKED;
274 $lockout_model->lock_time = time();
275
276 $this->create_blocked_lockout(
277 $lockout_model,
278 $this->model->fake_bots_message,
279 strtotime( '+' . $this->model->fake_bots_lockout_duration . ' ' . $this->model->fake_bots_lockout_duration_unit )
280 );
281
282 $remaining_time = $lockout_model->remaining_release_time();
283 }
284
285 $this->log_event( $ip, self::SCENARIO_FAKE_BOT, $bot_name );
286
287 wd_di()->get( Firewall::class )->actions_for_blocked(
288 $this->model->fake_bots_message,
289 $remaining_time,
290 self::SCENARIO_FAKE_BOT,
291 $this->get_user_ip()
292 );
293 }
294
295 /**
296 * Log the event into db, we will use the data in logs page later.
297 *
298 * @param string $ip The IP address involved in the event.
299 * @param string $scenario The scenario under which the event is logged.
300 * @param string $bot_name The name of the bot being impersonated.
301 */
302 public function log_event( $ip, $scenario, $bot_name ) {
303 $model = new Lockout_Log();
304 $model->ip = $ip;
305 $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
306 $model->user_agent = isset( $user_agent ) ? User_Agent::fast_cleaning( $user_agent ) : null;
307 $model->date = time();
308 $model->tried = $user_agent;
309 $model->blog_id = get_current_blog_id();
310
311 $ip_to_country = $this->ip_to_country( $ip );
312
313 if ( isset( $ip_to_country['iso'] ) ) {
314 $model->country_iso_code = $ip_to_country['iso'];
315 }
316
317 switch ( $scenario ) {
318 case self::SCENARIO_FAKE_BOT:
319 default:
320 $model->type = Lockout_Log::LOCKOUT_FAKE_BOT;
321 $model->log = sprintf(
322 /* translators: %s: The name of the bot being impersonated. */
323 esc_html__( 'Lockout occurred: Fake bot impersonated %s.', 'defender-security' ),
324 $bot_name
325 );
326 break;
327 }
328 $model->save();
329 }
330
331 /**
332 * Creates a lockout for a blocked IP.
333 *
334 * @param Lockout_Ip $model The lockout IP model.
335 * @param string $message The lockout message.
336 * @param int $time The timestamp when the lockout will be lifted.
337 */
338 protected function create_blocked_lockout( &$model, $message, $time ) {
339 $model->lockout_message = $message;
340 $model->release_time = $time;
341 $model->save();
342 }
343
344 /**
345 * Check via external APIs if the given IP belongs to Facebook.
346 *
347 * @param string $ip IPv4 or IPv6 address.
348 * @return bool True if owned by Facebook, false otherwise.
349 */
350 public function is_facebook_ip( string $ip ): bool {
351 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
352 return false;
353 }
354
355 $cache_key = self::FB_CACHE_KEY_PREFIX . md5( $ip );
356
357 $cached = get_site_transient( $cache_key );
358 if ( false !== $cached ) {
359 return (bool) $cached;
360 }
361
362 $is_fb = false;
363
364 // Fetch from APIs.
365 foreach ( self::IP_INFO_SERVICES as $endpoint ) {
366 $url = sprintf( $endpoint, $ip );
367
368 $response = wp_remote_get(
369 $url,
370 array(
371 'timeout' => 3,
372 'headers' => array( 'Accept' => 'application/json' ),
373 )
374 );
375
376 if ( is_wp_error( $response ) ) {
377 continue;
378 }
379
380 $body = wp_remote_retrieve_body( $response );
381 $data = json_decode( $body, true );
382
383 if ( ! is_array( $data ) ) {
384 continue;
385 }
386
387 $org = $data['org'] ?? $data['connection']['org'] ?? null;
388 $isp = $data['isp'] ?? $data['connection']['isp'] ?? null;
389
390 if (
391 preg_match( '/facebook|meta/i', $org ?? '' ) ||
392 preg_match( '/facebook|meta/i', $isp ?? '' )
393 ) {
394 $is_fb = true;
395 break;
396 }
397 }
398
399 set_site_transient(
400 $cache_key,
401 $is_fb ? 1 : 0,
402 $is_fb ? self::FB_CACHE_TTL_CONFIRMED : self::FB_CACHE_TTL_UNCONFIRMED
403 );
404
405 return $is_fb;
406 }
407
408 /**
409 * Clear all Facebook IP check transients.
410 */
411 public function clear_fb_transients(): void {
412 global $wpdb;
413
414 $cache_key = self::FB_CACHE_KEY_PREFIX;
415 $like_pattern = "_site_transient_{$cache_key}_%";
416 $timeout_pattern = "_site_transient_timeout_{$cache_key}_%";
417
418 if ( is_multisite() ) {
419 $table = $wpdb->sitemeta;
420 $key_column = 'meta_key';
421 } else {
422 $table = $wpdb->options;
423 $key_column = 'option_name';
424 }
425
426 // Delete Facebook IP check transients.
427 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
428 $wpdb->prepare(
429 "DELETE FROM {$table} WHERE {$key_column} LIKE %s OR {$key_column} LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
430 $like_pattern,
431 $timeout_pattern
432 )
433 );
434 }
435 }
436