'failed logins', 'xmlrpc' => 'failed XML-RPC logins', 'probe' => 'hidden-login probes', 'api' => 'failed test-session API calls', 'firewall' => 'firewall rule matches', 'fw_404' => 'missing-page probes', 'ratelimit' => 'request rate-limit breaches', 'comment_attack' => 'comments flagged as attacks', ); /** * Comments flagged as attacks allowed in the window before an address is blocked. * * One. A bot trap filled, a comment posted faster than a person types, a burst past the * comment rate cap, or comment text carrying an attack payload: the site owner's rule * (2026-10-01) is that a bad comment IS an attack and blocks on the first one. Not clamped * to the interactive threshold, which can only ever be one or more anyway. */ const DEFAULT_COMMENT_ATTACK_THRESHOLD = 1; /** * Matches allowed in the window before an address is blocked. * * Low, and it can afford to be: a genuine visitor trips a firewall rule rarely and by * accident, so ten separate matches inside the window is a scanner working through a * list. Compare the interactive default of 30 failed logins, which has to tolerate a * person mistyping a password. */ const DEFAULT_FIREWALL_THRESHOLD = 10; /** * 404s allowed in the window before an address is blocked. * * Much more generous, because a 404 has innocent causes that a firewall match does not: * a stale bookmark, a broken inbound link, an image a theme asks for and does not ship. * A directory scanner produces hundreds. */ const DEFAULT_404_THRESHOLD = 40; /** How the admin wants block alerts bundled. */ const OPTION_ALERT_BULK = 'csdt_autoblock_alert_bulk'; /** Blocks waiting to be reported. */ const OPTION_ALERT_PENDING = 'csdt_autoblock_alert_pending'; /** * Default bundling: one alert per ten blocks. * * Chosen because per-block alerting is genuinely unusable. A single afternoon of * ordinary background scanning produced a stream of near-identical WARNING messages, * each about a different address doing exactly the same thing, and an alert channel * nobody can read is an alert channel nobody reads. */ const DEFAULT_ALERT_BULK = '10'; /** * Bundling choices. * * Counts and intervals both, because they answer different questions. A count keeps the * alert proportional to how much is happening; an interval keeps it predictable. */ const ALERT_BULK_CHOICES = array( 'each', '10', '50', '100', 'hourly', 'daily' ); /** * Most addresses listed individually in a digest. * * The rest are counted. A digest is a summary, and an option row holding ten thousand * addresses during a distributed attack is the same amplification mistake as logging * every match without dedupe. * * 20 by request, on the reasoning that a phone notification stops being readable well * before then and the remainder is one tap away on the Login Security tab. Note that this * also bounds what is STORED, not just what is shown: queue_alert() stops appending past * this many and keeps counting, so a day-long bundle during a distributed attack cannot * grow the pending option without limit. * * @since 1.10.515 Reduced from 25. */ const ALERT_DIGEST_MAX = 20; /** * Renders the Auto-Block panel (Login Security tab). * * @since 1.10.389 */ public static function render_panel(): void { $enabled = CSDT_Options::get( self::OPTION_ENABLED ) === '1'; $threshold = (int) CSDT_Options::get( self::OPTION_THRESHOLD ); $api_thr = (int) get_option( self::OPTION_API_THRESHOLD, self::DEFAULT_API_THRESHOLD ); $window = (int) get_option( self::OPTION_WINDOW, self::DEFAULT_WINDOW ); $allowlist = (string) get_option( self::OPTION_ALLOWLIST, '' ); $log = get_option( self::OPTION_LOG, array() ); $log = is_array( $log ) ? $log : array(); $my_ip = CSDT_DevTools::get_client_ip(); $blind = CSDT_DevTools::ip_detection_is_broken(); ?>
🚫 'What counts as hostile', 'rec' => 'Four signals', 'html' => 'Failed logins and failed XML-RPC logins (counted per credential, not per request, because one system.multicall can carry hundreds of attempts), hidden-login probes (requests to wp-login.php, /wp-login, or wp-admin while logged out and your custom slug is active, none of which a genuine visitor makes), and failed test-session API calls, which get their own higher threshold because that endpoint is driven by CI.', ), array( 'name' => 'Why two different thresholds', 'rec' => 'Info', 'html' => '

Failed passwords are allowed 30 attempts, because a person can plausibly mistype that often. Probes of a login path that has moved are allowed 10, because nobody reaches it by accident.

The probe rule is separate: it keeps running even when the box above is unticked, so blocks can still appear below. Its threshold lives on the Brute Force panel.

', ), array( 'name' => 'Blocks expire and escalate', 'rec' => 'By design', 'html' => 'A first offence blocks for 1 hour, a second for 24 hours, and a third or later for 7 days. Permanent blocks accumulate into an unauditable list and eventually catch a recycled address belonging to a real reader. Expiry is enforced on every request, so a block releases itself even if WP-Cron is not running.', ), array( 'name' => 'What is never blocked', 'rec' => 'Lockout safety', 'html' => 'Private and reserved addresses (an unresolved proxy hop looks exactly like this, and blocking it would blackhole every visitor behind it), recognised proxy and CDN edges, your own IP while you are signed in as an administrator, any IP with a live administrator session, and anything on your allowlist. If your office egresses through a shared corporate proxy, add that address to the allowlist, blocking it would affect colleagues too.', ), array( 'name' => 'If you do lock yourself out', 'rec' => 'Recovery', 'html' => 'Blocks are stored in the csdt_ip_blocklist option. Clear them over SSH with:
wp option delete csdt_ip_blocklist
or disable the feature entirely with:
wp option update csdt_autoblock_enabled 0', ), array( 'name' => 'Requires working IP detection', 'rec' => 'Prerequisite', 'html' => 'Every signal is attributed by IP. If the Visitor IP Source panel above reports a problem, this feature is inert: events carry your proxy\'s address, which is on the never-block list. That is deliberate, but it means auto-blocking does nothing until IP detection is correct.', ), ) ); ?>
0 ) : ?>
0 ) : ?>


' . esc_html( $my_ip ) . '' ); ?>

0 ) { echo esc_html( sprintf( /* translators: %d: wp-login.php probes allowed over 7 days. */ __( 'This controls the threshold blocker only. A second rule blocks any address making %d or more wp-login.php attempts over 7 days, and it keeps running even when this box is unticked, so blocks can still appear below. Its threshold lives on the Brute Force panel.', 'cloudscale-cyber-devtools' ), $probe_7d ) ); } else { esc_html_e( 'The separate wp-login probe rule keeps running even when this is off.', 'cloudscale-cyber-devtools' ); } ?>

0 ) { continue; // A block; it is in the Blocked IPs list. } $notices[] = $row; } ?>
()
0 /* translators: %s: human-readable time difference. */ ? sprintf( __( '%s ago', 'cloudscale-cyber-devtools' ), human_time_diff( $n_at ) ) : __( 'unknown', 'cloudscale-cyber-devtools' ) ); ?>
'Saved.' ) ); } public static function register(): void { // wp_login_failed fires once per credential pair. That matters for // XML-RPC: a single system.multicall can carry hundreds of login // attempts, and counting requests instead of credentials would let an // attacker make thousands of attempts without ever tripping a // request-based threshold. Hooking here counts what actually happened. add_action( 'wp_login_failed', array( __CLASS__, 'on_login_failed' ), 20 ); CSDT_Ajax::register( 'wp_ajax_csdt_save_autoblock', array( __CLASS__, 'ajax_save' ), array( 'refuse_test_session' => true ) ); } /** Records a failed authentication, tagging XML-RPC separately. */ public static function on_login_failed(): void { $signal = ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ? 'xmlrpc' : 'login'; self::record( $signal ); } /** * Records one hostile event for the current client and blocks if warranted. * * @param string $signal One of the SIGNAL_LABELS keys. * @param string $ip Optional explicit IP; defaults to the current client. * @return void */ public static function record( string $signal, string $ip = '' ): void { try { if ( CSDT_Options::get( self::OPTION_ENABLED ) !== '1' ) { return; } if ( ! isset( self::SIGNAL_LABELS[ $signal ] ) ) { return; } if ( '' === $ip ) { $ip = CSDT_DevTools::get_client_ip(); } if ( self::is_exempt( $ip ) ) { return; } $now = time(); $window = max( 60, (int) get_option( self::OPTION_WINDOW, self::DEFAULT_WINDOW ) ); // Counted in a database table with one atomic statement per event since // 1.10.436. This used to read csdt_autoblock_counts, append a timestamp // and write the whole option back, so two simultaneous requests each // overwrote the other and one event was lost. An automated attack is // concurrent by nature, so the tally undercounted exactly as the attack // sped up: a threshold of 30 could take 50 or more real attempts to // trip. See CSDT_Storage. if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::record_event( $ip, $signal, $now ) ) { // Counters unavailable: go inert rather than guess. Blocking on an // unknown tally is how a defence starts refusing real readers, and // this class already takes the same position when it cannot resolve // client IPs. Recorded so the admin panel can say so out loud // instead of appearing to work. update_option( self::OPTION_COUNTERS_BROKEN, $now, false ); return; } if ( get_option( self::OPTION_COUNTERS_BROKEN ) ) { delete_option( self::OPTION_COUNTERS_BROKEN ); } $count = CSDT_Storage::count_events( $ip, $signal, $now - $window ); if ( null === $count ) { update_option( self::OPTION_COUNTERS_BROKEN, $now, false ); return; } $threshold = self::threshold_for( $signal ); if ( $count >= $threshold ) { self::block( $ip, $signal, $count ); } } catch ( \Throwable $e ) { // Never let accounting break a request. CSDT_Monitor::log_exception( $e, __METHOD__ ); } } /** * How many events of this signal are allowed inside the window. * * One function, because three call sites reading three options with three inline * defaults is how a threshold ends up meaning something different in the panel * from what the blocker enforces. * * @param string $signal One of SIGNAL_LABELS. * @return int */ public static function threshold_for( string $signal ): int { $interactive = max( 1, (int) CSDT_Options::get( self::OPTION_THRESHOLD ) ); if ( 'firewall' === $signal ) { // Never looser than the interactive threshold, so tightening that tightens this // too, and never more generous than the default, a request that trips a // firewall rule has no innocent explanation worth ten of. return max( 1, min( self::DEFAULT_FIREWALL_THRESHOLD, $interactive ) ); } if ( 'fw_404' === $signal ) { // Deliberately NOT clamped to the interactive threshold. 404s have innocent // causes that a firewall match does not, so tightening the login threshold to 5 // must not start banning readers who follow three stale links. return max( 1, self::DEFAULT_404_THRESHOLD ); } if ( 'api' === $signal ) { return max( 1, (int) get_option( self::OPTION_API_THRESHOLD, self::DEFAULT_API_THRESHOLD ) ); } if ( 'ratelimit' === $signal ) { // One event per address per class per MINUTE over the limit, so ten is // ten separate minutes of flooding inside the window: a client, not a // person. Clamped like the firewall signal. return max( 1, min( self::DEFAULT_FIREWALL_THRESHOLD, $interactive ) ); } if ( 'comment_attack' === $signal ) { return max( 1, self::DEFAULT_COMMENT_ATTACK_THRESHOLD ); } if ( 'probe' === $signal ) { // Never looser than the interactive threshold, so tightening that tightens // this too; never more generous than DEFAULT_PROBE_THRESHOLD, because a hit // on a login path that has moved has no innocent explanation. return max( 1, min( self::DEFAULT_PROBE_THRESHOLD, $interactive ) ); } return $interactive; } /** * The 7-day probe rule's threshold, in one place. * * @return int 0 when the admin has deliberately turned it off. */ public static function probe_7d_threshold(): int { $stored = get_option( 'csdt_wplogin_auto_block_threshold', null ); if ( null === $stored || '' === $stored ) { return self::DEFAULT_PROBE_7D_THRESHOLD; } return max( 0, (int) $stored ); } /** * One-time: lower a probe threshold left on one of the old generous values. * * The old scale started at 20 and defaulted to Off, so every install was either * undefended or defended at a number chosen from a menu that did not offer a * sensible one. This lowers 20/50/100/200 to 10 exactly once, and records it in the * auto-block log so a changed setting is visible rather than mysterious. * * What it deliberately does NOT touch: * - 0. That is the admin choosing Off, and a security feature that re-enables * itself is not a security feature. * - any other value, including one below 10. A number that was never on the old * menu was typed deliberately. * * The write goes through CSDT_Login::internal_option_write() because this option is * step-up gated: it is the plugin's own remediation, not a request from a browser, * and the gate is there to stop unattended DOWNGRADES. */ public static function maybe_tighten_probe_rule(): void { if ( get_option( self::OPTION_PROBE_7D_TIGHTENED ) ) { return; } $stored = get_option( 'csdt_wplogin_auto_block_threshold', null ); // Absent: nothing to migrate, the new default already applies. if ( null === $stored ) { update_option( self::OPTION_PROBE_7D_TIGHTENED, (string) time(), false ); return; } $old = (int) $stored; if ( ! in_array( $old, self::PROBE_7D_LEGACY_VALUES, true ) ) { update_option( self::OPTION_PROBE_7D_TIGHTENED, (string) time(), false ); return; } $new = self::DEFAULT_PROBE_7D_THRESHOLD; if ( class_exists( 'CSDT_Login' ) ) { CSDT_Login::internal_option_write( static function () use ( $new ) { update_option( 'csdt_wplogin_auto_block_threshold', (string) $new ); } ); } else { update_option( 'csdt_wplogin_auto_block_threshold', (string) $new ); } // Only record it as done if it actually took: the gate can veto, and a flag set // regardless would make one failed attempt permanent. if ( (int) get_option( 'csdt_wplogin_auto_block_threshold' ) !== $new ) { return; } update_option( self::OPTION_PROBE_7D_TIGHTENED, (string) time(), false ); $log = get_option( self::OPTION_LOG, array() ); if ( ! is_array( $log ) ) { $log = array(); } array_unshift( $log, array( 'ts' => time(), 'ip' => '', 'signal' => 'probe', 'count' => 0, 'ttl' => 0, 'offence' => 0, 'note' => sprintf( 'Probe auto-block threshold lowered from %d to %d probes / 7 days. Hits on a login path that has moved have no innocent explanation, and the old scale started at 20.', $old, $new ), ) ); update_option( self::OPTION_LOG, array_slice( $log, 0, 50 ), false ); } /** * Whether this IP must never be auto-blocked. * * The exemptions are not conveniences; each one prevents a specific * self-inflicted outage. */ public static function is_exempt( string $ip ): bool { if ( '' === $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return true; } // Private/reserved: this is what an unresolved proxy hop looks like. // Blocking it would blackhole every visitor behind that hop. if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return true; } // The site's own public IP. Every request the site makes to itself through // Cloudflare, uptime probes, health checks, loopback cron, arrives carrying // this address in CF-Connecting-IP, so it accrues against the very counters // that trigger a block. Left unexempted, a self-hosted site blocks its own // address, and everyone behind it, its own health probes included, gets // 403 Access denied. A blocker that can lock out // its own host is a worse outage than the probing it defends against. if ( in_array( $ip, self::own_public_ips(), true ) ) { return true; } // A recognised proxy/CDN edge is infrastructure, never an attacker. if ( CSDT_DevTools::ip_is_trusted_proxy( $ip ) ) { return true; } // The IP making this request, when it belongs to an administrator. if ( function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ) && $ip === CSDT_DevTools::get_client_ip() ) { return true; } // Any IP with a live administrator session. Corporate egress is often a // shared proxy address, so blocking it can lock out colleagues too. if ( in_array( $ip, self::admin_session_ips(), true ) ) { return true; } // Admin allowlist (single IPs or CIDRs). foreach ( preg_split( '/[\s,]+/', (string) get_option( self::OPTION_ALLOWLIST, '' ), -1, PREG_SPLIT_NO_EMPTY ) as $entry ) { if ( $entry === $ip ) { return true; } if ( strpos( $entry, '/' ) !== false && CSDT_DevTools::ip_matches_cidr( $ip, $entry ) ) { return true; } } return false; } /** * This site's own public IP address(es). * * Discovered rather than configured, because it moves with the ISP lease, and * cached for a day so no page load waits on it. Refreshed only from cron or CLI: * a visitor must never pay for an outbound request, and a stale answer here is * still a safe answer, it can only ever cause an IP NOT to be blocked. * * @return string[] */ public static function own_public_ips(): array { // Observed first: an address this site has SEEN ITSELF arrive from is evidence, where // the discovered list below is at best an inference. See observe_self_request(). return array_values( array_unique( array_merge( self::observed_ips(), self::discovered_public_ips() ) ) ); } /** * The discovered half of own_public_ips(): derived locally, or asked of /whoami. * * @since 1.10.963 Split out of own_public_ips() when the observed half was added. * @return string[] */ private static function discovered_public_ips(): array { $cached = get_option( 'csdt_own_public_ips', array() ); $ips = ( isset( $cached['ips'] ) && is_array( $cached['ips'] ) ) ? $cached['ips'] : array(); // Filtered on READ as well as on write. A proxy edge cached by a release before // 1.10.963 would otherwise be served until something replaced it, and on a site // where nothing can (local derivation finds nothing, the lookup opt-in is off) // that is for ever. $ips = array_values( array_filter( $ips, array( __CLASS__, 'is_plausible_own_ip' ) ) ); if ( $ips && ( time() - (int) ( $cached['ts'] ?? 0 ) ) < DAY_IN_SECONDS ) { return $ips; } if ( ! function_exists( 'wp_remote_get' ) || ! ( ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) ) { return $ips; } // Only ask when something will use the answer. The address exists to stop a blocker // banning the site's own host, so with every blocker off there is nothing to protect // and no reason to make a request at all. $needed = CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ) === '1' || CSDT_Options::get( 'csdt_autoblock_enabled' ) === '1' || ( class_exists( 'CSDT_Ip_Reputation' ) && CSDT_Ip_Reputation::is_enabled() ) || ( class_exists( 'CSDT_Firewall' ) && CSDT_Firewall::is_enabled() ) || CSDT_Options::get( 'csdt_geoblock_enabled' ) === '1'; if ( ! $needed ) { return $ips; } $found = self::own_public_ips_local(); // THE REMOTE CALL IS THE FALLBACK, NOT THE FIRST STEP. // // Local derivation covers ordinary hosting, where SERVER_ADDR (or the address // home_url()'s own hostname resolves to) is already the site's public address. It // does not cover a reverse proxy or a container network, where SERVER_ADDR is a // private address the loopback-test guard below correctly refuses to trust: this // plugin's own Pi deployment is exactly that case. wp_remote_get() to CloudScale's // /whoami is what covers it, and WordPress.org round 24 (WPORG-AGENT-002) found it // running on every fresh install with no way to turn it off. It is now gated on the // same opt-in as the daily CVE re-check: on a stock install with the option unset, // a site whose local derivation fails gets no answer here rather than an unconsented // request, and that is the SAFE failure direction, it can only cause an address NOT // to be exempted from auto-block, never the reverse. if ( empty( $found ) && class_exists( 'CSDT_Vuln_Recheck' ) && CSDT_Vuln_Recheck::is_enabled() ) { // One question, one answer: every managed AI call, licence check and active scan // already goes through the CloudScale API, so it sees this site's egress address on // every one of them. /whoami reports it back and nothing else, unauthenticated, // uncached and unlogged. Two third-party fallbacks used to sit behind it; they were // removed in 1.10.837 so that one documented endpoint is the whole story. $res = wp_remote_get( 'https://api.cloudscale.consulting/whoami', array( 'timeout' => 5, // Named, so core's default User-Agent, which carries the site URL, is not sent. 'user-agent' => 'CloudScale-Cyber-DevTools/' . ( defined( 'CSDT_DevTools::VERSION' ) ? CSDT_DevTools::VERSION : '0' ), 'headers' => array( 'Accept' => 'application/json' ), ) ); $r = CSDT_Http::json( $res ); $ip = ( 200 === $r['code'] && null !== $r['data'] ) ? trim( (string) ( $r['data']['ip'] ?? '' ) ) : ''; if ( self::is_plausible_own_ip( $ip ) ) { $found[] = $ip; } } // The value is cached in an option and refreshed daily, so a single failed refresh, // local or remote, keeps yesterday's answer rather than having none. if ( $found ) { update_option( 'csdt_own_public_ips', array( 'ts' => time(), 'ips' => array_values( array_unique( $found ) ) ), false ); return array_values( array_unique( $found ) ); } return $ips; } /** * This site's own public address(es), derived locally, no network call. * * Two sources, both already known to PHP: the value the web server sets for the socket * it is listening on, and what home_url()'s own hostname resolves to (the address the * rest of the internet would use to reach this site, which is not always the same * thing behind a load balancer, but is usually closer to right than nothing). * * Both are validated as PLAUSIBLE PUBLIC addresses before being trusted: SERVER_ADDR * inside a container is routinely a private RFC1918 address (this plugin's own * deployment is exactly that case), and a private or reserved address here would be * worse than an empty answer, it would tell the auto-blocker to exempt an address that * is not the site's public one at all. * * @return string[] */ private static function own_public_ips_local(): array { $found = array(); $server_addr = isset( $_SERVER['SERVER_ADDR'] ) ? CSDT_Request::server( 'SERVER_ADDR', 64 ) : ''; if ( self::is_plausible_own_ip( $server_addr ) ) { $found[] = $server_addr; } if ( function_exists( 'home_url' ) && function_exists( 'gethostbyname' ) ) { $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); if ( '' !== $host ) { $resolved = gethostbyname( $host ); // gethostbyname() returns the input UNCHANGED on failure, so a resolved value // identical to the hostname it was given is not a resolution. // // A PROXY EDGE IS NOT THIS SITE. Behind Cloudflare the hostname resolves to a // Cloudflare address: public, valid, and never the address this site's own // requests arrive from. Accepting it did two things at once, it exempted an // address that needed no exemption, and because the list was then non-empty // it stopped own_public_ips() ever asking anything better. On 2026-10-03 that // left the real egress unexempted, the probe rule blocked it, and every // request the site made to itself was refused for eight hours. if ( $resolved !== $host && self::is_plausible_own_ip( $resolved ) ) { $found[] = $resolved; } } } return $found; } /** * Whether an address could be this site's own public address. * * Public, and not a recognised proxy or CDN edge. The second half is the one that was * missing: an edge is the address visitors reach the site THROUGH, and every request * relayed by it carries somebody else's address as the client. * * @since 1.10.963 * @param mixed $ip Candidate address. * @return bool */ public static function is_plausible_own_ip( $ip ): bool { if ( ! is_string( $ip ) || '' === $ip ) { return false; } if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return false; } return ! CSDT_DevTools::ip_is_trusted_proxy( $ip ); } /* * ─── The site recognising its own requests ────────────────────────────── * * Discovery above is inference, and both of its sources fail on the commonest * self-hosted layout: inside a container SERVER_ADDR is private, and behind a CDN the * hostname resolves to the CDN. The /whoami lookup covers that, but it is opt-in and * off by default, so a stock install behind Cloudflare had NO source at all. * * Observation needs neither. Every request this site makes to itself (the cron spawn, * Site Health's loopback test, another plugin's background worker) is signed on the way * out with a secret only this site holds. When a request arrives carrying a valid * signature, the address it arrived from IS the site's own egress, whatever sits in * between, and that is recorded. No third party is asked, and the answer is the * address the blockers actually see rather than one somebody else reports. * * It is recorded at plugins_loaded priority 0, ahead of every blocker (1 to 5), so a * request from an address that is already blocked teaches the site its own address * first and is then released by the guard in CSDT_Login::enforce_ip_blocklist(). */ /** Header carrying "." on requests this site makes to itself. */ const SELF_HEADER = 'X-CSDT-Self'; /** Addresses the site has seen its own signed requests arrive from: ip => last seen. */ const OPTION_OBSERVED = 'csdt_own_observed_ips'; /** A signature older or newer than this is refused. Covers clock drift, not replay storage. */ const SELF_WINDOW = 5 * MINUTE_IN_SECONDS; /** An observed address not seen again for this long is dropped; the ISP lease has moved. */ const OBSERVED_TTL = 30 * DAY_IN_SECONDS; /** Most addresses kept. A site has one egress, perhaps one per address family or uplink. */ const OBSERVED_MAX = 4; /** * The signature for one timestamp. * * Keyed by wp_hash() on the site's own auth salt, which never leaves the server. Empty when the * pluggable layer is not loaded yet: no signature is then sent or accepted, which is * the inert direction. * * @since 1.10.963 * @param int $ts Unix time the request was signed at. * @return string Hex digest, or '' when it cannot be computed. */ private static function self_signature( int $ts ): string { if ( ! function_exists( 'wp_hash' ) ) { return ''; } return (string) wp_hash( 'csdt-self|' . $ts, 'auth' ); } /** * Hooked to `http_request_args`: signs a request whose destination is this site. * * Only ever added for this site's own hostname, so the signature is not handed to a * third party. What it authorises is narrow in any case: it cannot authenticate a * user or bypass a rule, it can only teach the site that the presenting address is its * own, and it stops being valid after SELF_WINDOW. * * @since 1.10.963 * @param mixed $args Request arguments. * @param mixed $url Request URL. * @return mixed The arguments, with the header added for a self-request. */ public static function sign_self_request( $args, $url ) { try { if ( ! is_array( $args ) || ! is_string( $url ) || ! function_exists( 'wp_parse_url' ) ) { return $args; } $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); $own = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( '' === $host || $host !== $own ) { return $args; } $ts = time(); $sig = self::self_signature( $ts ); if ( '' === $sig ) { return $args; } if ( ! isset( $args['headers'] ) || ! is_array( $args['headers'] ) ) { $args['headers'] = array(); } $args['headers'][ self::SELF_HEADER ] = $ts . '.' . $sig; } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return $args; } /** * Whether this request carries this site's own valid, current self-signature. * * Read-only: one header check, and nothing more for a request without the header, which is * every ordinary visitor. Used to learn the site's own egress (observe_self_request()) and by * Live Traffic to leave the site's own loopback requests (cron spawns, Upgrade Verification) * out of the visitor feed. It is not an authorisation: no blocker or rule is skipped on its * strength, because a signature is replayable for SELF_WINDOW by whoever sees one. * * @return bool */ public static function is_signed_self_request(): bool { if ( ! isset( $_SERVER['HTTP_X_CSDT_SELF'] ) ) { return false; } $parts = explode( '.', CSDT_Request::server( 'HTTP_X_CSDT_SELF', 128 ), 2 ); if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) { return false; } $ts = (int) $parts[0]; if ( abs( time() - $ts ) > self::SELF_WINDOW ) { return false; } $expected = self::self_signature( $ts ); return '' !== $expected && hash_equals( $expected, $parts[1] ); } /** * Hooked to `plugins_loaded` priority 0: records the client address of a request that * carries this site's own valid signature. * * Writes at most once a day per address, so the common case, the same egress as * yesterday, costs one option read and no write. * * @since 1.10.963 * @return void */ public static function observe_self_request(): void { try { if ( ! self::is_signed_self_request() ) { return; // Every ordinary visitor leaves here. } $ip = (string) CSDT_DevTools::get_client_ip(); if ( ! self::is_plausible_own_ip( $ip ) ) { return; // Private peer or a proxy edge: client IP detection is not resolving, nothing to learn. } $seen = get_option( self::OPTION_OBSERVED, array() ); $seen = is_array( $seen ) ? $seen : array(); $now = time(); if ( isset( $seen[ $ip ] ) && ( $now - (int) $seen[ $ip ] ) < DAY_IN_SECONDS ) { return; } $seen[ $ip ] = $now; foreach ( $seen as $addr => $at ) { if ( ( $now - (int) $at ) > self::OBSERVED_TTL ) { unset( $seen[ $addr ] ); } } arsort( $seen ); update_option( self::OPTION_OBSERVED, array_slice( $seen, 0, self::OBSERVED_MAX, true ), false ); } catch ( \Throwable $e ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } /** * Addresses this site has recently seen its own signed requests arrive from. * * @since 1.10.963 * @return string[] */ public static function observed_ips(): array { $seen = get_option( self::OPTION_OBSERVED, array() ); if ( ! is_array( $seen ) || ! $seen ) { return array(); } $now = time(); $out = array(); foreach ( $seen as $addr => $at ) { if ( ( $now - (int) $at ) <= self::OBSERVED_TTL && self::is_plausible_own_ip( (string) $addr ) ) { $out[] = (string) $addr; } } return $out; } /** * IP addresses with an active administrator session. * * WordPress records the originating IP in each session token, so this is the * authoritative "don't lock out a real admin" list. Cached briefly because * it costs a user-meta read per administrator. * * @return string[] */ private static function admin_session_ips(): array { $cached = get_transient( 'csdt_autoblock_admin_ips' ); if ( is_array( $cached ) ) { return $cached; } $ips = array(); /* * 'fields' => 'ID' as a STRING, and each element normalised anyway. * * This asked for array( 'ID' ) and read $u->ID. WordPress returns stdClass rows for that * form, so it worked, until something in the chain returned a flat list of id strings * instead, at which point `$u->ID` on a string emitted * "Attempt to read property ID on string" on every cron run (seen 2026-08-13 19:13 and * 19:52 SAST). `users_pre_query` lets any plugin replace the result set wholesale, and the * object cache persists a result's shape across updates, so the shape was never ours to * assume. * * THE WARNING WAS THE HARMLESS HALF. Reading ->ID on a string yields null, (int) null is 0, * get_user_meta( 0, … ) returns '', so every iteration hit the `continue` below and this * function returned an EMPTY list, silently switching off the "never lock out an address * with a live administrator session" exemption. That is the guard standing between this * blocker and locking the owner out of their own site from a shared corporate egress, and * it is exactly the class of self-inflicted outage the exemptions exist to prevent. It has * a passing test, which stubbed get_users() into objects and so proved the version of this * function that production was not running. * * The string form makes core use $wpdb->get_col(), which is unambiguous; the is_object() * normalisation costs nothing and survives a filter that hands back objects regardless. */ foreach ( get_users( array( 'role' => 'administrator', 'fields' => 'ID' ) ) as $admin ) { $admin_id = is_object( $admin ) ? (int) ( $admin->ID ?? 0 ) : (int) $admin; if ( $admin_id <= 0 ) { continue; } $tokens = get_user_meta( $admin_id, 'session_tokens', true ); if ( ! is_array( $tokens ) ) { continue; } foreach ( $tokens as $token ) { if ( is_array( $token ) && ! empty( $token['ip'] ) && filter_var( $token['ip'], FILTER_VALIDATE_IP ) ) { $ips[] = $token['ip']; } } } $ips = array_values( array_unique( $ips ) ); set_transient( 'csdt_autoblock_admin_ips', $ips, 5 * MINUTE_IN_SECONDS ); return $ips; } // ── Blocklist entry shape: one writer, one format ──────────────────── // // Four code paths used to create entries in csdt_ip_blocklist, in three // different shapes and with two contradictory expiry policies: // // - CSDT_Auto_Block::block() source=auto, expires_at escalating // - CSDT_Login (wp-login probes) auto=true, no expiry permanent // - CSDT_Perf_Monitor (failed logins) auto=true, no expiry permanent // - CSDT_Login::ajax_ip_block() no tag, no expiry permanent // // The divergence was not a decision. Because the release-expired-blocks // logic only ever releases entries carrying `expires_at`, everything written // by the other three paths was immortal, 79 such entries were found live, // all reason "wp-login probe", 63-78 days old, none releasable by any code // path. That is exactly what this class's own header warns against: a // permanent list eventually catches a recycled residential address belonging // to a real reader. Since 1.10.432 enforcement also covers the login page, // so such a reader is locked out of logging in, not merely of the front end. // // Everything now goes through add_entry(), and everything read goes through // normalise_entry(), so a legacy entry behaves correctly on the very first // request after upgrade rather than waiting for the migration to run. /** Canonical value of the `source` field. */ const SOURCE_AUTO = 'auto'; const SOURCE_MANUAL = 'manual'; /** * Expiry applied retroactively to legacy automatic blocks that never had one. * * The top of TTL_LADDER, i.e. the longest this class would ever block an * address on its own policy. Applied from the entry's original blocked_at, * not from now: the intent is to enforce the policy that should always have * applied, not to grant a stale block a fresh lease. */ const LEGACY_AUTO_TTL = 7 * DAY_IN_SECONDS; /** Option holding the blocklist shape version, so migration runs once. */ const OPTION_SHAPE_VERSION = 'csdt_ip_blocklist_shape'; /** Current shape version. Bump to re-run the migration. */ const SHAPE_VERSION = '2'; /** * Coerces any historical entry shape into the canonical one. * * Read path, so it must never write, never throw and never widen a block. * Classification is deliberately conservative in the direction that matters: * an entry it cannot prove is automatic is treated as MANUAL and therefore * left permanent, because releasing a block an administrator set by hand is * worse than keeping one a machine set. * * @param mixed $entry Raw value from the option. * @param string $ip Address the entry belongs to, for the fallback reason. * @return array{reason:string,blocked_at:int,expires_at:int,source:string,signal:string} * @since 1.10.432 */ public static function normalise_entry( $entry, string $ip = '' ): array { // A bare scalar is a very old shape: the reason with nothing else. if ( ! is_array( $entry ) ) { return array( 'reason' => is_string( $entry ) && '' !== $entry ? $entry : ( '' !== $ip ? "Blocked: {$ip}" : 'Blocked' ), 'blocked_at' => 0, 'expires_at' => 0, 'source' => self::SOURCE_MANUAL, 'signal' => '', ); } $reason = isset( $entry['reason'] ) ? (string) $entry['reason'] : ''; $blocked_at = isset( $entry['blocked_at'] ) ? (int) $entry['blocked_at'] : 0; // Source, in order of reliability: an explicit canonical field, then the // older boolean flag, then the reason text written by the legacy paths. if ( isset( $entry['source'] ) && self::SOURCE_AUTO === $entry['source'] ) { $source = self::SOURCE_AUTO; } elseif ( isset( $entry['source'] ) && self::SOURCE_MANUAL === $entry['source'] ) { $source = self::SOURCE_MANUAL; } elseif ( ! empty( $entry['auto'] ) ) { $source = self::SOURCE_AUTO; } elseif ( '' !== $reason && preg_match( '/^(auto-blocked|wp-login probe|probe ip auto)/i', $reason ) ) { // The 79 entries found live all read exactly "wp-login probe". $source = self::SOURCE_AUTO; } else { $source = self::SOURCE_MANUAL; } $expires_at = isset( $entry['expires_at'] ) ? (int) $entry['expires_at'] : 0; // A legacy automatic block with no expiry gets the policy it should have // had, measured from when it was actually imposed. An automatic block // whose date is unknown is treated as already expired rather than // permanent, we cannot justify holding it, and a still-hostile address // is re-blocked within minutes of trying again. if ( self::SOURCE_AUTO === $source && $expires_at <= 0 ) { $expires_at = $blocked_at > 0 ? $blocked_at + self::LEGACY_AUTO_TTL : 1; } return array( 'reason' => '' !== $reason ? $reason : 'Blocked', 'blocked_at' => $blocked_at, 'expires_at' => $expires_at, 'source' => $source, 'signal' => isset( $entry['signal'] ) ? (string) $entry['signal'] : '', ); } /** Blocks that have ended, newest first. Separate from the enforced list on purpose. */ const OPTION_HISTORY = 'csdt_ip_block_history'; /** * How many ended blocks are kept. * * Bounded because this is the option that grows: the live list self-limits, since an address * occupies one row however often it returns, but history gains a row per block that ends. On * a site taking a steady trickle of probes that is unbounded growth in an option, which is * how a log becomes a performance problem rather than a record. */ const HISTORY_MAX = 200; /** * How many ended blocks to keep, honouring a filter so the number is not a deploy. * * 200 is a judgement, not a measurement: enough that a week of ordinary probing stays readable, * few enough that the option stays small. A site under sustained attack will want more and a * quiet one needs less, and neither should have to edit a constant, so the value is filterable * and clamped to a range where it cannot become a performance problem at one end or useless at * the other. * * @since 1.10.586 * @return int */ public static function history_max(): int { return self::history_max_for( (int) apply_filters( 'csdt_block_history_max', self::HISTORY_MAX ) ); } /** * The clamp, separated from the filter so it is testable without WordPress. * * Both ends matter: a 0 keeps nothing and reads as "the history is broken", and a million puts * megabytes into a single option on a site under sustained attack. * * @since 1.10.586 * @param int $n Requested cap. * @return int */ public static function history_max_for( int $n ): int { return max( 20, min( 2000, $n ) ); } /** * Appends ended blocks to the history, newest first, capped. * * @since 1.10.583 * @param array $rows Rows built by prune() or an unblock path. */ private static function record_lifted( array $rows ): void { $history = get_option( self::OPTION_HISTORY, array() ); if ( ! is_array( $history ) ) { $history = array(); } // Newest first, matching how the panel reads it, so neither has to sort. usort( $rows, static fn( $a, $b ) => (int) $b['lifted_at'] <=> (int) $a['lifted_at'] ); $history = array_slice( array_merge( $rows, $history ), 0, self::history_max() ); update_option( self::OPTION_HISTORY, $history, false ); } /** * Every block this site knows about: currently enforced, then ended. Newest first. * * The panel's single list. Active rows come from the enforced option and carry their real * remaining time; ended rows come from history. An address that is blocked again after a * lifted block appears once as active and once per ended block, which is the truth about it * rather than a duplicate. * * @since 1.10.583 * @return array> */ public static function all_blocks(): array { $now = time(); $out = array(); $list = get_option( 'csdt_ip_blocklist', array() ); if ( is_array( $list ) ) { foreach ( $list as $ip => $row ) { $entry = self::normalise_entry( $row, (string) $ip ); // An entry whose expiry has passed but which prune() has not yet visited is // already unenforced, every reader checks the clock, not the cron. Showing it // as active would be a panel disagreeing with the code. $expired = self::entry_has_expired( $entry, $now ); $out[] = array( 'ip' => (string) $ip, 'reason' => (string) ( $entry['reason'] ?? '' ), 'blocked_at' => (int) ( $entry['blocked_at'] ?? 0 ), 'expires_at' => (int) ( $entry['expires_at'] ?? 0 ), 'source' => (string) ( $entry['source'] ?? '' ), 'signal' => (string) ( $entry['signal'] ?? '' ), 'state' => $expired ? 'lifted' : 'active', 'sort' => (int) ( $entry['blocked_at'] ?? 0 ), ); } } $history = get_option( self::OPTION_HISTORY, array() ); if ( is_array( $history ) ) { foreach ( $history as $row ) { if ( ! is_array( $row ) ) { continue; } $out[] = array( 'ip' => (string) ( $row['ip'] ?? '' ), 'reason' => (string) ( $row['reason'] ?? '' ), 'blocked_at' => (int) ( $row['blocked_at'] ?? 0 ), 'expires_at' => (int) ( $row['lifted_at'] ?? 0 ), 'source' => (string) ( $row['source'] ?? '' ), 'signal' => (string) ( $row['signal'] ?? '' ), 'state' => (string) ( $row['state'] ?? 'lifted' ), 'sort' => (int) ( $row['blocked_at'] ?? $row['lifted_at'] ?? 0 ), ); } } // Active first, then by recency. An administrator opens this to see who is blocked now; // the history is context underneath it, not a competing headline. usort( $out, static function ( $a, $b ) { if ( ( 'active' === $a['state'] ) !== ( 'active' === $b['state'] ) ) { return 'active' === $a['state'] ? -1 : 1; } return $b['sort'] <=> $a['sort']; } ); return $out; } /** * Whether a normalised entry is past its expiry. * * expires_at of 0 means "never", that is a manual block and only an * administrator removes it. Anything above 0 is compared to the clock. * * @since 1.10.432 */ public static function entry_has_expired( array $entry, ?int $now = null ): bool { $expires = (int) ( $entry['expires_at'] ?? 0 ); return $expires > 0 && $expires < ( null === $now ? time() : $now ); } /** * The escalating TTL this address has earned, and records the offence. * * Extracted so all three automatic blockers escalate on one ladder instead * of each inventing its own duration. * * @since 1.10.432 * @return int Seconds the block should last. */ public static function next_auto_ttl( string $ip ): int { $offences = get_option( self::OPTION_OFFENCES, array() ); if ( ! is_array( $offences ) ) { $offences = array(); } $prior = (int) ( $offences[ $ip ] ?? 0 ); $ttl = self::TTL_LADDER[ min( $prior, count( self::TTL_LADDER ) - 1 ) ]; $offences[ $ip ] = $prior + 1; update_option( self::OPTION_OFFENCES, $offences, false ); return $ttl; } /** * The single writer for csdt_ip_blocklist. Every path that blocks an address * goes through here, so every entry has the same shape and obeys the same * expiry policy. * * Refuses to add an address that is already listed (so a second signal does * not reset an existing block's clock) and refuses anything is_exempt() * rejects, which keeps the own-egress and proxy-hop protections on every * path rather than only on this class's own. * * @param string $ip Address to block. * @param string $reason Human-readable reason, shown in the admin. * @param string $source self::SOURCE_AUTO or self::SOURCE_MANUAL. * @param int $ttl Seconds until expiry; 0 means never (manual only). * @param string $signal Optional signal key for automatic blocks. * @return bool Whether an entry was added. * @since 1.10.432 */ public static function add_entry( string $ip, string $reason, string $source, int $ttl = 0, string $signal = '' ): bool { if ( self::SOURCE_MANUAL !== $source ) { $source = self::SOURCE_AUTO; } // Manual blocks are an administrator's explicit choice and may target an // address this class would never auto-block, so the exemptions apply to // automatic paths only. Validity is still required either way. if ( '' === $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return false; } if ( self::SOURCE_AUTO === $source && self::is_exempt( $ip ) ) { return false; } $list = get_option( 'csdt_ip_blocklist', array() ); if ( ! is_array( $list ) ) { $list = array(); } if ( isset( $list[ $ip ] ) ) { return false; } $now = time(); $list[ $ip ] = array( 'reason' => $reason, 'blocked_at' => $now, 'expires_at' => $ttl > 0 ? $now + $ttl : 0, 'source' => $source, 'signal' => $signal, ); $saved = update_option( 'csdt_ip_blocklist', $list, false ); if ( false === $saved ) { // The value changed (we just added an entry), so a false return is a real DB // failure, not a no-op. The block was NOT recorded, an attacker who tripped // the threshold goes unblocked. Log so the operator can find out. if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) { CSDT_DevTools::log( 'CSDT_Auto_Block::add_to_blocklist: update_option failed for IP ' . $ip ); } } update_option( 'csdt_ip_blocklist_active', '1' ); return true; } /** * One-time rewrite of the stored blocklist into the canonical shape. * * Idempotent and gated on an option version, following * CSDT_GitHub_Sync::maybe_install_table(). The read path already normalises, * so this is housekeeping rather than the fix, it exists so the admin screens * and any future reader see one shape, and so the data stops lying about * itself. * * @since 1.10.432 * @return array{migrated:int,expired:int,kept:int} Counts, for logging. */ public static function migrate_blocklist(): array { $stats = array( 'migrated' => 0, 'expired' => 0, 'kept' => 0, ); if ( get_option( self::OPTION_SHAPE_VERSION ) === self::SHAPE_VERSION ) { return $stats; } $list = get_option( 'csdt_ip_blocklist', array() ); if ( ! is_array( $list ) ) { $list = array(); } $out = array(); foreach ( $list as $ip => $entry ) { $normal = self::normalise_entry( $entry, (string) $ip ); if ( ! is_array( $entry ) || ! isset( $entry['source'] ) ) { ++$stats['migrated']; } // Drop anything already past its expiry rather than writing it back // for the request path to trip over. if ( self::entry_has_expired( $normal ) ) { ++$stats['expired']; continue; } $out[ (string) $ip ] = $normal; ++$stats['kept']; } update_option( 'csdt_ip_blocklist', $out, false ); update_option( 'csdt_ip_blocklist_active', empty( $out ) ? '0' : '1' ); update_option( self::OPTION_SHAPE_VERSION, self::SHAPE_VERSION, false ); if ( $stats['migrated'] > 0 || $stats['expired'] > 0 ) { self::log_migration( $stats ); } return $stats; } /** * Records the migration in the auto-block audit log, so a list that shrank by * 79 entries has a visible reason rather than looking like data loss. * * @since 1.10.432 */ private static function log_migration( array $stats ): void { $log = get_option( self::OPTION_LOG, array() ); if ( ! is_array( $log ) ) { $log = array(); } array_unshift( $log, array( 'ip' => '-', 'reason' => sprintf( 'Blocklist shape migrated: %d legacy entries normalised, %d released as expired, %d retained.', $stats['migrated'], $stats['expired'], $stats['kept'] ), 'at' => time(), 'signal' => 'migration', ) ); update_option( self::OPTION_LOG, array_slice( $log, 0, 100 ), false ); } /** * Blocks an IP with an escalating expiry, records it, and alerts. * * @param string $signal Signal that tripped the threshold. * @param int $count Events observed in the window. */ private static function block( string $ip, string $signal, int $count ): void { $label = self::SIGNAL_LABELS[ $signal ] ?? $signal; $ttl = self::next_auto_ttl( $ip ); if ( ! self::add_entry( $ip, sprintf( 'Auto-blocked: %d %s', $count, $label ), self::SOURCE_AUTO, $ttl, $signal ) ) { return; // Already blocked, or exempt. } $offences = get_option( self::OPTION_OFFENCES, array() ); $offence = is_array( $offences ) ? (int) ( $offences[ $ip ] ?? 1 ) : 1; // Clear the counters for this IP so an expired block starts it from zero // rather than from an already-tripped tally. if ( class_exists( 'CSDT_Storage' ) ) { CSDT_Storage::clear_events( $ip ); } self::log_block( $ip, $signal, $count, $ttl, $offence ); self::alert( $ip, $label, $count, $ttl, $offence ); } /** Appends to the rolling audit log (capped). */ private static function log_block( string $ip, string $signal, int $count, int $ttl, int $offence ): void { $log = get_option( self::OPTION_LOG, array() ); if ( ! is_array( $log ) ) { $log = array(); } array_unshift( $log, array( 'ip' => $ip, 'signal' => $signal, 'count' => $count, 'ttl' => $ttl, 'offence' => $offence, 'at' => time(), ) ); update_option( self::OPTION_LOG, array_slice( $log, 0, 100 ), false ); } /** * The four facts every block alert has to carry. * * One function because there are two block paths, this class's threshold rule and * CSDT_Login's 7-day probe rule, and they had drifted into two different alert texts. * The probe alert stated what triggered the block but never how long it lasted, so the * only way to find out was to open the admin panel. An alert you have to go and * investigate is half an alert. * * @since 1.10.488 * @param string $ip Blocked address. * @param string $trigger What tripped it, already pluralised, e.g. "58 wp-login.php probes". * @param string $window Human-readable window the trigger was counted over. * @param int $ttl Block duration in seconds. * @param int $offence Which offence this is for the address. * @param int $at When the block started; 0 means now. A bundled digest is sent * up to a day later, and the lift time must come from the block. * @return string Alert body. */ public static function describe_block( string $ip, string $trigger, string $window, int $ttl, int $offence, int $at = 0 ): string { $country = ''; if ( class_exists( 'CSDT_Geo' ) && method_exists( 'CSDT_Geo', 'lookup_dbip' ) ) { $country = (string) CSDT_Geo::lookup_dbip( $ip ); } $now = time(); $start = $at > 0 ? $at : $now; $lift = $start + $ttl; $expires = $ttl > 0 ? sprintf( $lift <= $now /* translators: 1: duration e.g. "4 hours", 2: local date and time it lifted. */ ? __( '%1$s, already lifted at %2$s', 'cloudscale-cyber-devtools' ) /* translators: 1: duration e.g. "4 hours", 2: local date and time it lifts. */ : __( '%1$s, lifting at %2$s', 'cloudscale-cyber-devtools' ), human_time_diff( $start, $lift ), wp_date( 'Y-m-d H:i T', $lift ) ) : __( 'permanent, until removed by hand', 'cloudscale-cyber-devtools' ); return sprintf( "IP auto-blocked: %s%s\nTrigger: %s in the last %s\nThreshold reached, so the address is refused.\nDuration: %s\nOffence: #%d%s\nSite: %s", $ip, $country ? ' (' . $country . ')' : '', $trigger, $window, $expires, $offence, $offence > 1 ? ' ' . __( '(repeat offender, so the block is longer than the first one)', 'cloudscale-cyber-devtools' ) : '', home_url() ); } /** * Telegram + email alert, rate-limited. * * A distributed attack can trip dozens of blocks a minute; without a cap the * alert channel becomes the denial of service. */ private static function alert( string $ip, string $label, int $count, int $ttl, int $offence ): void { self::queue_alert( array( 'ip' => $ip, 'trigger' => sprintf( '%d %s', $count, $label ), 'window' => human_time_diff( time(), time() + (int) get_option( self::OPTION_WINDOW, self::DEFAULT_WINDOW ) ), 'ttl' => $ttl, 'offence' => $offence, ) ); } /** The admin's chosen bundling, validated. */ public static function alert_bulk(): string { $stored = (string) get_option( self::OPTION_ALERT_BULK, self::DEFAULT_ALERT_BULK ); return in_array( $stored, self::ALERT_BULK_CHOICES, true ) ? $stored : self::DEFAULT_ALERT_BULK; } /** * Adds one block to the pending digest, and sends it if the bundle is due. * * Both block paths come through here, this class's threshold rule and CSDT_Login's * 7-day probe rule. That matters: the probe rule was the noisy one, sending its own * alert through a different function, so bundling only this class's alerts would have * left half the noise in place. * * @since 1.10.488 * @param array $entry Block details. * @param int|null $now Unix time, for tests. */ public static function queue_alert( array $entry, ?int $now = null ): void { $now = null === $now ? time() : $now; $bulk = self::alert_bulk(); $pending = get_option( self::OPTION_ALERT_PENDING, array() ); if ( ! is_array( $pending ) ) { $pending = array(); } // When the block happened, so a digest sent a day later reports the real lift time. if ( ! isset( $entry['at'] ) ) { $entry['at'] = $now; } // Which site queued it. A restored copy inherits this option with the database; // flush_alerts() drops a queue whose owner is another site. if ( ! isset( $pending['site'] ) ) { $pending['site'] = self::site_fingerprint(); } $pending['total'] = (int) ( $pending['total'] ?? 0 ) + 1; $pending['first'] = (int) ( $pending['first'] ?? $now ); $items = isset( $pending['items'] ) && is_array( $pending['items'] ) ? $pending['items'] : array(); if ( count( $items ) < self::ALERT_DIGEST_MAX ) { $items[] = $entry; } $pending['items'] = $items; update_option( self::OPTION_ALERT_PENDING, $pending, false ); if ( self::digest_due( $bulk, $pending, $now ) ) { self::flush_alerts( $now ); } } /** * Whether the pending digest should go out now. * * Pure, so every bundling choice is testable without sending anything. * * @since 1.10.488 * @param string $bulk Bundling choice. * @param array $pending Pending digest. * @param int $now Unix time. * @return bool */ public static function digest_due( string $bulk, array $pending, int $now ): bool { $total = (int) ( $pending['total'] ?? 0 ); if ( $total < 1 ) { return false; } if ( 'each' === $bulk ) { return true; } if ( 'hourly' === $bulk || 'daily' === $bulk ) { $age = $now - (int) ( $pending['first'] ?? $now ); return $age >= ( 'hourly' === $bulk ? HOUR_IN_SECONDS : DAY_IN_SECONDS ); } return $total >= max( 1, (int) $bulk ); } /** * Identifies the site that queued a digest. * * A hash, not the URL: a restore rewrites every stored copy of the old URL to the new * one (search-replace reaches serialized options), so a stored URL would always match. * * @return string */ public static function site_fingerprint(): string { return md5( rtrim( (string) home_url(), '/' ) ); } /** * Sends the pending digest and clears it. * * @since 1.10.488 * @param int|null $now Unix time, for tests. * @return bool Whether anything was sent. */ public static function flush_alerts( ?int $now = null ): bool { $now = null === $now ? time() : $now; $pending = get_option( self::OPTION_ALERT_PENDING, array() ); if ( ! is_array( $pending ) || (int) ( $pending['total'] ?? 0 ) < 1 ) { return false; } // A queue another site wrote, carried here by a database restore. On 2026-09-26 the DR // copy sent prod's day-old digest under its own "DR>" label, reporting a block DR never // made. Those blocks were prod's to report, and prod reports them. A queue from before // this field existed has no owner and is sent as it always was. if ( isset( $pending['site'] ) && ! hash_equals( (string) $pending['site'], self::site_fingerprint() ) ) { delete_option( self::OPTION_ALERT_PENDING ); return false; } // The old per-send cap, kept as a backstop rather than removed. Bundling makes it // almost unreachable, but "every single block" is still a choice an admin can make, // and under a distributed attack that choice would otherwise turn the alert channel // into the denial of service. $sent = CSDT_Alert_Window::count( 'autoblock_alerts_sent' ); if ( $sent >= 5 ) { // Deliberately NOT cleared. The blocks stay pending and roll into the next // digest, so throttling delays the report rather than losing it. return false; } if ( ! class_exists( 'CSDT_Telegram' ) || ! CSDT_Telegram::is_configured() ) { // Nowhere to send it, now or later. Holding the queue would only grow it. delete_option( self::OPTION_ALERT_PENDING ); return false; } $total = (int) $pending['total']; $items = is_array( $pending['items'] ?? null ) ? $pending['items'] : array(); $msg = self::describe_digest( $total, $items, (int) ( $pending['first'] ?? $now ), $now ); // Counts ATTEMPTS, not deliveries: while Telegram is down each attempt can cost a // 10s timeout inside the request that triggered the block, and this is what bounds that. // Option-backed (CSDT_Alert_Window): a transient counter reset on every deploy's flush. CSDT_Alert_Window::bump( 'autoblock_alerts_sent', 15 * MINUTE_IN_SECONDS ); // The queue is cleared AFTER the send, and only when the digest went out. It used to // be cleared first and the result ignored, so a digest held by the Telegram cap or a // failed POST was simply gone: the same loss the backstop above promises not to cause. // Held or failed, the blocks stay pending and roll into the next digest. $delivered = CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $msg, 'CSDT Auto-Block', 'warning' ); $outcome = method_exists( 'CSDT_Telegram', 'last_outcome' ) ? CSDT_Telegram::last_outcome() : ''; // A muted copy will never send; keeping its queue would only grow it. An older // CSDT_Telegram cannot say it was muted, so ask it directly. $muted = 'muted' === $outcome || ( '' === $outcome && method_exists( 'CSDT_Telegram', 'is_muted' ) && CSDT_Telegram::is_muted() ); if ( $delivered || $muted ) { delete_option( self::OPTION_ALERT_PENDING ); } return $delivered; } /** * Builds the digest body. * * A single block still reads as a single block, no "1 address blocked" summary wrapper * around one entry, because the commonest case deserves the clearest message. * * @since 1.10.488 * @param int $total Blocks in this digest. * @param array $items Detailed entries, possibly fewer than $total. * @param int $first Unix time of the earliest block. * @param int $now Unix time. * @return string */ public static function describe_digest( int $total, array $items, int $first, int $now ): string { if ( 1 === $total && 1 === count( $items ) ) { $one = $items[0]; return self::describe_block( (string) ( $one['ip'] ?? '' ), (string) ( $one['trigger'] ?? '' ), (string) ( $one['window'] ?? '' ), (int) ( $one['ttl'] ?? 0 ), (int) ( $one['offence'] ?? 1 ), (int) ( $one['at'] ?? 0 ) ); } $span = max( 1, $now - $first ); $lines = array(); foreach ( $items as $item ) { $ttl = (int) ( $item['ttl'] ?? 0 ); $at = (int) ( $item['at'] ?? 0 ); $lines[] = sprintf( 'β€’ %s, %s, blocked for %s%s', (string) ( $item['ip'] ?? '' ), (string) ( $item['trigger'] ?? '' ), $ttl > 0 ? human_time_diff( 0, $ttl ) : __( 'until removed by hand', 'cloudscale-cyber-devtools' ), ( $ttl > 0 && $at > 0 && $at + $ttl <= $now ) ? __( ' (already lifted)', 'cloudscale-cyber-devtools' ) : '' ); } if ( $total > count( $items ) ) { $lines[] = sprintf( /* translators: %d: number of further blocked addresses not listed. */ __( 'β€’ …and %d more; the full list is on the Login Security tab', 'cloudscale-cyber-devtools' ), $total - count( $items ) ); } return sprintf( "%s\n\n%s\n\n%s\n%s", sprintf( /* translators: 1: number of addresses, 2: time span e.g. "3 hours". */ _n( '%1$d address auto-blocked in the last %2$s', '%1$d addresses auto-blocked in the last %2$s', $total, 'cloudscale-cyber-devtools' ), $total, human_time_diff( $now - $span, $now ) ), implode( "\n", $lines ), __( 'Bundled to keep this channel readable. The full record is on the Login Security tab.', 'cloudscale-cyber-devtools' ), sprintf( 'Site: %s', home_url() ) ); } /** * Drops expired blocks and stale ledger entries. Runs on cron. * * Expiry is also enforced at request time, but pruning keeps wp_options * small and the admin list honest about what is actually in force. */ public static function prune(): void { $now = time(); $list = get_option( 'csdt_ip_blocklist', array() ); if ( is_array( $list ) ) { $changed = false; $lifted = array(); foreach ( $list as $ip => $row ) { $expires = is_array( $row ) ? (int) ( $row['expires_at'] ?? 0 ) : 0; if ( $expires > 0 && $expires < $now ) { // Kept, not discarded. Until now an expired block vanished completely, so // "Blocked IPs" showed current state only and the sole surviving trace was a // separate audit table with no reason and no unblock button, two lists // answering one question, badly. // // The retained copy goes to its OWN option rather than staying here with a // state flag. That was the tempting version and it is wrong: five places // test membership of csdt_ip_blocklist with isset() and no expiry check, so // a lifted row left in place would silently change behaviour at all five, // including CSDT_Login's wp-login probe blocker, which would then treat a // lifted address as already handled and never block it again, and // add_entry(), which would refuse to re-block it at all. The enforced list // keeps exactly the semantics every reader already relies on; history is a // separate concern and is stored separately. if ( is_array( $row ) ) { $lifted[] = array( 'ip' => (string) $ip, 'reason' => (string) ( $row['reason'] ?? '' ), 'blocked_at' => (int) ( $row['blocked_at'] ?? 0 ), 'lifted_at' => $expires, 'source' => (string) ( $row['source'] ?? '' ), 'signal' => (string) ( $row['signal'] ?? '' ), 'state' => 'lifted', ); } unset( $list[ $ip ] ); $changed = true; } } if ( $changed ) { update_option( 'csdt_ip_blocklist', $list, false ); update_option( 'csdt_ip_blocklist_active', empty( $list ) ? '0' : '1' ); } if ( ! empty( $lifted ) ) { self::record_lifted( $lifted ); } } // A time-based bundle ("one an hour", "one a day") is only checked when the next // block arrives. Three blocks followed by a quiet week would otherwise sit pending // and never be reported at all, a digest that silently swallows what it was meant // to summarise. So the hourly cron asks as well. if ( self::digest_due( self::alert_bulk(), (array) get_option( self::OPTION_ALERT_PENDING, array() ), $now ) ) { self::flush_alerts( $now ); } // Counter rows and firewall log rows past retention, in bounded batches. Both // report what they removed: a prune that has quietly stopped covering anything // should be visible, and on this schedule nothing else would ever say so. if ( class_exists( 'CSDT_Storage' ) ) { $pruned_counters = CSDT_Storage::prune( $now ); $pruned_log = is_callable( array( 'CSDT_Storage', 'prune_log' ) ) ? CSDT_Storage::prune_log( $now ) : 0; $pruned_activity = is_callable( array( 'CSDT_Activity_Log', 'prune' ) ) ? CSDT_Activity_Log::prune( $now ) : 0; // Live traffic, pruned whether or not recording is on: switching it off must not // leave a day of addresses and user agents sitting in the table forever. $pruned_traffic = is_callable( array( 'CSDT_Storage', 'prune_traffic' ) ) ? CSDT_Storage::prune_traffic( $now ) : 0; if ( ( $pruned_counters + $pruned_log + $pruned_activity + $pruned_traffic ) > 0 ) { update_option( 'csdt_storage_last_prune', array( 'ts' => $now, 'counters' => (int) $pruned_counters, 'log' => (int) $pruned_log, 'activity' => (int) $pruned_activity, 'traffic' => (int) $pruned_traffic, ), false ); } } // The retired option ledger. Deleted rather than trimmed: nothing reads it // since 1.10.436, and on a busy site it could be hundreds of kilobytes. if ( false !== get_option( self::OPTION_COUNTS, false ) ) { delete_option( self::OPTION_COUNTS ); } } // trim_ledger() was removed in 1.10.436 along with the option ledger it // maintained. It ran on EVERY hostile request and walked all tracked addresses // each time, and its MAX_TRACKED_IPS cap silently stopped counting the 501st // attacking address in a window. Retention is now a bucket comparison in // CSDT_Storage::prune(), running hourly on cron rather than in the request. }