/** * 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'); Projects Archive | Hunt Architects

Hunt Architects acknowledge the Australian Aboriginal and Torres Strait Islander peoples as the Traditional Custodians of the lands on which we live and conduct our business.

We pay our respects to Elders past and present. We value their continuing culture and contribution to the life of our nation, regions and cities.

Commercial & Civic

Albany, WA

National Anzac Centre

Sport & Recreation

Floreat, WA

Bendat Basketball Centre

Commercial & Civic

Karratha, WA

Red Earth Arts Precinct

Aged Care, Multi-Residential

Melbourne, Victoria

VMCH O’Neill House

Aquatic, Sport & Recreation

Karratha, WA

Karratha Leisureplex

Commercial & Civic, Education

Bunbury, WA

Bunbury Library