'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();
?>
0 ) :
?>
0 ) :
?>
' . esc_html( $my_ip ) . ''
);
?>
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.
}