model = $model; $this->robots_file = ABSPATH . 'robots.txt'; $this->smartcrawl = new Smartcrawl(); } /** * Check if the malicious bot is enabled. */ public function is_enabled(): bool { return $this->model->enabled && $this->model->malicious_bot_enabled; } /** * Get the hash for the malicious bot URL. * * @return string|false The hash if set, false otherwise. */ public function get_hash() { return get_site_option( self::URL_HASH_KEY, false ); } /** * Set the hash for the malicious bot URL. * * @param string $hash The hash to set, should be a 16-character hexadecimal string. */ private function set_hash( string $hash ) { update_site_option( self::URL_HASH_KEY, $hash ); } /** * Rotate the hash for the malicious bot URL. */ public function rotate_hash() { $new_hash = $this->generate_hash(); $this->set_hash( $new_hash ); $this->register_rewrite_rule(); flush_rewrite_rules(); $this->remove_rule(); $this->inject_rule(); } /** * Generate a new hash for the malicious bot URL. * * @return string|false A 16-character hexadecimal string or false on failure. */ private function generate_hash() { return substr( bin2hex( Crypt::random_bytes( 32 ) ), 0, 16 ); } /** * Handle the robots.txt rule. */ public function handle_robots_txt() { if ( $this->is_enabled() ) { if ( $this->smartcrawl->should_serve_robots() ) { add_filter( 'smartcrawl_robots_txt_content', array( $this, 'add_bot_trap_to_smartcrawl_robots' ) ); } else { $this->inject_rule(); } } else { if ( $this->smartcrawl->is_activated() ) { remove_filter( 'smartcrawl_robots_txt_content', array( $this, 'add_bot_trap_to_smartcrawl_robots' ) ); } $this->remove_rule(); } } /** * Get rule block for robots.txt. * * @param string $eol The end-of-line character to use, defaults to "\n". * * @return string The block of text to be added to the robots.txt file. */ public function get_block( string $eol = PHP_EOL ): string { $disallow_path = $this->get_hash(); return $this->start_comment . 'User-agent: *' . $eol . "Disallow: /{$disallow_path}" . $this->end_comment; } /** * Inject the malicious bot rule into the robots.txt. */ public function inject_rule() { $wp_filesystem = $this->get_wp_filesystem(); if ( $wp_filesystem->exists( $this->robots_file ) ) { // Inject rule into physical robots.txt file. $contents = $wp_filesystem->get_contents( $this->robots_file ); if ( ! str_contains( $contents, $this->start_comment ) ) { $contents = $this->add_rules_to_content( $contents ); $dir_name = pathinfo( $this->robots_file, PATHINFO_DIRNAME ); if ( $wp_filesystem->is_writable( $dir_name ) ) { $wp_filesystem->put_contents( $this->robots_file, $contents ); } } } else { // Inject virtual rule via filter. add_filter( 'robots_txt', array( $this, 'add_rules_to_content' ) ); } } /** * Add bot trap rules to SmartCrawl's robots.txt content. * * @param string $content The existing robots.txt content from SmartCrawl. * @return string The modified robots.txt content with bot trap rules. */ public function add_bot_trap_to_smartcrawl_robots( $content ): string { if ( ! $this->is_enabled() ) { return $content; } return $this->add_rules_to_content( $content ); } /** * Remove the bot trap rule from the robots.txt. */ public function remove_rule() { $wp_filesystem = $this->get_wp_filesystem(); if ( ! $wp_filesystem->exists( $this->robots_file ) ) { return; } $contents = $wp_filesystem->get_contents( $this->robots_file ); $eol = $this->detect_line_ending( $contents ); // Remove only the plugin block. $pattern = '/\\s*' . preg_quote( $this->start_comment, '/' ) . '.*?' . preg_quote( $this->end_comment, '/' ) . '\\s*/s'; $new_contents = preg_replace( $pattern, '', $contents ); $new_contents = trim( $new_contents ); if ( '' === $new_contents ) { // File only had plugin block — delete it. wp_delete_file( $this->robots_file ); } else { $dir_name = pathinfo( $this->robots_file, PATHINFO_DIRNAME ); if ( $wp_filesystem->is_writable( $dir_name ) ) { $wp_filesystem->put_contents( $this->robots_file, $new_contents . $eol ); } } } /** * Log the event into db, we will use the data in logs page later. * * @param string $ip The IP address involved in the event. * @param string $uri The URI that was accessed. * @param string $scenario The scenario under which the event is logged. */ public function log_event( $ip, $uri, $scenario ) { $model = new Lockout_Log(); $model->ip = $ip; $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' ); $model->user_agent = isset( $user_agent ) ? User_Agent::fast_cleaning( $user_agent ) : null; $model->date = time(); $model->tried = $uri; $model->blog_id = get_current_blog_id(); $ip_to_country = $this->ip_to_country( $ip ); if ( isset( $ip_to_country['iso'] ) ) { $model->country_iso_code = $ip_to_country['iso']; } switch ( $scenario ) { case self::SCENARIO_MALICIOUS_BOT: default: $model->type = Lockout_Log::LOCKOUT_MALICIOUS_BOT; $model->log = esc_html__( 'Lockout occurred: Bot ignored robots.txt rules.', 'defender-security' ); break; } $model->save(); // Notify directly because the 'defender_notify' hook isn't registered yet at firewall-time. wd_di()->get( Firewall_Notification::class )->notify_lockout( $model ); } /** * Creates a lockout for a blocked IP. * * @param Lockout_Ip $model The lockout IP model. * @param string $message The lockout message. * @param int $time The timestamp when the lockout will be lifted. */ public function create_blocked_lockout( &$model, $message, $time ) { $model->lockout_message = $message; $model->release_time = $time; $model->save(); } /** * Merge the rule into existing User-agent: * block if present, * otherwise prepends a new block. * * @param string $contents The existing robots.txt content. * * @return string The modified robots.txt content with Defender rule added. */ public function add_rules_to_content( string $contents ): string { if ( preg_match( self::USER_AGENT_REGEX, $contents ) ) { $disallow_path = $this->get_hash(); $defender_rule = "{$this->start_comment}Disallow: /$disallow_path{$this->end_comment}"; return preg_replace( self::USER_AGENT_REGEX, '$1' . PHP_EOL . $defender_rule, $contents, 1 ); } return $this->get_block( PHP_EOL ) . $contents; } /** * Detect if there's an empty Disallow line in robots.txt. * * @return bool True if empty Disallow line is detected, false otherwise. */ public function has_empty_disallow_line(): bool { $contents = ''; $wp_filesystem = $this->get_wp_filesystem(); // Check physical robots.txt file first. if ( $wp_filesystem->exists( $this->robots_file ) ) { $contents = $wp_filesystem->get_contents( $this->robots_file ); } elseif ( $this->smartcrawl->should_serve_robots() ) { // Get SmartCrawl virtual robots.txt content. $contents = $this->smartcrawl->get_robots_content(); } else { // Get WordPress virtual robots.txt content. $contents = apply_filters( 'robots_txt', '', false ); } if ( ! is_string( $contents ) || '' === trim( $contents ) ) { return false; } // Pattern to match "Disallow:" followed by optional whitespace and end of line (empty value). // Allows for optional leading whitespace before "Disallow:". $pattern = '/^\s*Disallow:\s*$/mi'; if ( ! preg_match( $pattern, $contents ) ) { return false; } // Make sure it's not within our managed block. $defender_block_pattern = '/' . preg_quote( $this->start_comment, '/' ) . '.*?' . preg_quote( $this->end_comment, '/' ) . '/s'; $contents_without_defender = preg_replace( $defender_block_pattern, '', $contents ); return preg_match( $pattern, $contents_without_defender ) === 1; } /** * Registers a rewrite rule for the malicious bot URL. * The URL will be in the format: /{hash}/ * where {hash} is a 16-character hexadecimal string. */ public function register_rewrite_rule() { $hash = $this->get_hash(); add_rewrite_rule( "^{$hash}/?$", 'index.php?' . self::URL_QUERY . '=' . $hash, 'top' ); } }