PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.0
Defender Security – Malware Scanner, Login Security & Firewall v6.2.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-malicious-bot.php

class-malicious-bot.php in Defender Security – Malware Scanner, Login Security & Firewall 6.2.0, at src/component/class-malicious-bot.php

348 lines 9.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles malicious bot 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_Log;
13 use WP_Defender\Traits\File_Operations;
14 use WP_Defender\Integrations\Smartcrawl;
15 use WP_Defender\Model\Setting\User_Agent_Lockout;
16 use WP_Defender\Model\Notification\Firewall_Notification;
17
18 /**
19 * Handles operations to insert a weekly rotating hash URL into the footer,
20 * and blocking IP addresses that access this URL.
21 */
22 class Malicious_Bot extends Component {
23 use Country;
24 use File_Operations;
25
26 public const URL_HASH_KEY = 'wpdef_malicious_bot_url_hash';
27 public const SCENARIO_MALICIOUS_BOT = 'malicious_bot';
28 public const USER_AGENT_REGEX = '/(^User-agent:\s*\*)/mi';
29 public const URL_QUERY = 'wpdef-malicious-bot-url';
30
31 /**
32 * The path to the robots.txt file.
33 *
34 * @var string
35 */
36 private $robots_file;
37
38 /**
39 * The start comment for the robots.txt.
40 *
41 * @var string
42 */
43 private $start_comment = PHP_EOL . '# WP Defender - Begin' . PHP_EOL;
44
45 /**
46 * The end comment for the robots.txt.
47 *
48 * @var string
49 */
50 private $end_comment = PHP_EOL . '# WP Defender - End' . PHP_EOL;
51
52 /**
53 * The model for handling the data.
54 *
55 * @var User_Agent_Lockout
56 */
57 protected $model;
58
59 /**
60 * SmartCrawl integration instance.
61 *
62 * @var Smartcrawl
63 */
64 private $smartcrawl;
65
66 /**
67 * Constructor for the Malicious_Bot class.
68 * Initializes the model and sets the path to the robots.txt file.
69 *
70 * @param User_Agent_Lockout $model The model instance for malicious bot functionality.
71 */
72 public function __construct( User_Agent_Lockout $model ) {
73 $this->model = $model;
74 $this->robots_file = ABSPATH . 'robots.txt';
75 $this->smartcrawl = new Smartcrawl();
76 }
77
78 /**
79 * Check if the malicious bot is enabled.
80 */
81 public function is_enabled(): bool {
82 return $this->model->enabled && $this->model->malicious_bot_enabled;
83 }
84
85 /**
86 * Get the hash for the malicious bot URL.
87 *
88 * @return string|false The hash if set, false otherwise.
89 */
90 public function get_hash() {
91 return get_site_option( self::URL_HASH_KEY, false );
92 }
93
94 /**
95 * Set the hash for the malicious bot URL.
96 *
97 * @param string $hash The hash to set, should be a 16-character hexadecimal string.
98 */
99 private function set_hash( string $hash ) {
100 update_site_option( self::URL_HASH_KEY, $hash );
101 }
102
103 /**
104 * Rotate the hash for the malicious bot URL.
105 */
106 public function rotate_hash() {
107 $new_hash = $this->generate_hash();
108 $this->set_hash( $new_hash );
109
110 $this->register_rewrite_rule();
111
112 flush_rewrite_rules();
113
114 $this->remove_rule();
115 $this->inject_rule();
116 }
117
118 /**
119 * Generate a new hash for the malicious bot URL.
120 *
121 * @return string|false A 16-character hexadecimal string or false on failure.
122 */
123 private function generate_hash() {
124 return substr( bin2hex( Crypt::random_bytes( 32 ) ), 0, 16 );
125 }
126
127 /**
128 * Handle the robots.txt rule.
129 */
130 public function handle_robots_txt() {
131 if ( $this->is_enabled() ) {
132 if ( $this->smartcrawl->should_serve_robots() ) {
133 add_filter( 'smartcrawl_robots_txt_content', array( $this, 'add_bot_trap_to_smartcrawl_robots' ) );
134 } else {
135 $this->inject_rule();
136 }
137 } else {
138 if ( $this->smartcrawl->is_activated() ) {
139 remove_filter( 'smartcrawl_robots_txt_content', array( $this, 'add_bot_trap_to_smartcrawl_robots' ) );
140 }
141 $this->remove_rule();
142 }
143 }
144
145 /**
146 * Get rule block for robots.txt.
147 *
148 * @param string $eol The end-of-line character to use, defaults to "\n".
149 *
150 * @return string The block of text to be added to the robots.txt file.
151 */
152 public function get_block( string $eol = PHP_EOL ): string {
153 $disallow_path = $this->get_hash();
154
155 return $this->start_comment .
156 'User-agent: *' . $eol .
157 "Disallow: /{$disallow_path}" .
158 $this->end_comment;
159 }
160
161 /**
162 * Inject the malicious bot rule into the robots.txt.
163 */
164 public function inject_rule() {
165 $wp_filesystem = $this->get_wp_filesystem();
166
167 if ( $wp_filesystem->exists( $this->robots_file ) ) {
168 // Inject rule into physical robots.txt file.
169 $contents = $wp_filesystem->get_contents( $this->robots_file );
170 if ( ! str_contains( $contents, $this->start_comment ) ) {
171 $contents = $this->add_rules_to_content( $contents );
172
173 $dir_name = pathinfo( $this->robots_file, PATHINFO_DIRNAME );
174 if ( $wp_filesystem->is_writable( $dir_name ) ) {
175 $wp_filesystem->put_contents( $this->robots_file, $contents );
176 }
177 }
178 } else {
179 // Inject virtual rule via filter.
180 add_filter( 'robots_txt', array( $this, 'add_rules_to_content' ) );
181 }
182 }
183
184 /**
185 * Add bot trap rules to SmartCrawl's robots.txt content.
186 *
187 * @param string $content The existing robots.txt content from SmartCrawl.
188 * @return string The modified robots.txt content with bot trap rules.
189 */
190 public function add_bot_trap_to_smartcrawl_robots( $content ): string {
191 if ( ! $this->is_enabled() ) {
192 return $content;
193 }
194
195 return $this->add_rules_to_content( $content );
196 }
197
198 /**
199 * Remove the bot trap rule from the robots.txt.
200 */
201 public function remove_rule() {
202 $wp_filesystem = $this->get_wp_filesystem();
203
204 if ( ! $wp_filesystem->exists( $this->robots_file ) ) {
205 return;
206 }
207
208 $contents = $wp_filesystem->get_contents( $this->robots_file );
209 $eol = $this->detect_line_ending( $contents );
210
211 // Remove only the plugin block.
212 $pattern = '/\\s*' . preg_quote( $this->start_comment, '/' ) .
213 '.*?' . preg_quote( $this->end_comment, '/' ) . '\\s*/s';
214
215 $new_contents = preg_replace( $pattern, '', $contents );
216 $new_contents = trim( $new_contents );
217
218 if ( '' === $new_contents ) {
219 // File only had plugin block — delete it.
220 wp_delete_file( $this->robots_file );
221 } else {
222 $dir_name = pathinfo( $this->robots_file, PATHINFO_DIRNAME );
223 if ( $wp_filesystem->is_writable( $dir_name ) ) {
224 $wp_filesystem->put_contents( $this->robots_file, $new_contents . $eol );
225 }
226 }
227 }
228
229 /**
230 * Log the event into db, we will use the data in logs page later.
231 *
232 * @param string $ip The IP address involved in the event.
233 * @param string $uri The URI that was accessed.
234 * @param string $scenario The scenario under which the event is logged.
235 */
236 public function log_event( $ip, $uri, $scenario ) {
237 $model = new Lockout_Log();
238 $model->ip = $ip;
239 $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
240 $model->user_agent = isset( $user_agent ) ? User_Agent::fast_cleaning( $user_agent ) : null;
241 $model->date = time();
242 $model->tried = $uri;
243 $model->blog_id = get_current_blog_id();
244
245 $ip_to_country = $this->ip_to_country( $ip );
246
247 if ( isset( $ip_to_country['iso'] ) ) {
248 $model->country_iso_code = $ip_to_country['iso'];
249 }
250
251 switch ( $scenario ) {
252 case self::SCENARIO_MALICIOUS_BOT:
253 default:
254 $model->type = Lockout_Log::LOCKOUT_MALICIOUS_BOT;
255 $model->log = esc_html__( 'Lockout occurred: Bot ignored robots.txt rules.', 'defender-security' );
256 break;
257 }
258 $model->save();
259 // Notify directly because the 'defender_notify' hook isn't registered yet at firewall-time.
260 wd_di()->get( Firewall_Notification::class )->notify_lockout( $model );
261 }
262
263 /**
264 * Creates a lockout for a blocked IP.
265 *
266 * @param Lockout_Ip $model The lockout IP model.
267 * @param string $message The lockout message.
268 * @param int $time The timestamp when the lockout will be lifted.
269 */
270 public function create_blocked_lockout( &$model, $message, $time ) {
271 $model->lockout_message = $message;
272 $model->release_time = $time;
273 $model->save();
274 }
275
276 /**
277 * Merge the rule into existing User-agent: * block if present,
278 * otherwise prepends a new block.
279 *
280 * @param string $contents The existing robots.txt content.
281 *
282 * @return string The modified robots.txt content with Defender rule added.
283 */
284 public function add_rules_to_content( string $contents ): string {
285 if ( preg_match( self::USER_AGENT_REGEX, $contents ) ) {
286 $disallow_path = $this->get_hash();
287 $defender_rule = "{$this->start_comment}Disallow: /$disallow_path{$this->end_comment}";
288 return preg_replace(
289 self::USER_AGENT_REGEX,
290 '$1' . PHP_EOL . $defender_rule,
291 $contents,
292 1
293 );
294
295 }
296 return $this->get_block( PHP_EOL ) . $contents;
297 }
298
299 /**
300 * Detect if there's an empty Disallow line in robots.txt.
301 *
302 * @return bool True if empty Disallow line is detected, false otherwise.
303 */
304 public function has_empty_disallow_line(): bool {
305 $contents = '';
306 $wp_filesystem = $this->get_wp_filesystem();
307
308 // Check physical robots.txt file first.
309 if ( $wp_filesystem->exists( $this->robots_file ) ) {
310 $contents = $wp_filesystem->get_contents( $this->robots_file );
311 } elseif ( $this->smartcrawl->should_serve_robots() ) {
312 // Get SmartCrawl virtual robots.txt content.
313 $contents = $this->smartcrawl->get_robots_content();
314 } else {
315 // Get WordPress virtual robots.txt content.
316 $contents = apply_filters( 'robots_txt', '', false );
317 }
318
319 if ( ! is_string( $contents ) || '' === trim( $contents ) ) {
320 return false;
321 }
322
323 // Pattern to match "Disallow:" followed by optional whitespace and end of line (empty value).
324 // Allows for optional leading whitespace before "Disallow:".
325 $pattern = '/^\s*Disallow:\s*$/mi';
326 if ( ! preg_match( $pattern, $contents ) ) {
327 return false;
328 }
329
330 // Make sure it's not within our managed block.
331 $defender_block_pattern = '/' . preg_quote( $this->start_comment, '/' ) .
332 '.*?' . preg_quote( $this->end_comment, '/' ) . '/s';
333 $contents_without_defender = preg_replace( $defender_block_pattern, '', $contents );
334
335 return preg_match( $pattern, $contents_without_defender ) === 1;
336 }
337
338 /**
339 * Registers a rewrite rule for the malicious bot URL.
340 * The URL will be in the format: /{hash}/
341 * where {hash} is a 16-character hexadecimal string.
342 */
343 public function register_rewrite_rule() {
344 $hash = $this->get_hash();
345 add_rewrite_rule( "^{$hash}/?$", 'index.php?' . self::URL_QUERY . '=' . $hash, 'top' );
346 }
347 }
348