Plugin Directory

Changeset 3790231


Ignore:
Timestamp:
11/07/2026 10:02:29 PM (23 hours ago)
Author:
gataurus
Message:

release 5.9.1.7: move temporary blocks to a dedicated DB table, fix cleanup race, drop unused helpers

Location:
cyberpulse/trunk
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • cyberpulse/trunk/cyberpulse-core.php

    r3784916 r3790231  
    316316define('CYBERSEC_PERMABAN_LADDER_OPTION', 'cybersec_permaban_ladder');
    317317define('CYBERSEC_PERMABAN_MIGRATION_OPTION', 'cybersec_permaban_migrated');
    318 define('CYBERSEC_VERSION', '5.9.1.6');
     318define('CYBERSEC_VERSION', '5.9.1.7');
     319
     320/** Schema revision of the temporary-block table. Bump to re-run dbDelta(). */
     321define('CYBERSEC_TB_SCHEMA', '2');
    319322
    320323/**
    … …  
    20842087
    20852088/**
    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 by
    2089  * support work, so that "why is this visitor blocked?" has one answer instead
    2090  * of a hand-written sequence of get_option() calls.
    2091  *
    2092  * @param string $ip
    2093  * @return array
    2094  */
    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 /**
    21472089 * Lifts blocks, by scope.
    21482090 *
    … …  
    22392181
    22402182    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" and
    2285             // "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 array
    2314  */
    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     );
    23532183}
    23542184
    … …  
    41293959
    41303960    if ($blocks === null) {
    4131         $blocks = get_option(CYBERSEC_TEMP_BLOCKS_OPTION, array());
     3961        $blocks = cybersec_temp_blocks_all();
    41323962    }
    41333963
    … …  
    41533983}
    41543984
    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 */
     4010function cybersec_tb_table() {
    41804011    global $wpdb;
    41814012
     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 */
     4024function 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
    41824054    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 */
     4075function cybersec_tb_install() {
    42224076    global $wpdb;
    42234077
    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 */
     4123function 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,
    42284144        time()
    42294145    ));
    42304146
    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
    42404174 * @return void
    42414175 */
    4242 function cybersec_tb_bridge_put($target, $expires) {
    4243     $table = cybersec_tb_bridge_table();
    4244 
    4245     if ($table === '' || strpos((string) $target, '/') !== false) {
     4176function 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()) {
    42464180        return;
    42474181    }
    42484182
    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 */
     4207function &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.
    42954217 * @return array<string,array{expires:int,country:string,reason:string}>
    42964218 */
    4297 function cybersec_tb_bridge_rows() {
    4298     static $rows = null;
     4219function cybersec_tb_rows($refresh = false) {
     4220    $rows = &cybersec_tb_rows_store();
     4221
     4222    if ($refresh) {
     4223        $rows = null;
     4224    }
    42994225
    43004226    if ($rows !== null) {
    … …  
    43024228    }
    43034229
    4304     $table = cybersec_tb_bridge_table();
    4305     if ($table === '') {
    4306         $rows = array();
     4230    $rows = array();
     4231
     4232    if (!cybersec_tb_ready()) {
    43074233        return $rows;
    43084234    }
    43094235
    4310     $table = esc_sql($table);
     4236    $table = esc_sql(cybersec_tb_table());
    43114237
    43124238    global $wpdb;
    43134239
    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.
    43154241    $found = $wpdb->get_results($wpdb->prepare(
    43164242        "SELECT ip, expires, country, reason FROM `$table` WHERE expires > %d",
    … …  
    43184244    ), ARRAY_A);
    43194245
    4320     $rows = array();
    43214246    if (is_array($found)) {
    43224247        foreach ($found as $row) {
    … …  
    43334258
    43344259/**
     4260 * Returns the block covering $ip (exact or CIDR), or null.
     4261 *
     4262 * @param string $ip
     4263 * @return array|null
     4264 */
     4265function 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 */
     4295function 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 */
     4317function 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 */
     4339function 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 */
     4367function 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 */
     4418function 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}
     4460add_action('admin_init', 'cybersec_tb_maybe_upgrade');
     4461
     4462
     4463/**
    43354464 * Every temporary block this build should show, from both stores.
    43364465 *
    … …  
    43484477    }
    43494478
    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;
    43514500}
    43524501
    … …  
    43624511    update_option(CYBERSEC_TEMP_BLOCKS_OPTION, array());
    43634512
    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();
    43734517    }
    43744518
    … …  
    43814525
    43824526function 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
    43834535    $key = cybersec_temp_block_key_for($ip, $blocks);
    43844536
    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;
    44084555}
    44094556
    … …  
    44644611    }
    44654612
     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.
    44664627    $locked = cybersec_temp_blocks_lock();
    44674628
    … …  
    44714632    }
    44724633
     4634    $existing = isset($tb[$target]) ? cybersec_temp_block_expires($tb[$target]) : 0;
     4635
    44734636    $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,
    44784639    );
    44794640
    44804641    $ok = update_option(CYBERSEC_TEMP_BLOCKS_OPTION, $tb);
    4481 
    4482     // Keep the PRO table in step: a block created here must still be
    4483     // enforced if the site switches builds.
    4484     cybersec_tb_bridge_put($target, $tb[$target]['expires']);
    44854642
    44864643    if ($locked) {
    … …  
    44904647    wp_cache_delete('alloptions', 'options');
    44914648
    4492     if ($ok) {
     4649    if ($stored_table || $ok) {
    44934650        cybersec_mark_edge_dirty();
    44944651    }
    44954652
    4496     return (bool) $ok;
     4653    return (bool) ($stored_table || $ok);
    44974654}
    44984655
    … …  
    45234680    }
    45244681
    4525     // Removal must clear both stores. The previous early return meant a block
    4526     // 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);
    45294686
    45304687    if ($locked) {
    … …  
    45344691    wp_cache_delete('alloptions', 'options');
    45354692
    4536     if ($removed_option || $removed_bridge) {
     4693    if ($removed_option || $removed_table) {
    45374694        cybersec_mark_edge_dirty();
    45384695    }
    45394696
    4540     return $removed_option || $removed_bridge;
     4697    return $removed_option || $removed_table;
    45414698}
    45424699
    … …  
    45884745    cybersec_prune_expired_permanent_blocks();
    45894746
    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                }
    46034774            }
    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();
    46114779            }
    4612             cybersec_mark_edge_dirty();
    4613         }
     4780        }
     4781
     4782        cybersec_temp_blocks_unlock();
    46144783    }
    46154784
    … …  
    51815350
    51825351/**
    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 and
    5187  * allowed line carries the full User-Agent. This walks the last N days, merges
    5188  * what it finds with whatever is still stored, and never lowers an existing
    5189  * 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 them
    5295  * "/24 subnets". Before deleting anything, an operator needs to see what is
    5296  * actually stored: how many host bans, how many ranges, how old they are and
    5297  * 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 separately
    5313         }
    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" and
    5320         // "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 /**
    53665352 * Records a violation for $ip in the permaban ladder and reports the state.
    53675353 *
    … …  
    65246510
    65256511/**
    6526  * Detects Cloudflare origin leaks — CF markers present without CF-Connecting-IP.
    6527  *
    6528  * @return bool
    6529  */
    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 $ip
    6536  * @return bool
    6537  */
    6538 function cybersec_check_ipv6_connection($ip){ return filter_var($ip,FILTER_VALIDATE_IP,FILTER_FLAG_IPV6)!==false; }
    6539 
    6540 /**
    65416512 * Returns true if the request is using HTTP/1.0 or HTTP/1.
    65426513 *
    … …  
    66026573 */
    66036574function 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-domain
    6609  *             list has always lived inside cybersec_check_fake_referer(). Kept
    6610  *             so external code that calls it still works.
    6611  *
    6612  * @return bool
    6613  */
    6614 function cybersec_check_bad_referer_spam(){ return cybersec_check_fake_referer(); }
    66156575
    66166576/**
    … …  
    87118671    // A temp block set within the 5-second qcheck TTL would
    87128672    // 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().
    87158676    // ============================================================
    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;
    87288680    }
    87298681    // ============================================================
    … …  
    95749526    }
    95759527
    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) {
    95789529        cybersec_show_block_page($ip, cybersec_translate('Temporary block.'));
    95799530        exit;
    … …  
    96969647        $reason = cybersec_translate('IP in permanent block list');
    96979648    } 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) {
    97019650            $reason = cybersec_translate('Temporary block.');
    97029651        }
    … …  
    1045510404
    1045610405// ==================== BLOCK 10: 403 PAGE (FIXED) ====================
    10457 
    10458 /**
    10459  * Generates a signed unblock token for an IP. Expires after the auto-unblock
    10460  * window plus 10 minutes of grace. HMAC-signed with the WP nonce salt.
    10461  *
    10462  * @param string $ip
    10463  * @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 — the
    10468     // same grace the reference build uses. A longer window only widens the
    10469     // 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 }
    1047510406
    1047610407/**
    … …  
    1239412325});
    1239512326
     12327
     12328
  • cyberpulse/trunk/cyberpulse-htaccess.php

    r3784916 r3790231  
    319319    // added after the permabans, up to a combined cap that keeps .htaccess
    320320    // 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();
    322325    if (!empty($tb) && is_array($tb)) {
    323326        uasort($tb, function($a, $b) {
  • cyberpulse/trunk/cyberpulse.php

    r3784916 r3790231  
    44 * Plugin URI: https://wordpress.org/plugins/cyberpulse
    55 * 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.6
     6 * Version: 5.9.1.7
    77 * Author: CyberPulse Security
    88 * Author URI: https://cyberpulse-security.com
    … …  
    1313 * Requires at least: 5.3
    1414 * Requires PHP: 7.4
    15  * Tested up to: 7.1
    1615 */
    1716if (!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 */
     23if (defined('CYBERSEC_IS_PRO_VERSION') && CYBERSEC_IS_PRO_VERSION) {
     24    return;
     25}
    1826
    1927// ------------------------------------------------------------------
    … …  
    240248}
    241249
    242 /**
    243  * Checks whether a path is writable via WP_Filesystem.
    244  *
    245  * @param string $path
    246  * @return bool
    247  */
    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 $path
    257  * @return bool
    258  */
    259 function cybersec_fs_mkdir($path) {
    260     $fs = cybersec_fs_init();
    261     return $fs->mkdir($path);
    262 }
    263 
    264250// ------------------------------------------------------------------
    265251// Internationalization
    … …  
    417403                add_option($option_name, array(), '', 'no');
    418404            }
     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);
    419416        }
    420417    }
    … …  
    502499    return $links;
    503500}
     501
  • cyberpulse/trunk/readme.txt

    r3784916 r3790231  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 5.9.1.6
     7Stable tag: 5.9.1.7
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1121126. ⚙️ Security settings with multiple bot detection options
    113113
     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
    114119= 5.9.1.6 =
    115120* ⚡ 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  
    1717 */
    1818if (!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
     34if (defined('WP_PLUGIN_DIR')) {
     35    $cybersec_pro_paths[] = WP_PLUGIN_DIR . '/cyberpulse-pro/cyberpulse.php';
     36}
     37
     38if (defined('WPMU_PLUGIN_DIR')) {
     39    $cybersec_pro_paths[] = WPMU_PLUGIN_DIR . '/cyberpulse-pro/cyberpulse.php';
     40}
     41
     42foreach ($cybersec_pro_paths as $cybersec_pro_path) {
     43    if (file_exists($cybersec_pro_path)) {
     44        $cybersec_pro_present = true;
     45        break;
     46    }
     47}
     48
     49if ($cybersec_pro_present) {
     50    return;
     51}
    1952
    2053// ==================== OPTIONS ====================
    … …  
    6093delete_option('cybersec_log_dir_lock');
    6194delete_option('cybersec_tb_bridge_present');
     95delete_option('cybersec_tb_present');
     96delete_option('cybersec_tb_schema');
     97delete_option('cybersec_tb_schema_failed');
     98delete_option('cybersec_tb_migrated_v2');
    6299delete_option('cybersec_403_build');
    63100
Note: See TracChangeset for help on using the changeset viewer.