| 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 |
* Fake_Bot_Detection constructor. |
| 78 |
* |
| 79 |
* @param User_Agent_Lockout $model The model instance for fake bot detection functionality. |
| 80 |
*/ |
| 81 |
public function __construct( User_Agent_Lockout $model ) { |
| 82 |
$this->model = $model; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Check if the fake bot detection is enabled. |
| 87 |
*/ |
| 88 |
public function is_enabled(): bool { |
| 89 |
return $this->model->enabled && $this->model->fake_bots_enabled; |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* Determine if the current HTTP request is from a legitimate crawler. |
| 94 |
* |
| 95 |
* The detection flow is: |
| 96 |
* 1. Check User-Agent against known crawler UAs. |
| 97 |
* 2. If UA matches, check the request IP against the crawler's allowlist. |
| 98 |
* 3. If not in allowlist, perform reverse DNS lookup (IP → hostname). |
| 99 |
* 4. Verify that the hostname matches known crawler domain patterns. |
| 100 |
* 5. Perform forward DNS lookup (hostname → IP) and ensure the original IP matches. |
| 101 |
* |
| 102 |
* Crawler definitions (UA, IP ranges, hostname patterns) are loaded from |
| 103 |
* a remote JSON file (e.g. GitHub Gist). If that fails, a local fallback |
| 104 |
* JSON file bundled with the plugin is used. |
| 105 |
* |
| 106 |
* @return void |
| 107 |
*/ |
| 108 |
public function validate_legit_crawler(): void { |
| 109 |
$ips = $this->get_user_ip(); |
| 110 |
$agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' ); |
| 111 |
|
| 112 |
if ( array() === $ips || '' === $agent ) { |
| 113 |
return; |
| 114 |
} |
| 115 |
|
| 116 |
foreach ( $this->crawlers as $name => $data ) { |
| 117 |
// 1. User-Agent check. |
| 118 |
if ( ! $this->match_user_agent( $agent, $data['user_agents'] ?? array() ) ) { |
| 119 |
continue; |
| 120 |
} |
| 121 |
|
| 122 |
// Check each IP against the allowed list. |
| 123 |
foreach ( $ips as $ip ) { |
| 124 |
// 2. IP allowlist check. |
| 125 |
$allowed_ips = array_merge( |
| 126 |
$data['ips']['ipv4'] ?? array(), |
| 127 |
$data['ips']['ipv6'] ?? array() |
| 128 |
); |
| 129 |
|
| 130 |
if ( array() !== $allowed_ips && $this->is_ip_in_format( $ip, $allowed_ips ) ) { |
| 131 |
return; |
| 132 |
} |
| 133 |
|
| 134 |
// 3. Reverse DNS lookup. |
| 135 |
$hostname = gethostbyaddr( $ip ); |
| 136 |
if ( ! $hostname || $hostname === $ip ) { |
| 137 |
$this->block_ip( $ip, $agent, $name ); |
| 138 |
return; |
| 139 |
} |
| 140 |
|
| 141 |
$pattern = $data['hostname_pattern'] ?? ''; |
| 142 |
if ( '' === $pattern || ! preg_match( '#' . $pattern . '#i', $hostname ) ) { |
| 143 |
$this->block_ip( $ip, $agent, $name ); |
| 144 |
return; // Hostname doesn't match. |
| 145 |
} |
| 146 |
|
| 147 |
// 4. Forward DNS lookup |
| 148 |
$resolved_ips = gethostbynamel( $hostname ); |
| 149 |
if ( is_array( $resolved_ips ) && in_array( $ip, $resolved_ips, true ) ) { |
| 150 |
return; // Verified by forward + reverse DNS. |
| 151 |
} |
| 152 |
} |
| 153 |
|
| 154 |
$this->block_ip( $ip, $agent, $name ); |
| 155 |
return; // UA matched, but DNS validation failed. |
| 156 |
} |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Load crawler definitions from remote URL with local fallback. |
| 161 |
* |
| 162 |
* @return void |
| 163 |
*/ |
| 164 |
public function load_crawlers(): void { |
| 165 |
// 1. Try cache first. |
| 166 |
$cached = get_site_transient( self::CACHE_KEY ); |
| 167 |
if ( is_array( $cached ) ) { |
| 168 |
$this->crawlers = $cached; |
| 169 |
return; |
| 170 |
} |
| 171 |
|
| 172 |
// 2. Attempt remote fetch. |
| 173 |
$response = wp_remote_get( self::REMOTE_URL, array( 'timeout' => 5 ) ); |
| 174 |
|
| 175 |
if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) { |
| 176 |
$json = wp_remote_retrieve_body( $response ); |
| 177 |
$data = json_decode( $json, true ); |
| 178 |
|
| 179 |
if ( is_array( $data ) ) { |
| 180 |
$this->crawlers = $data; |
| 181 |
|
| 182 |
// Cache remote result for 12h. |
| 183 |
set_site_transient( self::CACHE_KEY, $this->crawlers, self::CACHE_TTL ); |
| 184 |
return; |
| 185 |
} |
| 186 |
} |
| 187 |
|
| 188 |
// 3. Remote failed → fallback to local file (not cached). |
| 189 |
$local_path = defender_path( self::LOCAL_FILE ); |
| 190 |
if ( file_exists( $local_path ) ) { |
| 191 |
$json = file_get_contents( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 192 |
$data = json_decode( $json, true ); |
| 193 |
$this->crawlers = is_array( $data ) ? $data : array(); |
| 194 |
} else { |
| 195 |
$this->crawlers = array(); |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
|
| 200 |
/** |
| 201 |
* Test User-Agent string against known crawler substrings. |
| 202 |
* |
| 203 |
* @param string $agent Full User-Agent string from request. |
| 204 |
* @param array $patterns List of substrings to match against. |
| 205 |
* |
| 206 |
* @return bool True if UA contains one of the patterns, false otherwise. |
| 207 |
*/ |
| 208 |
protected function match_user_agent( string $agent, array $patterns ): bool { |
| 209 |
foreach ( $patterns as $ua ) { |
| 210 |
if ( stripos( $agent, $ua ) !== false ) { |
| 211 |
return true; |
| 212 |
} |
| 213 |
} |
| 214 |
return false; |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* Block the given IP address according to settings. |
| 219 |
* |
| 220 |
* @param string $ip The IP address to block. |
| 221 |
* @param string $user_agent The User-Agent string from the request. |
| 222 |
* @param string $bot_name The User-Agent label impersonated (e.g. "Googlebot"). |
| 223 |
*/ |
| 224 |
protected function block_ip( string $ip, string $user_agent, string $bot_name ): void { |
| 225 |
$lockout_model = Lockout_Ip::get( $ip ); |
| 226 |
$remaining_time = 0; |
| 227 |
if ( 'permanent' === $this->model->fake_bots_lockout_type ) { |
| 228 |
$lockout_model->attempt = 0; |
| 229 |
$lockout_model->meta['login'] = array(); |
| 230 |
$lockout_model->meta['nf'] = array(); |
| 231 |
$lockout_model->save(); |
| 232 |
// We block IP here unlike other UA lockout cases. |
| 233 |
do_action( 'wd_blacklist_this_ip', $ip ); |
| 234 |
} else { |
| 235 |
$lockout_model->status = Lockout_Ip::STATUS_BLOCKED; |
| 236 |
$lockout_model->lock_time = time(); |
| 237 |
|
| 238 |
$this->create_blocked_lockout( |
| 239 |
$lockout_model, |
| 240 |
$this->model->fake_bots_message, |
| 241 |
strtotime( '+' . $this->model->fake_bots_lockout_duration . ' ' . $this->model->fake_bots_lockout_duration_unit ) |
| 242 |
); |
| 243 |
|
| 244 |
$remaining_time = $lockout_model->remaining_release_time(); |
| 245 |
} |
| 246 |
|
| 247 |
$this->log_event( $ip, self::SCENARIO_FAKE_BOT, $bot_name ); |
| 248 |
|
| 249 |
wd_di()->get( Firewall::class )->actions_for_blocked( |
| 250 |
$this->model->fake_bots_message, |
| 251 |
$remaining_time, |
| 252 |
self::SCENARIO_FAKE_BOT, |
| 253 |
$this->get_user_ip() |
| 254 |
); |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Log the event into db, we will use the data in logs page later. |
| 259 |
* |
| 260 |
* @param string $ip The IP address involved in the event. |
| 261 |
* @param string $scenario The scenario under which the event is logged. |
| 262 |
* @param string $bot_name The name of the bot being impersonated. |
| 263 |
*/ |
| 264 |
public function log_event( $ip, $scenario, $bot_name ) { |
| 265 |
$model = new Lockout_Log(); |
| 266 |
$model->ip = $ip; |
| 267 |
$user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' ); |
| 268 |
$model->user_agent = isset( $user_agent ) ? User_Agent::fast_cleaning( $user_agent ) : null; |
| 269 |
$model->date = time(); |
| 270 |
$model->tried = $user_agent; |
| 271 |
$model->blog_id = get_current_blog_id(); |
| 272 |
|
| 273 |
$ip_to_country = $this->ip_to_country( $ip ); |
| 274 |
|
| 275 |
if ( ! empty( $ip_to_country ) && isset( $ip_to_country['iso'] ) ) { |
| 276 |
$model->country_iso_code = $ip_to_country['iso']; |
| 277 |
} |
| 278 |
|
| 279 |
switch ( $scenario ) { |
| 280 |
case self::SCENARIO_FAKE_BOT: |
| 281 |
default: |
| 282 |
$model->type = Lockout_Log::LOCKOUT_FAKE_BOT; |
| 283 |
$model->log = sprintf( |
| 284 |
/* translators: %s: The name of the bot being impersonated. */ |
| 285 |
esc_html__( 'Lockout occurred: Fake bot impersonated %s.', 'defender-security' ), |
| 286 |
$bot_name |
| 287 |
); |
| 288 |
break; |
| 289 |
} |
| 290 |
$model->save(); |
| 291 |
} |
| 292 |
|
| 293 |
/** |
| 294 |
* Creates a lockout for a blocked IP. |
| 295 |
* |
| 296 |
* @param Lockout_Ip $model The lockout IP model. |
| 297 |
* @param string $message The lockout message. |
| 298 |
* @param int $time The timestamp when the lockout will be lifted. |
| 299 |
*/ |
| 300 |
protected function create_blocked_lockout( &$model, $message, $time ) { |
| 301 |
$model->lockout_message = $message; |
| 302 |
$model->release_time = $time; |
| 303 |
$model->save(); |
| 304 |
} |
| 305 |
} |
| 306 |
|