Changeset 3790231
- Timestamp:
- 11/07/2026 10:02:29 PM (23 hours ago)
- Location:
- cyberpulse/trunk
- Files:
-
- 5 edited
-
cyberpulse-core.php (modified) (25 diffs)
-
cyberpulse-htaccess.php (modified) (1 diff)
-
cyberpulse.php (modified) (5 diffs)
-
readme.txt (modified) (2 diffs)
-
uninstall.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
cyberpulse/trunk/cyberpulse-core.php
r3784916 r3790231 316 316 define('CYBERSEC_PERMABAN_LADDER_OPTION', 'cybersec_permaban_ladder'); 317 317 define('CYBERSEC_PERMABAN_MIGRATION_OPTION', 'cybersec_permaban_migrated'); 318 define('CYBERSEC_VERSION', '5.9.1.6'); 318 define('CYBERSEC_VERSION', '5.9.1.7'); 319 320 /** Schema revision of the temporary-block table. Bump to re-run dbDelta(). */ 321 define('CYBERSEC_TB_SCHEMA', '2'); 319 322 320 323 /** … … 2084 2087 2085 2088 /** 2086 * Collects every fact the plugin knows about one address.2087 *2088 * Read-only and side-effect free: used by `wp cyberpulse check-ip` and by2089 * support work, so that "why is this visitor blocked?" has one answer instead2090 * of a hand-written sequence of get_option() calls.2091 *2092 * @param string $ip2093 * @return array2094 */2095 function cybersec_diagnose_ip($ip) {2096 $ip = (string) $ip;2097 2098 if (!filter_var($ip, FILTER_VALIDATE_IP)) {2099 return array('valid' => false, 'ip' => $ip);2100 }2101 2102 // Which permanent record covers this address, if any?2103 $permaban_key = null;2104 $permanent = cybersec_get_permanent_blocked_subnets();2105 $now = time();2106 2107 foreach ($permanent as $subnet => $data) {2108 $expires = cybersec_permaban_expires($data);2109 if ($expires > 0 && $expires < $now) {2110 continue;2111 }2112 if (cybersec_ip_in_subnet($ip, (string) $subnet)) {2113 $permaban_key = (string) $subnet;2114 break;2115 }2116 }2117 2118 $tb = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array());2119 $tb_key = cybersec_temp_block_key_for($ip, $tb);2120 $ladder = get_option(CYBERSEC_PERMABAN_LADDER_OPTION, array());2121 $net = cybersec_get_subnet_24($ip);2122 $brute = get_option(CYBERSEC_BRUTE_OPTION, array());2123 $offend = get_option(CYBERSEC_REPEAT_OPTION, array());2124 2125 return array(2126 'valid' => true,2127 'ip' => $ip,2128 'network' => cybersec_ip_cache_scope($ip),2129 'country' => cybersec_get_country_by_ip($ip),2130 'whitelisted' => cybersec_is_whitelisted($ip),2131 'admin_ip' => cybersec_is_admin_ip($ip),2132 'server_ip' => cybersec_is_server_ip($ip),2133 'internal' => cybersec_is_internal_ip($ip),2134 'datacenter' => cybersec_is_datacenter_ip($ip),2135 'permanent_ban' => $permaban_key,2136 'temp_block_key' => $tb_key,2137 'temp_block_until' => ($tb_key !== null) ? cybersec_temp_block_expires($tb[$tb_key]) : 0,2138 'ladder_hits' => (int) ($ladder[$net][$ip]['hits'] ?? 0),2139 'repeat_offender_runs' => (int) ($offend[$ip]['count'] ?? 0),2140 'brute_attempts' => (int) ($brute[$ip]['attempts'] ?? 0),2141 'test_mode' => cybersec_test_mode_enabled_for($ip),2142 'rehab' => (bool) get_transient('cybersec_rehab_' . $ip),2143 );2144 }2145 2146 /**2147 2089 * Lifts blocks, by scope. 2148 2090 * … … 2239 2181 2240 2182 return $result; 2241 }2242 2243 /**2244 * Aggregates block reasons from the daily logs.2245 *2246 * @param int $days How many daily files to read (1 = today only).2247 * @return array{total:int, by_reason:array, by_day:array}2248 */2249 function cybersec_block_report($days = 1) {2250 $days = max(1, (int) $days);2251 $total = 0;2252 $reasons = array();2253 $by_day = array();2254 2255 for ($i = 0; $i < $days; $i++) {2256 $date = current_time('Y-m-d', time() - $i * DAY_IN_SECONDS);2257 $file = CYBERSEC_LOG_DIR . 'blocked-bots-' . $date . '.log';2258 2259 if (!cybersec_fs_exists($file)) {2260 continue;2261 }2262 2263 $content = cybersec_fs_get_contents($file);2264 if (!is_string($content) || $content === '') {2265 continue;2266 }2267 2268 $day_count = 0;2269 2270 foreach (preg_split('/\r\n|\r|\n/', $content) as $line) {2271 if (strpos($line, 'BLOCKED') === false) {2272 continue;2273 }2274 2275 $day_count++;2276 $total++;2277 2278 // "… | Reason: <text> | UA: …"2279 $reason = 'unknown';2280 if (preg_match('/\|\s*Reason:\s*([^|]+)\|/', $line, $m)) {2281 $reason = trim($m[1]);2282 }2283 2284 // Group on the first word group, so "Hidden file scanner: /.env" and2285 // "Hidden file scanner: /.git/config" share a row.2286 $group = $reason;2287 if (strpos($reason, ':') !== false) {2288 $group = trim(substr($reason, 0, strpos($reason, ':')));2289 }2290 $group = preg_replace('/\s*\([^)]*\)\s*$/', '', $group);2291 2292 if ($group === '') {2293 $group = 'unknown';2294 }2295 2296 if (!isset($reasons[$group])) {2297 $reasons[$group] = 0;2298 }2299 $reasons[$group]++;2300 }2301 2302 $by_day[$date] = $day_count;2303 }2304 2305 arsort($reasons);2306 2307 return array('total' => $total, 'by_reason' => $reasons, 'by_day' => $by_day);2308 }2309 2310 /**2311 * Operational counters for `wp cyberpulse stats`.2312 *2313 * @return array2314 */2315 function cybersec_ops_snapshot() {2316 $stats = cybersec_get_daily_stats();2317 $tb = cybersec_temp_blocks_all();2318 $now = time();2319 $active_temp = 0;2320 2321 foreach ($tb as $entry) {2322 if (cybersec_temp_block_expires($entry) > $now) {2323 $active_temp++;2324 }2325 }2326 2327 $permanent = cybersec_get_permanent_blocked_subnets();2328 $wide = 0;2329 $host = 0;2330 2331 foreach ((array) $permanent as $subnet => $data) {2332 $parts = explode('/', (string) $subnet, 2);2333 $bits = isset($parts[1]) ? (int) $parts[1] : 32;2334 $is_v6 = isset($parts[0]) && filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;2335 2336 if ($is_v6 ? ($bits >= 128) : ($bits >= 32)) {2337 $host++;2338 } else {2339 $wide++;2340 }2341 }2342 2343 return array(2344 'blocked_today' => (int) ($stats['blocked'] ?? 0),2345 'allowed_today' => (int) ($stats['allowed'] ?? 0),2346 'brute_today' => (int) ($stats['brute'] ?? 0),2347 'temp_active' => $active_temp,2348 'permaban_hosts' => $host,2349 'permaban_wide' => $wide,2350 'cache_backend' => cybersec_runtime_cache_backend(),2351 'geolocation' => true,2352 );2353 2183 } 2354 2184 … … 4129 3959 4130 3960 if ($blocks === null) { 4131 $blocks = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array());3961 $blocks = cybersec_temp_blocks_all(); 4132 3962 } 4133 3963 … … 4153 3983 } 4154 3984 4155 /** 4156 * Returns the end timestamp of an active temp block covering $ip, or 0. 4157 * 4158 * @param string $ip 4159 * @param array|null $blocks Pre-loaded option value. 4160 * @return int 4161 */ 4162 /** 4163 * Name of the table the PRO build uses for temporary blocks, or '' when the 4164 * site has never run that build. 4165 * 4166 * The existence check is cached for the request and for the site: without the 4167 * persistent flag, the new bridge would run SHOW TABLES on every front-end 4168 * request (every visitor without an option temp block now consults the table). 4169 * The flag is cleared on activation and uninstall, when the table can change. 4170 * 4171 * @return string Table name with the site prefix, or '' when it does not exist. 4172 */ 4173 function cybersec_tb_bridge_table() { 4174 static $table = null; 4175 4176 if ($table !== null) { 4177 return $table; 4178 } 4179 3985 // ==================== TEMPORARY BLOCK STORE ==================== 3986 // 3987 // Temporary blocks used to live in one option row holding a map of every 3988 // blocked address. Every writer re-serialised the whole array, so two 3989 // concurrent blocks could silently overwrite each other and the loser's block 3990 // simply vanished. The store is now a dedicated table with one row per 3991 // address: each block is an independent INSERT/UPDATE, so concurrent writers 3992 // cannot clobber one another. 3993 // 3994 // The switch is deliberately additive and reversible: 3995 // - the option keeps being written as a mirror, so rolling back to an older 3996 // build still finds every block; 3997 // - on upgrade the existing option rows are copied into the table before 3998 // the table is used for reads; 3999 // - when the table cannot be created (no CREATE privilege, old MySQL), every 4000 // helper falls back to the option and the plugin behaves exactly as before. 4001 // 4002 // The table name and schema are shared with CyberPulse PRO, so a site can 4003 // switch builds without losing the blocks. 4004 4005 /** 4006 * Fully-prefixed name of the temporary-block table. 4007 * 4008 * @return string 4009 */ 4010 function cybersec_tb_table() { 4180 4011 global $wpdb; 4181 4012 4013 return $wpdb->prefix . 'cyberpulse_temp_blocks'; 4014 } 4015 4016 /** 4017 * Returns true when the temporary-block table exists. 4018 * 4019 * Cached per request: the check itself hits the database. 4020 * 4021 * @param bool $refresh Drop the cached verdict and probe again. 4022 * @return bool 4023 */ 4024 function cybersec_tb_ready($refresh = false) { 4025 static $ready = null; 4026 4027 if ($refresh) { 4028 $ready = null; 4029 $known = ''; 4030 } else { 4031 $known = get_option('cybersec_tb_present', ''); 4032 } 4033 4034 if ($ready !== null) { 4035 return $ready; 4036 } 4037 4038 // The verdict is also stored in an option: a SHOW TABLES on every request 4039 // would be a database round-trip on the hot path, and the table only 4040 // appears or disappears on activation, upgrade or uninstall. 4041 4042 if ($known === 'yes') { 4043 $ready = true; 4044 return $ready; 4045 } 4046 4047 if ($known === 'no') { 4048 $ready = false; 4049 return $ready; 4050 } 4051 4052 global $wpdb; 4053 4182 4054 if (!is_object($wpdb) || empty($wpdb->prefix)) { 4183 $table = ''; 4184 return $table; 4185 } 4186 4187 $candidate = $wpdb->prefix . 'cyberpulse_temp_blocks'; 4188 4189 $known = get_option('cybersec_tb_bridge_present', ''); 4190 4191 if ($known === 'yes' || $known === 'no') { 4192 $table = ($known === 'yes') ? $candidate : ''; 4193 4194 return $table; 4195 } 4196 4197 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Existence probe, result stored in an autoloaded option. 4198 $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $candidate)); 4199 4200 $table = ($found === $candidate) ? $candidate : ''; 4201 4202 update_option('cybersec_tb_bridge_present', ($table !== '') ? 'yes' : 'no', true); 4203 4204 return $table; 4205 } 4206 4207 /** 4208 * Expiry of a temporary block stored by the PRO build, 0 when there is none. 4209 * 4210 * @param string $ip Address. 4211 * @return int Unix timestamp, or 0. 4212 */ 4213 function cybersec_tb_bridge_expires($ip) { 4214 $table = cybersec_tb_bridge_table(); 4215 4216 if ($table === '' || !filter_var((string) $ip, FILTER_VALIDATE_IP)) { 4217 return 0; 4218 } 4219 4220 $table = esc_sql($table); 4221 4055 $ready = false; 4056 return $ready; 4057 } 4058 4059 $table = cybersec_tb_table(); 4060 4061 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time schema probe; the verdict is cached in an option. 4062 $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); 4063 $ready = ($found === $table); 4064 4065 update_option('cybersec_tb_present', $ready ? 'yes' : 'no', true); 4066 4067 return $ready; 4068 } 4069 4070 /** 4071 * Creates (or upgrades) the temporary-block table. Idempotent. 4072 * 4073 * @return bool True when the table is usable afterwards. 4074 */ 4075 function cybersec_tb_install() { 4222 4076 global $wpdb; 4223 4077 4224 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_bridge_table() (SHOW TABLES) and escaped with esc_sql(); the address is a placeholder. 4225 $expires = $wpdb->get_var($wpdb->prepare( 4226 "SELECT expires FROM `$table` WHERE ip = %s AND expires > %d", 4227 $ip, 4078 if (!is_object($wpdb) || empty($wpdb->prefix)) { 4079 return false; 4080 } 4081 4082 if (!function_exists('dbDelta')) { 4083 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 4084 } 4085 4086 $table = esc_sql(cybersec_tb_table()); 4087 $charset = $wpdb->get_charset_collate(); 4088 4089 // One row per address: the primary key is what makes concurrent writes 4090 // independent of each other. 4091 $sql = "CREATE TABLE $table ( 4092 ip varchar(45) NOT NULL, 4093 expires int(11) NOT NULL, 4094 country varchar(8) NOT NULL DEFAULT '', 4095 reason varchar(255) NOT NULL DEFAULT '', 4096 created_at int(11) NOT NULL DEFAULT 0, 4097 PRIMARY KEY (ip), 4098 KEY expires (expires) 4099 ) $charset;"; 4100 4101 dbDelta($sql); 4102 4103 $ready = cybersec_tb_ready(true); 4104 4105 // Refresh the persistent verdict so the next request does not probe again. 4106 update_option('cybersec_tb_present', $ready ? 'yes' : 'no', true); 4107 4108 return $ready; 4109 } 4110 4111 /** 4112 * Writes one temporary block. 4113 * 4114 * The expiry never moves backwards: a short automatic block must not shorten 4115 * a longer ban an administrator set by hand. 4116 * 4117 * @param string $ip Address or CIDR. 4118 * @param int $expires Unix timestamp. 4119 * @param string $reason Human-readable reason. 4120 * @param string $country Country code, when known. 4121 * @return bool 4122 */ 4123 function cybersec_tb_write($ip, $expires, $reason = '', $country = '') { 4124 global $wpdb; 4125 4126 if (!is_object($wpdb) || !cybersec_tb_ready()) { 4127 return false; 4128 } 4129 4130 $table = esc_sql(cybersec_tb_table()); 4131 4132 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix plus a plugin constant and escaped with esc_sql(); all values are placeholders. 4133 $ok = $wpdb->query($wpdb->prepare( 4134 "INSERT INTO `$table` (ip, expires, country, reason, created_at) 4135 VALUES (%s, %d, %s, %s, %d) 4136 ON DUPLICATE KEY UPDATE 4137 expires = GREATEST(expires, VALUES(expires)), 4138 country = VALUES(country), 4139 reason = VALUES(reason)", 4140 (string) $ip, 4141 (int) $expires, 4142 (string) $country, 4143 (string) $reason, 4228 4144 time() 4229 4145 )); 4230 4146 4231 return $expires ? (int) $expires : 0; 4232 } 4233 4234 /** 4235 * Mirrors a temporary block into the PRO table when it exists, so the other 4236 * build keeps enforcing it. 4237 * 4238 * @param string $target Address or CIDR. 4239 * @param int $expires Unix timestamp. 4147 if ($ok) { 4148 // Patch the in-request memo instead of re-reading the table: a block 4149 // must be visible to the rest of this request, but a SELECT per block 4150 // (and per migrated row) is pure overhead. 4151 cybersec_tb_note_row($ip, $expires, $country, $reason); 4152 4153 return true; 4154 } 4155 4156 // The write failed. The table may have been dropped by a PRO uninstall 4157 // while this build stayed active; re-probe so the persistent flag flips to 4158 // "no" and the option store takes over, instead of failing on every block. 4159 cybersec_tb_ready(true); 4160 4161 return false; 4162 } 4163 4164 /** 4165 * Records a successful write in the per-request memo. 4166 * 4167 * Does nothing when the memo has not been loaded yet — the next read will 4168 * simply fetch the real row from the table. 4169 * 4170 * @param string $ip 4171 * @param int $expires 4172 * @param string $country 4173 * @param string $reason 4240 4174 * @return void 4241 4175 */ 4242 function cybersec_tb_ bridge_put($target, $expires) {4243 $table = cybersec_tb_bridge_table();4244 4245 if ( $table === '' || strpos((string) $target, '/') !== false) {4176 function cybersec_tb_note_row($ip, $expires, $country = '', $reason = '') { 4177 // An already-expired write is never visible: the SELECT excludes those 4178 // rows, and the memo must not disagree with it. 4179 if ((int) $expires <= time()) { 4246 4180 return; 4247 4181 } 4248 4182 4249 $table = esc_sql($table); 4250 4251 global $wpdb; 4252 4253 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_bridge_table() (SHOW TABLES) and escaped with esc_sql(); all values are placeholders. 4254 $wpdb->query($wpdb->prepare( 4255 "INSERT INTO `$table` (ip, expires, country, reason, created_at) 4256 VALUES (%s, %d, %s, %s, %d) 4257 ON DUPLICATE KEY UPDATE expires = VALUES(expires), reason = VALUES(reason)", 4258 $target, 4259 (int) $expires, 4260 (string) cybersec_get_country_by_ip($target), 4261 '', 4262 time() 4263 )); 4264 } 4265 4266 /** 4267 * Removes the mirrored row when the block is lifted here. 4268 * 4269 * @param string $target Address or CIDR. 4270 * @return bool True when a row was deleted from the PRO table. 4271 */ 4272 function cybersec_tb_bridge_delete($target) { 4273 $table = cybersec_tb_bridge_table(); 4274 4275 if ($table === '' || strpos((string) $target, '/') !== false) { 4276 return false; 4277 } 4278 4279 $table = esc_sql($table); 4280 4281 global $wpdb; 4282 4283 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_bridge_table() (SHOW TABLES) and escaped with esc_sql(); the address is a placeholder. 4284 $deleted = $wpdb->query($wpdb->prepare("DELETE FROM `$table` WHERE ip = %s", $target)); 4285 4286 return (bool) $deleted; 4287 } 4288 4289 /** 4290 * Every temporary block the other build recorded, keyed by address. 4291 * 4292 * Used to merge the list into this build's view: counts, tables and the 4293 * enforcement check must not disagree just because two stores exist. 4294 * 4183 $rows = &cybersec_tb_rows_store(); 4184 4185 if ($rows === null) { 4186 return; 4187 } 4188 4189 $existing = isset($rows[(string) $ip]) ? (int) $rows[(string) $ip]['expires'] : 0; 4190 4191 $rows[(string) $ip] = array( 4192 'expires' => max($existing, (int) $expires), 4193 'country' => (string) $country, 4194 'reason' => (string) $reason, 4195 ); 4196 } 4197 4198 /** 4199 * Shared per-request row memo. 4200 * 4201 * A static inside cybersec_tb_rows() could not be updated after a write, which 4202 * forced either a SELECT per block or a stale read. Returning it by reference 4203 * lets cybersec_tb_note_row() patch the memo instead. 4204 * 4205 * @return array|null 4206 */ 4207 function &cybersec_tb_rows_store() { 4208 static $rows = null; 4209 4210 return $rows; 4211 } 4212 4213 /** 4214 * Returns every live row, keyed by address. 4215 * 4216 * @param bool $refresh Drop the per-request memo and re-read the table. 4295 4217 * @return array<string,array{expires:int,country:string,reason:string}> 4296 4218 */ 4297 function cybersec_tb_bridge_rows() { 4298 static $rows = null; 4219 function cybersec_tb_rows($refresh = false) { 4220 $rows = &cybersec_tb_rows_store(); 4221 4222 if ($refresh) { 4223 $rows = null; 4224 } 4299 4225 4300 4226 if ($rows !== null) { … … 4302 4228 } 4303 4229 4304 $ table = cybersec_tb_bridge_table();4305 if ($table === '') { 4306 $rows = array();4230 $rows = array(); 4231 4232 if (!cybersec_tb_ready()) { 4307 4233 return $rows; 4308 4234 } 4309 4235 4310 $table = esc_sql( $table);4236 $table = esc_sql(cybersec_tb_table()); 4311 4237 4312 4238 global $wpdb; 4313 4239 4314 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_ bridge_table() (SHOW TABLES) and escaped with esc_sql(); the timestamp is a placeholder.4240 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_ready() and escaped with esc_sql(); the timestamp is a placeholder. 4315 4241 $found = $wpdb->get_results($wpdb->prepare( 4316 4242 "SELECT ip, expires, country, reason FROM `$table` WHERE expires > %d", … … 4318 4244 ), ARRAY_A); 4319 4245 4320 $rows = array();4321 4246 if (is_array($found)) { 4322 4247 foreach ($found as $row) { … … 4333 4258 4334 4259 /** 4260 * Returns the block covering $ip (exact or CIDR), or null. 4261 * 4262 * @param string $ip 4263 * @return array|null 4264 */ 4265 function cybersec_tb_get($ip) { 4266 $ip = (string) $ip; 4267 4268 if ($ip === '') { 4269 return null; 4270 } 4271 4272 $rows = cybersec_tb_rows(); 4273 4274 if (isset($rows[$ip])) { 4275 return $rows[$ip]; 4276 } 4277 4278 foreach ($rows as $key => $row) { 4279 $key = (string) $key; 4280 4281 if (strpos($key, '/') !== false && cybersec_ip_in_subnet($ip, $key)) { 4282 return $row; 4283 } 4284 } 4285 4286 return null; 4287 } 4288 4289 /** 4290 * Deletes one block by its key. 4291 * 4292 * @param string $ip Address or CIDR. 4293 * @return bool True when a row was removed. 4294 */ 4295 function cybersec_tb_delete($ip) { 4296 global $wpdb; 4297 4298 if (!is_object($wpdb) || !cybersec_tb_ready()) { 4299 return false; 4300 } 4301 4302 $table = esc_sql(cybersec_tb_table()); 4303 4304 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_ready() and escaped with esc_sql(); the address is a placeholder. 4305 $deleted = $wpdb->query($wpdb->prepare("DELETE FROM `$table` WHERE ip = %s", (string) $ip)); 4306 4307 cybersec_tb_rows(true); 4308 4309 return (bool) $deleted; 4310 } 4311 4312 /** 4313 * Deletes every block. 4314 * 4315 * @return bool 4316 */ 4317 function cybersec_tb_clear() { 4318 global $wpdb; 4319 4320 if (!is_object($wpdb) || !cybersec_tb_ready()) { 4321 return false; 4322 } 4323 4324 $table = esc_sql(cybersec_tb_table()); 4325 4326 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_ready() and escaped with esc_sql(). 4327 $ok = $wpdb->query("DELETE FROM `$table`"); 4328 4329 cybersec_tb_rows(true); 4330 4331 return (bool) $ok; 4332 } 4333 4334 /** 4335 * Deletes expired rows. Returns the number removed (0 when unknown). 4336 * 4337 * @return int 4338 */ 4339 function cybersec_tb_purge_expired() { 4340 global $wpdb; 4341 4342 if (!is_object($wpdb) || !cybersec_tb_ready()) { 4343 return 0; 4344 } 4345 4346 $table = esc_sql(cybersec_tb_table()); 4347 4348 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_ready() and escaped with esc_sql(); the timestamp is a placeholder. 4349 $deleted = $wpdb->query($wpdb->prepare("DELETE FROM `$table` WHERE expires <= %d", time())); 4350 4351 cybersec_tb_rows(true); 4352 4353 return (int) $deleted; 4354 } 4355 4356 /** 4357 * Copies the legacy option rows into the table. 4358 * 4359 * Expired rows are skipped. A row already present in the table keeps the later 4360 * expiry (cybersec_tb_write() never shortens one), so a site that previously 4361 * ran PRO does not lose a longer ban. The option itself is left in place: it is 4362 * the rollback path and the read fallback. 4363 * 4364 * @param bool $force Run even when the migration flag is already set. 4365 * @return int Number of rows copied. 4366 */ 4367 function cybersec_tb_migrate_option($force = false) { 4368 if (!cybersec_tb_ready()) { 4369 return 0; 4370 } 4371 4372 if (!$force && get_option('cybersec_tb_migrated_v2')) { 4373 return 0; 4374 } 4375 4376 $legacy = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 4377 $now = time(); 4378 $moved = 0; 4379 4380 if (is_array($legacy)) { 4381 foreach ($legacy as $key => $entry) { 4382 $key = (string) $key; 4383 4384 if ($key === '') { 4385 continue; 4386 } 4387 4388 $expires = cybersec_temp_block_expires($entry); 4389 4390 if ($expires <= $now) { 4391 continue; 4392 } 4393 4394 $country = is_array($entry) && isset($entry['country']) ? (string) $entry['country'] : ''; 4395 $reason = is_array($entry) && isset($entry['reason']) ? (string) $entry['reason'] : ''; 4396 4397 if (cybersec_tb_write($key, $expires, $reason, $country)) { 4398 $moved++; 4399 } 4400 } 4401 } 4402 4403 update_option('cybersec_tb_migrated_v2', 1, false); 4404 4405 return $moved; 4406 } 4407 4408 /** 4409 * Creates the table and migrates the legacy rows once per site. 4410 * 4411 * Runs in wp-admin only: an update is followed by an admin visit, and a 4412 * front-end request must never pay for dbDelta(). If the table cannot be 4413 * created, the failure is remembered for a day so the plugin does not run 4414 * dbDelta() on every page load, and the option store keeps working. 4415 * 4416 * @return void 4417 */ 4418 function cybersec_tb_maybe_upgrade() { 4419 if (get_option('cybersec_tb_schema') === CYBERSEC_TB_SCHEMA) { 4420 // The table may have been created by the PRO build while this one was 4421 // inactive (PRO deactivates FREE, not the other way round). Adopt it 4422 // instead of running the option store forever. The re-probe is 4423 // throttled so a host where the table genuinely does not exist does 4424 // not pay for SHOW TABLES on every admin page. 4425 if (get_option('cybersec_tb_present') === 'yes') { 4426 return; 4427 } 4428 4429 if (get_transient('cybersec_tb_recheck')) { 4430 return; 4431 } 4432 4433 set_transient('cybersec_tb_recheck', 1, DAY_IN_SECONDS); 4434 4435 if (cybersec_tb_ready(true)) { 4436 cybersec_tb_migrate_option(true); 4437 } 4438 4439 return; 4440 } 4441 4442 if (get_transient('cybersec_tb_schema_lock')) { 4443 return; 4444 } 4445 4446 set_transient('cybersec_tb_schema_lock', 1, 5 * MINUTE_IN_SECONDS); 4447 4448 if (cybersec_tb_install()) { 4449 cybersec_tb_migrate_option(true); 4450 update_option('cybersec_tb_schema', CYBERSEC_TB_SCHEMA); 4451 } else { 4452 // Do not retry dbDelta() on every request; the option store is the 4453 // fallback until the next activation or plugin update. 4454 update_option('cybersec_tb_schema', CYBERSEC_TB_SCHEMA); 4455 update_option('cybersec_tb_schema_failed', time(), false); 4456 } 4457 4458 delete_transient('cybersec_tb_schema_lock'); 4459 } 4460 add_action('admin_init', 'cybersec_tb_maybe_upgrade'); 4461 4462 4463 /** 4335 4464 * Every temporary block this build should show, from both stores. 4336 4465 * … … 4348 4477 } 4349 4478 4350 return array_merge(cybersec_tb_bridge_rows(), $option); 4479 // The table is the authoritative store; the option is the mirror written 4480 // for rollback and for hosts where the table could not be created. On a 4481 // key collision the LATER expiry wins: a site that previously ran PRO may 4482 // have a longer ban in the table than the one the mirror remembers, and a 4483 // ban must never be shortened by a merge. 4484 if (cybersec_tb_ready()) { 4485 $merged = cybersec_tb_rows(); 4486 4487 foreach ($option as $key => $entry) { 4488 $key = (string) $key; 4489 4490 if (!isset($merged[$key]) 4491 || cybersec_temp_block_expires($entry) >= cybersec_temp_block_expires($merged[$key])) { 4492 $merged[$key] = $entry; 4493 } 4494 } 4495 4496 return $merged; 4497 } 4498 4499 return $option; 4351 4500 } 4352 4501 … … 4362 4511 update_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 4363 4512 4364 $table = cybersec_tb_bridge_table(); 4365 4366 if ($table !== '') { 4367 global $wpdb; 4368 4369 $table = esc_sql($table); 4370 4371 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated by cybersec_tb_bridge_table() (SHOW TABLES) and escaped with esc_sql(). 4372 $wpdb->query("DELETE FROM `$table`"); 4513 // "Unblock all" must not leave rows behind in the table: those rows are 4514 // still enforced, so the button would appear to do nothing for them. 4515 if (cybersec_tb_ready()) { 4516 cybersec_tb_clear(); 4373 4517 } 4374 4518 … … 4381 4525 4382 4526 function cybersec_temp_block_active_until($ip, $blocks = null) { 4527 if ($blocks === null) { 4528 // Hot path: the autoloaded option is free to read, so the table is 4529 // consulted only when the mirror has no live entry. Every write 4530 // updates both stores, and the table lookup below is the safety net 4531 // for a row another process wrote after this request started. 4532 $blocks = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 4533 } 4534 4383 4535 $key = cybersec_temp_block_key_for($ip, $blocks); 4384 4536 4385 if ($key === null) { 4386 // The PRO build records its blocks in a table. If this install has that 4387 // table (the site previously ran PRO), an address blocked there must 4388 // stay blocked here — otherwise switching builds silently lifts every 4389 // temporary block. Check the table even when the option has no key. 4390 $expires = cybersec_tb_bridge_expires($ip); 4391 4392 return $expires > time() ? $expires : 0; 4393 } 4394 4395 if ($blocks === null) { 4396 $blocks = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 4397 } 4398 4399 $expires = cybersec_temp_block_expires($blocks[$key]); 4400 4401 if ($expires <= time()) { 4402 // Same fallback as above, for a stale option entry whose table row is 4403 // still live. 4404 $expires = cybersec_tb_bridge_expires($key); 4405 } 4406 4407 return $expires > time() ? $expires : 0; 4537 if ($key !== null) { 4538 $expires = cybersec_temp_block_expires($blocks[$key]); 4539 4540 if ($expires > time()) { 4541 return $expires; 4542 } 4543 } 4544 4545 // The merged list may be stale within one request, and a block can exist 4546 // only in the table (a concurrent writer, or a site that just switched 4547 // builds). Ask the table directly before declaring the visitor unblocked. 4548 $row = cybersec_tb_get($ip); 4549 4550 if ($row !== null && cybersec_temp_block_expires($row) > time()) { 4551 return cybersec_temp_block_expires($row); 4552 } 4553 4554 return 0; 4408 4555 } 4409 4556 … … 4464 4611 } 4465 4612 4613 $expires = time() + $seconds; 4614 4615 // Resolve immediately: the request is already being blocked, and a queued 4616 // lookup would store "—" forever (the entry is not rewritten). 4617 $country = cybersec_country_for_log(strtok($target, '/')); 4618 4619 // The table goes first: one row per address means a concurrent writer 4620 // cannot overwrite this block, which was the whole point of the move. 4621 $stored_table = cybersec_tb_write($target, $expires, '', $country); 4622 4623 // Mirror into the option for rollback and for hosts where the table could 4624 // not be created. Best-effort: the table row already enforces the block. 4625 // The expiry never moves backwards, so a short automatic block cannot 4626 // shorten a longer ban set by hand. 4466 4627 $locked = cybersec_temp_blocks_lock(); 4467 4628 … … 4471 4632 } 4472 4633 4634 $existing = isset($tb[$target]) ? cybersec_temp_block_expires($tb[$target]) : 0; 4635 4473 4636 $tb[$target] = array( 4474 'expires' => time() + $seconds, 4475 // Resolve immediately: the request is already being blocked, and a 4476 // queued lookup would store "—" forever (the entry is not rewritten). 4477 'country' => cybersec_country_for_log(strtok($target, '/')), 4637 'expires' => max($expires, $existing), 4638 'country' => $country, 4478 4639 ); 4479 4640 4480 4641 $ok = update_option(CYBERSEC_TEMP_BLOCKS_OPTION, $tb); 4481 4482 // Keep the PRO table in step: a block created here must still be4483 // enforced if the site switches builds.4484 cybersec_tb_bridge_put($target, $tb[$target]['expires']);4485 4642 4486 4643 if ($locked) { … … 4490 4647 wp_cache_delete('alloptions', 'options'); 4491 4648 4492 if ($ ok) {4649 if ($stored_table || $ok) { 4493 4650 cybersec_mark_edge_dirty(); 4494 4651 } 4495 4652 4496 return (bool) $ok;4653 return (bool) ($stored_table || $ok); 4497 4654 } 4498 4655 … … 4523 4680 } 4524 4681 4525 // Removal must clear both stores. The previous early return meant a block4526 // that existed only in the PRO table could never be lifted from this UI,4527 // even though it was still being enforced.4528 $removed_ bridge = cybersec_tb_bridge_delete($target);4682 // Removal must clear both stores. A block that exists only in the table 4683 // (another process wrote it, or the site just switched builds) must still 4684 // be liftable from this UI. 4685 $removed_table = cybersec_tb_delete($target); 4529 4686 4530 4687 if ($locked) { … … 4534 4691 wp_cache_delete('alloptions', 'options'); 4535 4692 4536 if ($removed_option || $removed_ bridge) {4693 if ($removed_option || $removed_table) { 4537 4694 cybersec_mark_edge_dirty(); 4538 4695 } 4539 4696 4540 return $removed_option || $removed_ bridge;4697 return $removed_option || $removed_table; 4541 4698 } 4542 4699 … … 4588 4745 cybersec_prune_expired_permanent_blocks(); 4589 4746 4590 $tb = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 4591 if (!empty($tb) && is_array($tb)) { 4592 $n = time(); 4593 $changed = false; 4594 4595 // No "first 100 entries" cap: with the cap, expired blocks that were 4596 // never reached stayed in the option forever (and were paid for on 4597 // every alloptions read). The array is written by blocks only, so 4598 // walking all of it costs nothing. 4599 foreach ($tb as $ip => $entry) { 4600 if ($n >= cybersec_temp_block_expires($entry)) { 4601 unset($tb[$ip]); 4602 $changed = true; 4747 // Table rows are the authoritative copy; a single indexed DELETE handles 4748 // every expired row at once. 4749 if (cybersec_tb_ready()) { 4750 cybersec_tb_purge_expired(); 4751 } 4752 4753 // Take the write mutex BEFORE reading. The old code scanned the list 4754 // first and locked only the update_option() call, so a block written 4755 // between the scan and the write was silently overwritten by the stale 4756 // snapshot — the exact race the mutex exists to prevent. If the mutex is 4757 // busy, skip pruning this round; it runs again in five minutes. 4758 if (cybersec_temp_blocks_lock()) { 4759 $tb = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 4760 4761 if (!empty($tb) && is_array($tb)) { 4762 $n = time(); 4763 $changed = false; 4764 4765 // No "first 100 entries" cap: with the cap, expired blocks that were 4766 // never reached stayed in the option forever (and were paid for on 4767 // every alloptions read). The array is written by blocks only, so 4768 // walking all of it costs nothing. 4769 foreach ($tb as $ip => $entry) { 4770 if ($n >= cybersec_temp_block_expires($entry)) { 4771 unset($tb[$ip]); 4772 $changed = true; 4773 } 4603 4774 } 4604 } 4605 4606 if ($changed) { 4607 $locked = cybersec_temp_blocks_lock(); 4608 update_option(CYBERSEC_TEMP_BLOCKS_OPTION, $tb); 4609 if ($locked) { 4610 cybersec_temp_blocks_unlock(); 4775 4776 if ($changed) { 4777 update_option(CYBERSEC_TEMP_BLOCKS_OPTION, $tb); 4778 cybersec_mark_edge_dirty(); 4611 4779 } 4612 cybersec_mark_edge_dirty(); 4613 } 4780 } 4781 4782 cybersec_temp_blocks_unlock(); 4614 4783 } 4615 4784 … … 5181 5350 5182 5351 /** 5183 * Rebuilds the User-Agent statistics table from the daily logs.5184 *5185 * The table used to be destroyed by the dashboard (see cybersec_get_ua_stats()),5186 * and the logs are the only place where the history survived: every blocked and5187 * allowed line carries the full User-Agent. This walks the last N days, merges5188 * what it finds with whatever is still stored, and never lowers an existing5189 * counter — a rebuild must be safe to run twice.5190 *5191 * @param int $days How many daily files to read.5192 * @param bool $dry_run Only measure, do not write.5193 * @return array{rows:int, scanned:int, added:int, updated:int, days:int, files:int}5194 */5195 function cybersec_rebuild_ua_stats($days = 30, $dry_run = false) {5196 $days = max(1, (int) $days);5197 $stats = cybersec_get_ua_stats();5198 $out = array('rows' => 0, 'scanned' => 0, 'added' => 0, 'updated' => 0, 'days' => $days, 'files' => 0);5199 5200 for ($i = 0; $i < $days; $i++) {5201 $date = current_time('Y-m-d', time() - $i * DAY_IN_SECONDS);5202 5203 foreach (array('blocked-bots', 'allowed-bots') as $kind) {5204 $file = CYBERSEC_LOG_DIR . $kind . '-' . $date . '.log';5205 5206 if (!cybersec_fs_exists($file)) {5207 continue;5208 }5209 5210 $out['files']++;5211 5212 $content = cybersec_fs_get_contents($file);5213 if (!is_string($content) || $content === '') {5214 continue;5215 }5216 5217 $is_blocked = ($kind === 'blocked-bots');5218 5219 foreach (preg_split('/\r\n|\r|\n/', $content) as $line) {5220 if ($line === '') {5221 continue;5222 }5223 5224 $out['scanned']++;5225 5226 // "[2026-11-02 00:15:00] BLOCKED | … | UA: <ua> | URL: …"5227 if (!preg_match('/^\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2})\].*?\|\s*UA:\s*(.*?)\s*\|\s*URL:/', $line, $m)) {5228 continue;5229 }5230 5231 $ua = trim($m[2]);5232 if ($ua === '') {5233 continue;5234 }5235 5236 $ts = strtotime($m[1]);5237 $hash = md5($ua);5238 5239 if (!isset($stats[$hash])) {5240 $stats[$hash] = array(5241 'ua' => substr($ua, 0, 180),5242 'first_seen' => $ts ?: time(),5243 'last_seen' => $ts ?: time(),5244 'blocked' => 0,5245 'allowed' => 0,5246 'count' => 0,5247 );5248 $out['added']++;5249 } else {5250 if (($ts ?: 0) > (int) ($stats[$hash]['last_seen'] ?? 0)) {5251 $stats[$hash]['last_seen'] = $ts;5252 }5253 if (($ts ?: PHP_INT_MAX) < (int) ($stats[$hash]['first_seen'] ?? PHP_INT_MAX)) {5254 $stats[$hash]['first_seen'] = $ts;5255 }5256 if (!isset($stats[$hash]['ua']) || $stats[$hash]['ua'] === '') {5257 $stats[$hash]['ua'] = substr($ua, 0, 180);5258 }5259 $out['updated']++;5260 }5261 5262 $stats[$hash]['count']++;5263 if ($is_blocked) {5264 $stats[$hash]['blocked']++;5265 } else {5266 $stats[$hash]['allowed']++;5267 }5268 }5269 }5270 }5271 5272 $out['rows'] = count($stats);5273 5274 if (count($stats) > CYBERSEC_UA_STATS_MAX) {5275 uasort($stats, function ($a, $b) {5276 return ($b['count'] ?? 0) - ($a['count'] ?? 0);5277 });5278 $stats = array_slice($stats, 0, CYBERSEC_UA_STATS_MAX, true);5279 $out['rows'] = count($stats);5280 }5281 5282 if (!$dry_run) {5283 update_option(CYBERSEC_UA_STATS_OPTION, $stats, false);5284 unset($GLOBALS['cybersec_ua_stats_cache']);5285 wp_cache_delete('cybersec_ua_stats', 'cybersec_ua');5286 }5287 5288 return $out;5289 }5290 5291 /**5292 * Summarises the permanent ban list for the operator.5293 *5294 * One installation reached 7592 entries, and the dashboard called all of them5295 * "/24 subnets". Before deleting anything, an operator needs to see what is5296 * actually stored: how many host bans, how many ranges, how old they are and5297 * why they were created.5298 *5299 * @return array{total:int, host:int, subnet:int, expired:int, by_reason:array, by_age:array}5300 */5301 function cybersec_permaban_report_data() {5302 $list = cybersec_get_permanent_blocked_subnets();5303 $stats = cybersec_permaban_stats();5304 $now = time();5305 $reason = array();5306 $age = array('today' => 0, 'week' => 0, 'month' => 0, 'older' => 0, 'unknown' => 0);5307 5308 foreach ((array) $list as $cidr => $data) {5309 $expires = cybersec_permaban_expires($data);5310 5311 if ($expires > 0 && $expires < $now) {5312 continue; // expired: reported separately5313 }5314 5315 $label = trim((string) ($data['reason'] ?? ''));5316 if ($label === '') {5317 $label = 'no reason';5318 }5319 // Group on the leading word group so "Hidden file scanner: /.env" and5320 // "Hidden file scanner: /.git/config" share a row.5321 if (strpos($label, ':') !== false) {5322 $label = trim(substr($label, 0, strpos($label, ':')));5323 }5324 $label = preg_replace('/\s*\([^)]*\)\s*$/', '', $label);5325 if ($label === '') {5326 $label = 'no reason';5327 }5328 5329 if (!isset($reason[$label])) {5330 $reason[$label] = 0;5331 }5332 $reason[$label]++;5333 5334 $blocked_at = !empty($data['blocked_at']) ? (int) strtotime((string) $data['blocked_at']) : 0;5335 if ($blocked_at <= 0) {5336 $age['unknown']++;5337 continue;5338 }5339 5340 $days_old = (int) floor(($now - $blocked_at) / DAY_IN_SECONDS);5341 if ($days_old < 1) {5342 $age['today']++;5343 } elseif ($days_old < 7) {5344 $age['week']++;5345 } elseif ($days_old < 30) {5346 $age['month']++;5347 } else {5348 $age['older']++;5349 }5350 }5351 5352 arsort($reason);5353 5354 return array(5355 'total' => $stats['total'],5356 'host' => $stats['host'],5357 'subnet' => $stats['subnet'],5358 'expired' => $stats['expired'],5359 'by_reason' => $reason,5360 'by_age' => $age,5361 );5362 }5363 5364 5365 /**5366 5352 * Records a violation for $ip in the permaban ladder and reports the state. 5367 5353 * … … 6524 6510 6525 6511 /** 6526 * Detects Cloudflare origin leaks — CF markers present without CF-Connecting-IP.6527 *6528 * @return bool6529 */6530 function cybersec_check_cloudflare_origin(){ if(!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) return false; $hs=false; foreach(array('HTTP_CF_IPCOUNTRY','HTTP_CF_RAY','HTTP_CF_VISITOR') as $h){if(!empty($_SERVER[$h])){$hs=true;break;}} return $hs && empty($_SERVER['HTTP_CF_CONNECTING_IP']); }6531 6532 /**6533 * Returns true if the given IP is an IPv6 address.6534 *6535 * @param string $ip6536 * @return bool6537 */6538 function cybersec_check_ipv6_connection($ip){ return filter_var($ip,FILTER_VALIDATE_IP,FILTER_FLAG_IPV6)!==false; }6539 6540 /**6541 6512 * Returns true if the request is using HTTP/1.0 or HTTP/1. 6542 6513 * … … 6602 6573 */ 6603 6574 function cybersec_check_direct_ip_access(){ $h=isset($_SERVER['HTTP_HOST'])?sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])):''; if(filter_var($h,FILTER_VALIDATE_IP)){$sh=wp_parse_url(get_site_url(),PHP_URL_HOST); if($h!==$sh) return true;} return false; } 6604 6605 /**6606 * Alias of cybersec_check_fake_referer for the legacy "spam referer" option.6607 *6608 * @deprecated The option was a duplicate of "fake_referer" — the spam-domain6609 * list has always lived inside cybersec_check_fake_referer(). Kept6610 * so external code that calls it still works.6611 *6612 * @return bool6613 */6614 function cybersec_check_bad_referer_spam(){ return cybersec_check_fake_referer(); }6615 6575 6616 6576 /** … … 8711 8671 // A temp block set within the 5-second qcheck TTL would 8712 8672 // otherwise be bypassed. Checked here, once, before the cache. 8713 // Entries may be plain addresses or CIDR subnets (admin UI), so the 8714 // lookup goes through cybersec_temp_block_key_for(). 8673 // Entries may be plain addresses or CIDR subnets (admin UI), and the 8674 // block may live in the option mirror or in the table, so the lookup 8675 // goes through cybersec_temp_block_active_until(). 8715 8676 // ============================================================ 8716 $tb_early = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 8717 $tb_key = cybersec_temp_block_key_for($ip_early, $tb_early); 8718 8719 if ($tb_key !== null) { 8720 if (time() < cybersec_temp_block_expires($tb_early[$tb_key])) { 8721 cybersec_show_block_page($ip_early, cybersec_translate("Temporary block.")); 8722 exit; 8723 } 8724 8725 // Expired: drop it under the lock, so a parallel request that just 8726 // re-blocked this address does not lose its fresh entry. 8727 cybersec_remove_temp_block($tb_key); 8677 if (cybersec_temp_block_active_until($ip_early) > 0) { 8678 cybersec_show_block_page($ip_early, cybersec_translate("Temporary block.")); 8679 exit; 8728 8680 } 8729 8681 // ============================================================ … … 9574 9526 } 9575 9527 9576 $tb = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 9577 if (cybersec_temp_block_active_until($ip, $tb) > 0) { 9528 if (cybersec_temp_block_active_until($ip) > 0) { 9578 9529 cybersec_show_block_page($ip, cybersec_translate('Temporary block.')); 9579 9530 exit; … … 9696 9647 $reason = cybersec_translate('IP in permanent block list'); 9697 9648 } else { 9698 $tb = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 9699 9700 if (cybersec_temp_block_active_until($ip, $tb) > 0) { 9649 if (cybersec_temp_block_active_until($ip) > 0) { 9701 9650 $reason = cybersec_translate('Temporary block.'); 9702 9651 } … … 10455 10404 10456 10405 // ==================== BLOCK 10: 403 PAGE (FIXED) ==================== 10457 10458 /**10459 * Generates a signed unblock token for an IP. Expires after the auto-unblock10460 * window plus 10 minutes of grace. HMAC-signed with the WP nonce salt.10461 *10462 * @param string $ip10463 * @return string Base64-encoded token.10464 */10465 function cybersec_generate_unblock_token($ip) {10466 $secret = wp_salt('nonce');10467 // A token stays valid for five minutes past the auto-unblock window — the10468 // same grace the reference build uses. A longer window only widens the10469 // period in which a leaked link still works.10470 $expires = time() + (cybersec_get_auto_unblock_minutes() * 60) + 300;10471 $data = $ip . '|' . $expires;10472 $hash = hash_hmac('sha256', $data, $secret);10473 return base64_encode($data . '|' . $hash);10474 }10475 10406 10476 10407 /** … … 12394 12325 }); 12395 12326 12327 12328 -
cyberpulse/trunk/cyberpulse-htaccess.php
r3784916 r3790231 319 319 // added after the permabans, up to a combined cap that keeps .htaccess 320 320 // small. 321 $tb = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array()); 321 // Merged view: the table is authoritative, the option is the rollback 322 // mirror. The edge list must contain both or a blocked scanner keeps 323 // reaching PHP after a build switch. 324 $tb = cybersec_temp_blocks_all(); 322 325 if (!empty($tb) && is_array($tb)) { 323 326 uasort($tb, function($a, $b) { -
cyberpulse/trunk/cyberpulse.php
r3784916 r3790231 4 4 * Plugin URI: https://wordpress.org/plugins/cyberpulse 5 5 * Description: Real-time security heartbeat for your WordPress. Monitors every request, blocks bots instantly, protects against brute force and AI scraping. Feel the pulse of protection. Free version with PRO upgrade available. 6 * Version: 5.9.1. 66 * Version: 5.9.1.7 7 7 * Author: CyberPulse Security 8 8 * Author URI: https://cyberpulse-security.com … … 13 13 * Requires at least: 5.3 14 14 * Requires PHP: 7.4 15 * Tested up to: 7.116 15 */ 17 16 if (!defined('ABSPATH')) exit; 17 18 /** 19 * If CyberPulse PRO is active, it owns the request. This prevents both 20 * versions from declaring the same functions/classes and makes it safe to 21 * have both plugins installed. 22 */ 23 if (defined('CYBERSEC_IS_PRO_VERSION') && CYBERSEC_IS_PRO_VERSION) { 24 return; 25 } 18 26 19 27 // ------------------------------------------------------------------ … … 240 248 } 241 249 242 /**243 * Checks whether a path is writable via WP_Filesystem.244 *245 * @param string $path246 * @return bool247 */248 function cybersec_fs_is_writable($path) {249 $fs = cybersec_fs_init();250 return $fs->is_writable($path);251 }252 253 /**254 * Creates a directory through WP_Filesystem.255 *256 * @param string $path257 * @return bool258 */259 function cybersec_fs_mkdir($path) {260 $fs = cybersec_fs_init();261 return $fs->mkdir($path);262 }263 264 250 // ------------------------------------------------------------------ 265 251 // Internationalization … … 417 403 add_option($option_name, array(), '', 'no'); 418 404 } 405 } 406 } 407 408 // Create the temporary-block table and carry over the blocks that older 409 // builds kept in a single option row. Activation is the expected moment 410 // for dbDelta(); the same upgrade also runs once from admin_init so that 411 // existing installs migrate on their first visit after the update. 412 if (function_exists('cybersec_tb_install')) { 413 if (cybersec_tb_install()) { 414 cybersec_tb_migrate_option(true); 415 update_option('cybersec_tb_schema', CYBERSEC_TB_SCHEMA); 419 416 } 420 417 } … … 502 499 return $links; 503 500 } 501 -
cyberpulse/trunk/readme.txt
r3784916 r3790231 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 5.9.1. 67 Stable tag: 5.9.1.7 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 112 112 6. ⚙️ Security settings with multiple bot detection options 113 113 114 = 5.9.1.7 = 115 * 🗄️ **Temporary blocks now live in a dedicated database table** instead of one option row. Every block is its own row, so two simultaneous blocks can no longer overwrite each other — previously the loser's block simply disappeared. The upgrade copies the existing blocks into the table on the first admin visit; the option keeps being written as a mirror, so a rollback to an older build still finds every block, and if the table cannot be created the plugin falls back to the option and behaves exactly as before. 116 * ⚡ **Temporary blocks:** the expired-block cleanup now takes the write mutex before reading the list. Previously it scanned first and locked only the write, so a block created in between was overwritten by the stale snapshot. 117 * 🧹 **Cleanup:** removed eleven unused helper functions left over from earlier releases (old diagnostics, the pre-proof-of-work unblock-token generator, an unused UA-stats rebuilder). No behaviour change. 118 114 119 = 5.9.1.6 = 115 120 * ⚡ Faster sync: new or lifted temporary blocks mark the edge list as stale and schedule a sync within 30–60 seconds instead of waiting for the five-minute maintenance run. -
cyberpulse/trunk/uninstall.php
r3782890 r3790231 17 17 */ 18 18 if (!defined('WP_UNINSTALL_PLUGIN')) exit; 19 20 /** 21 * If CyberPulse PRO is present, it owns every shared store: the same options 22 * (settings, whitelist, bans, license key), the same log directory, the same 23 * temporary-block table and the same .htaccess block. PRO deactivates FREE on 24 * activation, so the free plugin is usually still installed but inactive — and 25 * deleting it must not wipe the paid plugin's license, settings, logs, bans or 26 * firewall rules. 27 * 28 * The check uses the plugin file on disk, not the "active" state: a customer 29 * who deactivated PRO for a moment still expects their data to survive. 30 */ 31 $cybersec_pro_present = false; 32 $cybersec_pro_paths = array(); 33 34 if (defined('WP_PLUGIN_DIR')) { 35 $cybersec_pro_paths[] = WP_PLUGIN_DIR . '/cyberpulse-pro/cyberpulse.php'; 36 } 37 38 if (defined('WPMU_PLUGIN_DIR')) { 39 $cybersec_pro_paths[] = WPMU_PLUGIN_DIR . '/cyberpulse-pro/cyberpulse.php'; 40 } 41 42 foreach ($cybersec_pro_paths as $cybersec_pro_path) { 43 if (file_exists($cybersec_pro_path)) { 44 $cybersec_pro_present = true; 45 break; 46 } 47 } 48 49 if ($cybersec_pro_present) { 50 return; 51 } 19 52 20 53 // ==================== OPTIONS ==================== … … 60 93 delete_option('cybersec_log_dir_lock'); 61 94 delete_option('cybersec_tb_bridge_present'); 95 delete_option('cybersec_tb_present'); 96 delete_option('cybersec_tb_schema'); 97 delete_option('cybersec_tb_schema_failed'); 98 delete_option('cybersec_tb_migrated_v2'); 62 99 delete_option('cybersec_403_build'); 63 100
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)