/** * Plugin Name: Efront 404 Guard * Plugin URI: https://efront.digital * Description: Monitors and manages 404 requests, blocks malicious bots, and provides an admin interface for configuration and reporting. * Version: 1.0.1 * Author: Efront * Author URI: https://efront.digital * License: GPL v2 or later * License URI: https://www.gnu.org/licenses/gpl-2.0.html * Text Domain: efront-404-guard */ if (!defined('ABSPATH')) { exit; } define('EFRONT_404_GUARD_VERSION', '1.0.1'); define('EFRONT_404_GUARD_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('EFRONT_404_GUARD_PLUGIN_URL', plugin_dir_url(__FILE__)); // Load required class files require_once EFRONT_404_GUARD_PLUGIN_DIR . 'includes/class-efront-404-monitor.php'; require_once EFRONT_404_GUARD_PLUGIN_DIR . 'includes/class-efront-404-monitor-settings.php'; require_once EFRONT_404_GUARD_PLUGIN_DIR . 'includes/class-efront-404-monitor-guard.php'; // Plugin Update Checker if (!class_exists('YahnisElsts\\PluginUpdateChecker\\v5p6\\PucFactory')) { require_once EFRONT_404_GUARD_PLUGIN_DIR . 'includes/plugin-update-checker/plugin-update-checker.php'; } use YahnisElsts\PluginUpdateChecker\v5p6\PucFactory; // Function to check if tables exist function efront_404_guard_check_tables() { global $wpdb; $tables = array( $wpdb->prefix . 'efront_blocked_ips', $wpdb->prefix . 'efront_404_logs', $wpdb->prefix . 'efront_404_logs_archive' ); foreach ($tables as $table) { if ($wpdb->get_var("SHOW TABLES LIKE '$table'") != $table) { return false; } } return true; } // Function to create tables if they don't exist function efront_404_guard_create_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); // Create blocked IPs table $table_name = $wpdb->prefix . 'efront_blocked_ips'; $sql1 = "CREATE TABLE IF NOT EXISTS $table_name ( id bigint(20) NOT NULL AUTO_INCREMENT, ip_address varchar(45) NOT NULL, blocked tinyint(1) NOT NULL DEFAULT 1, block_time datetime NOT NULL, details text, country varchar(64) DEFAULT NULL, PRIMARY KEY (id), KEY ip_address (ip_address), KEY blocked (blocked) ) $charset_collate;"; // Create 404 logs table $logs_table = $wpdb->prefix . 'efront_404_logs'; $sql2 = "CREATE TABLE IF NOT EXISTS $logs_table ( id bigint(20) NOT NULL AUTO_INCREMENT, time datetime NOT NULL, ip_address varchar(45) NOT NULL, request_uri varchar(255) NOT NULL, user_agent text, PRIMARY KEY (id), KEY ip_address (ip_address), KEY time (time) ) $charset_collate;"; // Create archive table $archive_table = $wpdb->prefix . 'efront_404_logs_archive'; $sql3 = "CREATE TABLE IF NOT EXISTS $archive_table ( id bigint(20) NOT NULL AUTO_INCREMENT, time datetime NOT NULL, ip_address varchar(45) NOT NULL, request_uri varchar(255) NOT NULL, user_agent text, request_type int(11) NOT NULL, PRIMARY KEY (id), KEY ip_address (ip_address), KEY time (time), KEY request_type (request_type) ) $charset_collate;"; require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); // Execute each table creation separately dbDelta($sql1); dbDelta($sql2); dbDelta($sql3); // Verify all tables were created $tables = array( $wpdb->prefix . 'efront_blocked_ips', $wpdb->prefix . 'efront_404_logs', $wpdb->prefix . 'efront_404_logs_archive' ); foreach ($tables as $table) { if ($wpdb->get_var("SHOW TABLES LIKE '$table'") != $table) { error_log('Efront 404 Guard: Failed to create table - ' . $table); return false; } } return true; } // Initialize the plugin function efront_404_guard_init() { // Ensure tables exist if (!efront_404_guard_check_tables()) { if (!efront_404_guard_create_tables()) { error_log('Efront 404 Guard: Failed to create database tables during initialization'); return; } } // Initialize the monitor $monitor = new Efront_404_Monitor(); // Initialize settings $settings = new Efront_404_Monitor_Settings(); $settings->init(); // Initialize guard $guard = new Efront_404_Monitor_Guard(); // Set up plugin update checker $efront_404_guard_update_checker = PucFactory::buildUpdateChecker( 'https://dedicated.4.efront.digital/plugins/efront_404_guard/update.json', __FILE__, 'efront_404_guard' ); } add_action('plugins_loaded', 'efront_404_guard_init'); // Activation hook register_activation_hook(__FILE__, 'efront_404_guard_activate'); function efront_404_guard_activate() { // Create tables if (!efront_404_guard_create_tables()) { error_log('Efront 404 Guard: Failed to create database tables during activation'); return false; } // Set default options add_option('efront_404_max_requests', 4); add_option('efront_404_time_period', 2); add_option('efront_404_archive_retention', 30); add_option('efront_404_allow_akamai', 0); add_option('efront_404_allow_anonymous_data', 0); add_option('efront_404_timezone', 'Australia/Sydney'); return true; } // Deactivation hook register_deactivation_hook(__FILE__, 'efront_404_guard_deactivate'); function efront_404_guard_deactivate() { // Clean up any scheduled events wp_clear_scheduled_hook('efront_404_guard_cleanup'); } // Uninstall hook register_uninstall_hook(__FILE__, 'efront_404_guard_uninstall'); function efront_404_guard_uninstall() { global $wpdb; // List of tables to drop $tables = array( $wpdb->prefix . 'efront_blocked_ips', $wpdb->prefix . 'efront_404_logs', $wpdb->prefix . 'efront_404_logs_archive' ); // Drop tables if they exist foreach ($tables as $table) { if ($wpdb->get_var("SHOW TABLES LIKE '$table'") == $table) { $result = $wpdb->query("DROP TABLE IF EXISTS $table"); if ($result === false) { error_log('Efront 404 Guard: Failed to drop table - ' . $table); } } } // Delete options $options = array( 'efront_404_max_requests', 'efront_404_time_period', 'efront_404_archive_retention', 'efront_404_allow_akamai', 'efront_404_allow_anonymous_data', 'efront_404_timezone' ); foreach ($options as $option) { delete_option($option); } } /** * Get the real IP address of the visitor * * @return string The visitor's IP address */ function efront_404_guard_get_real_ip() { // List of headers that might contain the real IP $ip_headers = array( 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR' ); // Check each header foreach ($ip_headers as $header) { if (!empty($_SERVER[$header])) { // Handle multiple IPs in X-Forwarded-For $ips = explode(',', $_SERVER[$header]); $ip = trim($ips[0]); // Check if it's an IPv6 address if (strpos($ip, ':') !== false) { // Validate IPv6 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { // Check if it's a private IPv6 range if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { continue; // Skip private/reserved IPv6 } return $ip; } } else { // Validate IPv4 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return $ip; } } } } // Fallback to REMOTE_ADDR $fallback_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; // Validate fallback IP if (strpos($fallback_ip, ':') !== false) { // IPv6 fallback if (filter_var($fallback_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { return $fallback_ip; } } else { // IPv4 fallback if (filter_var($fallback_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return $fallback_ip; } } return '0.0.0.0'; } // Store the IP for later use $efront_404_guard_client_ip = efront_404_guard_get_real_ip(); // Check if the IP is blocked and return 401 if so if (efront_404_guard_is_ip_blocked($efront_404_guard_client_ip)) { $uri = $_SERVER['REQUEST_URI'] ?? ''; $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; // Log the blocked request efront_404_guard_log_archive($efront_404_guard_client_ip, $uri, $user_agent, 401); // Send 401 response header('HTTP/1.1 401 Unauthorized'); header('Content-Type: text/plain'); echo 'Access Denied'; exit; } /** * Check if an IP is blocked in the block table * * @param string $ip The IP address * @return bool True if blocked, false otherwise */ function efront_404_guard_is_ip_blocked($ip) { global $wpdb; // Check if table exists if (!efront_404_guard_check_tables()) { return false; } $table = $wpdb->prefix . 'efront_blocked_ips'; $row = $wpdb->get_row($wpdb->prepare("SELECT blocked FROM $table WHERE ip_address = %s ORDER BY id DESC LIMIT 1", $ip)); return ($row && intval($row->blocked) === 1); } /** * Get current time in the configured timezone * * @return string Current time in MySQL format */ function efront_404_guard_get_current_time() { $timezone = get_option('efront_404_timezone', 'Australia/Sydney'); $date = new DateTime('now', new DateTimeZone($timezone)); return $date->format('Y-m-d H:i:s'); } /** * Check if an IP address belongs to Googlebot or Akamai * * @param string $ip The IP address to check * @param string|null $user_agent Optional user agent string for additional verification * @return string|false 'google', 'akamai', or false */ function efront_404_guard_is_trusted_ip($ip, $user_agent = null) { $host = gethostbyaddr($ip); if (!$host) { return false; } // Googlebot check if ($user_agent && stripos($user_agent, 'Googlebot') !== false) { if (substr($host, -13) === '.googlebot.com' || substr($host, -10) === '.google.com') { $resolved_ips = gethostbynamel($host); if ($resolved_ips && in_array($ip, $resolved_ips)) { return 'google'; } } } // Akamai check $akamai_domains = [ '.akamai.net', '.akamaiedge.net', '.akamaitechnologies.com', '.akamaized.net', '.akamaistream.net', '.akamaitechnologies.fr', '.akamaized-staging.net', ]; foreach ($akamai_domains as $domain) { if (substr($host, -strlen($domain)) === $domain) { $resolved_ips = gethostbynamel($host); if ($resolved_ips && in_array($ip, $resolved_ips)) { return 'akamai'; } } } return false; } /** * Check if a request URI ends with any of the specified suffixes to ignore * * @param string $uri The request URI * @return bool True if the URI should be ignored, false otherwise */ function efront_404_guard_should_ignore_suffix($uri) { $suffixes = ['.png', '.jpg', '.jpeg', '.gif', '.pdf', '.svg', 'crop', 'js', 'xml', '.ico', '.ttf', '.woff', '.eot', '.map']; $uri = strtolower(parse_url($uri, PHP_URL_PATH) ?? $uri); foreach ($suffixes as $suffix) { if (substr($uri, -strlen($suffix)) === $suffix) { return true; } } return false; } /** * Instantly block non-Google (and optionally non-Akamai) IPs that request dangerous suffixes * * @param string $ip The IP address * @param string $uri The requested URI * @param string $trusted_type 'google', 'akamai', or false * @param bool $allow_akamai Whether Akamai is allowed * @return bool True if blocked, false otherwise */ function efront_404_guard_block_on_dangerous_suffix($ip, $uri, $trusted_type = false, $allow_akamai = false) { $block_suffixes = ['.env', '.key', '.asp', '.log', '.sh', '.svc', '.wadl', '.conf', '.sql', '_log', '.log', '.yml', '.key', '.gz']; $uri_path = strtolower(parse_url($uri, PHP_URL_PATH) ?? $uri); foreach ($block_suffixes as $suffix) { if (substr($uri_path, -strlen($suffix)) === $suffix) { // If Google or Akamai (when allowed), add to block table with blocked=0 and details if ($trusted_type === 'google') { efront_404_guard_block_ip($ip, 'Google', 0); return false; } if ($trusted_type === 'akamai' && $allow_akamai) { efront_404_guard_block_ip($ip, 'Akamai', 0); return false; } // Otherwise, block this IP efront_404_guard_block_ip($ip, $uri); return true; } } return false; } /** * Get offender details from AbuseIPDB for a given IP address */ function efront_404_guard_get_offender_details($ip) { global $wpdb; $table = $wpdb->prefix . 'efront_blocked_ips'; $transient_key = 'efront_404_guard_ip_' . str_replace('.', '_', $ip); $details = get_transient($transient_key); if ($details !== false) { return $details; } // First check if we already have details for this IP in the DB $existing = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $table WHERE ip_address = %s", $ip )); if ($existing && !empty($existing->details)) { set_transient($transient_key, $existing->details, DAY_IN_SECONDS); return $existing->details; } // Get AbuseIPDB data if API key is available $abuseipdb_key = get_option('efront_404_guard_abuseipdb_key'); $details = ''; if (!empty($abuseipdb_key)) { $response = wp_remote_get("https://api.abuseipdb.com/api/v2/check?ipAddress=" . urlencode($ip), [ 'headers' => [ 'Key' => $abuseipdb_key, 'Accept' => 'application/json' ] ]); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { $data = json_decode(wp_remote_retrieve_body($response), true); if ($data && isset($data['data'])) { $details = sprintf( 'Abuse Score: %d%% | Country: %s | Domain: %s | Total Reports: %d', $data['data']['abuseConfidenceScore'], $data['data']['countryCode'] ?? 'Unknown', $data['data']['domain'] ?? 'Unknown', $data['data']['totalReports'] ?? 0 ); } } } // If no AbuseIPDB data or key not available, try ipinfo.io if (empty($details)) { $ipinfo_token = get_option('efront_404_guard_ipinfo_token'); if (!empty($ipinfo_token)) { $url = "https://api.ipinfo.io/lite/{$ip}?token={$ipinfo_token}"; } else { $url = "https://ipinfo.io/{$ip}/json"; } $response = wp_remote_get($url); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { $data = json_decode(wp_remote_retrieve_body($response), true); if ($data) { $country = $data['country'] ?? ($data['country_code'] ?? 'Unknown'); $region = $data['region'] ?? 'Unknown'; $city = $data['city'] ?? 'Unknown'; $org = $data['org'] ?? ($data['asn'] ?? 'Unknown'); $details = sprintf( 'Country: %s | Region: %s | City: %s | Organization: %s', $country, $region, $city, $org ); } } } // Store the details in the database and transient if (!empty($details)) { set_transient($transient_key, $details, DAY_IN_SECONDS); if ($existing) { $wpdb->update( $table, ['details' => $details], ['ip_address' => $ip] ); } else { $wpdb->insert( $table, [ 'ip_address' => $ip, 'details' => $details, 'blocked' => 1, 'block_time' => current_time('mysql') ] ); } } return $details; } /** * Insert an IP into the block table * * @param string $ip The IP address * @param string $details Details (e.g., requested URL) */ function efront_404_guard_block_ip($ip, $details = '', $blocked = 1) { global $wpdb; $table = $wpdb->prefix . 'efront_blocked_ips'; // Sanitize inputs $ip = filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; $details = sanitize_text_field($details); $blocked = absint($blocked); if (empty($ip)) { return; } // Only block if not already blocked $row = $wpdb->get_row($wpdb->prepare( "SELECT id FROM $table WHERE ip_address = %s AND blocked = 1", $ip )); if ($blocked == 1) { if ($row) { // Already blocked, do not block again return; } // Get AbuseIPDB details $details = efront_404_guard_get_offender_details($ip); } // Extract country from details string $country = null; if ($details && preg_match('/Country\s*:\s*([^|]+)/', $details, $matches)) { $country = trim($matches[1]); } $wpdb->insert( $table, array( 'ip_address' => $ip, 'blocked' => $blocked, 'block_time' => efront_404_guard_get_current_time(), 'details' => $details, 'country' => $country, ), array('%s', '%d', '%s', '%s', '%s') ); } /** * Log a 404 request to the logs table */ function efront_404_guard_log_404($ip, $uri, $user_agent) { global $wpdb; $table = $wpdb->prefix . 'efront_404_logs'; // Sanitize inputs $ip = filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; $uri = esc_url_raw($uri); $user_agent = sanitize_text_field($user_agent); if (empty($ip)) { return; } // Fetch and cache geolocation details for this IP efront_404_guard_get_offender_details($ip); $wpdb->insert( $table, array( 'time' => efront_404_guard_get_current_time(), 'ip_address' => $ip, 'request_uri' => $uri, 'user_agent' => $user_agent, ), array('%s', '%s', '%s', '%s') ); } /** * Log a request to the archive table */ function efront_404_guard_log_archive($ip, $uri, $user_agent, $response_code) { global $wpdb; $table = $wpdb->prefix . 'efront_404_logs_archive'; // Sanitize inputs $ip = filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; $uri = esc_url_raw($uri); $user_agent = sanitize_text_field($user_agent); $response_code = absint($response_code); if (empty($ip)) { return; } $wpdb->insert( $table, array( 'time' => efront_404_guard_get_current_time(), 'ip_address' => $ip, 'request_uri' => $uri, 'user_agent' => $user_agent, 'request_type' => $response_code, ), array('%s', '%s', '%s', '%s', '%d') ); } /** * Check if an IP exceeds the 404 threshold in the logs table */ function efront_404_guard_check_threshold($ip, $max_requests, $time_window) { global $wpdb; $table = $wpdb->prefix . 'efront_404_logs'; // Sanitize inputs $ip = filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; $max_requests = absint($max_requests); $time_window = absint($time_window); if (empty($ip)) { return false; } $count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE ip_address = %s AND time >= DATE_SUB(NOW(), INTERVAL %d SECOND)", $ip, $time_window )); return (int)$count >= $max_requests; } // Main 404 handling logic (to be called on 404) function efront_404_guard_handle_404($ip, $uri, $user_agent, $max_requests, $time_window) { // Log to logs table efront_404_guard_log_404($ip, $uri, $user_agent); // Log to archive table (404 response) efront_404_guard_log_archive($ip, $uri, $user_agent, 404); // Check threshold if (efront_404_guard_check_threshold($ip, $max_requests, $time_window)) { // Block the IP efront_404_guard_block_ip($ip, 'Auto-blocked: too many 404s'); } } // If blocked, always log to archive with the response code function efront_404_guard_log_if_blocked($ip, $uri, $user_agent, $response_code) { if (efront_404_guard_is_ip_blocked($ip)) { efront_404_guard_log_archive($ip, $uri, $user_agent, $response_code); } } // To log a 200 response, use: // efront_404_guard_log_archive($ip, $uri, $user_agent, 200); // This will log the request to the archive table with a 200 response code. // Automatically log all 200 responses to the archive table add_action('shutdown', function() use ($efront_404_guard_client_ip) { if (http_response_code() === 200) { if (function_exists('is_user_logged_in') && is_user_logged_in()) { $current_user = wp_get_current_user(); if ($current_user && !empty($current_user->ID) && user_can($current_user, 'manage_options')) { global $wpdb; $table = $wpdb->prefix . 'efront_blocked_ips'; $is_allowed = $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $table WHERE ip_address = %s AND blocked = 0", $efront_404_guard_client_ip)); if ($is_allowed) { return; // Do not log allowed IP traffic for logged-in admins } } } $uri = $_SERVER['REQUEST_URI'] ?? ''; $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; efront_404_guard_log_archive($efront_404_guard_client_ip, $uri, $user_agent, 200); } }); // WordPress admin user allow-list logic if (function_exists('is_user_logged_in') && is_user_logged_in()) { $current_user = wp_get_current_user(); if ($current_user && !empty($current_user->ID) && user_can($current_user, 'manage_options')) { $admin_ip = $_SERVER['REMOTE_ADDR']; global $wpdb; $table = $wpdb->prefix . 'efront_blocked_ips'; $exists = $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $table WHERE ip_address = %s AND blocked = 0", $admin_ip)); if (!$exists) { $details = 'WP Admin : ' . $current_user->user_login . ' ' . $current_user->user_email; $wpdb->insert($table, array( 'ip_address' => $admin_ip, 'blocked' => 0, 'block_time' => efront_404_guard_get_current_time(), 'details' => $details )); } // Do not log or block anything for this user return; } } // The actual monitoring code will be added here in the next phase function efront_404_guard_maybe_upgrade_db() { global $wpdb; $table = $wpdb->prefix . 'efront_blocked_ips'; $column = $wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'country'"); if (empty($column)) { $wpdb->query("ALTER TABLE $table ADD COLUMN country VARCHAR(64) DEFAULT NULL"); } // Update plugin version option update_option('efront_404_guard_version', '1.1.0'); } add_action('plugins_loaded', 'efront_404_guard_maybe_upgrade_db');