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-fake-bot-detection.php

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

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