| 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 |
|