PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.0.1
Defender Security – Malware Scanner, Login Security & Firewall v6.0.1
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.0.1, at src/component/class-malicious-bot.php

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