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

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