ID : get_current_user_id();
if ( $user_id <= 0 ) {
return;
}
// Same session token, so this replaces the cookie just set rather than opening a
// second session for one login.
wp_set_auth_cookie( $user_id, true, '', wp_get_session_token() );
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Returns true when the auth cookie should be written as a persistent cookie.
* Respects the custom session duration setting (always persistent) and falls
* back to the user's original "Remember Me" choice stored in the 2FA transient.
*
* @since 1.9.10
* @param array $pending 2FA pending transient data (may be empty for non-2FA logins).
* @return bool
*/
private static function login_should_remember( array $pending = array() ): bool {
if ( CSDT_Options::get( 'csdt_devtools_session_duration' ) !== 'default' ) {
return true;
}
return ! empty( $pending['remember'] );
}
/**
* Hooked to `authenticate` at priority 1. Returns a WP_Error when the
* submitted username has been temporarily locked due to repeated failed attempts.
*
* @since 1.9.10
* @param \WP_User|\WP_Error|null $user
* @param string $username
* @param string $password
* @return \WP_User|\WP_Error|null
*/
public static function login_brute_force_check( $user, string $username, string $password ) {
try {
if ( CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ) !== '1' ) {
return $user;
}
if ( empty( $username ) ) {
return $user;
}
// The address first, whatever the username. A per-account lock cannot see
// one address trying two hundred accounts once each, and the auto-blocker
// only counts when somebody has switched it on. This needs no opt-in and
// writes no blocklist: a transient, released by time.
$ip_error = self::ip_lock_error( self::get_client_ip() );
if ( null !== $ip_error ) {
return $ip_error;
}
$lock_key = 'csdt_devtools_bf_lock_' . md5( strtolower( $username ) );
$locked_until = get_transient( $lock_key );
if ( $locked_until === false ) {
return $user;
}
$remaining = (int) $locked_until - time();
if ( $remaining <= 0 ) {
delete_transient( $lock_key );
return $user;
}
$mins = (int) ceil( $remaining / 60 );
/* translators: %d is the number of minutes remaining in a lockout period */
$label = $mins <= 1
? __( 'less than a minute', 'cloudscale-cyber-devtools' )
/* translators: %d: number of minutes */
: sprintf( _n( '%d minute', '%d minutes', $mins, 'cloudscale-cyber-devtools' ), $mins );
return new \WP_Error(
'csdt_devtools_account_locked',
sprintf(
/* translators: %s = remaining lockout time, e.g. "5 minutes" */
__( 'This account has been temporarily locked due to too many failed login attempts. Please try again in %s.', 'cloudscale-cyber-devtools' ),
$label
)
);
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $user;
}
}
/**
* Fired on `login_init` at priority 0, before any other login hook.
* If the visitor already has a valid WordPress session, redirect them
* straight to the dashboard instead of showing the login form.
*
* Skipped for logout, password reset, and other non-login actions so those
* flows are never short-circuited.
*
* @since 1.9.4
* @return void
*/
public static function login_redirect_authenticated(): void {
try {
$action = self::requested_login_action( 'login' );
$skip = array( 'logout', 'lostpassword', 'rp', 'resetpass', 'postpass', 'register', 'csdt_devtools_2fa' );
if ( in_array( $action, $skip, true ) ) {
return;
}
if ( ! is_user_logged_in() ) {
return;
}
wp_safe_redirect( admin_url() );
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Fired on `login_init` at priority 1. Blocks direct access to
* wp-login.php when Hide Login is enabled.
*
* @since 1.9.4
* @return void
*/
/**
* Replaces credential-specific login errors with a generic message to prevent
* username enumeration. Lockout and 2FA errors are left unchanged.
*/
/**
* Has the administrator opted in to the "Powered by CloudScale" credit?
*
* One switch, csdt_show_powered_by, default off, covers every place the plugin
* could put its own name in front of a visitor: the code-block footer, the
* login error text, the access-restricted page and the honeypot page. A link
* to the vendor on a public page is a credit, and WordPress.org's guideline 10
* is that a credit appears only when the site owner asked for it.
*
* @return bool
*/
/**
* wp-login.php's own `action` argument (login, logout, lostpassword, ...), or $default.
*
* The one place it is read. Core's login screen reads the same argument the same way
* (wp-login.php: `$action = isset( $_REQUEST['action'] ) ? $_REQUEST['action'] : 'login'`)
* and that form carries no nonce, so none can be verified here; the value only decides
* which login screen this request is for and is never stored.
*
* @param string $default Value when the request names no action.
*/
private static function requested_login_action( string $default ): string {
return CSDT_Request::routing_key( 'action', 'request' ) ?? $default;
}
public static function credit_opted_in(): bool {
return get_option( 'csdt_show_powered_by', '0' ) === '1';
}
/**
* The "secured by" block for the two full-page responses, or nothing.
*
* @return string HTML, or an empty string when the credit is not opted in.
*/
private static function credit_block(): string {
// The credit and its opt-in share one statement: the administrator's "Show Powered by
// CloudScale credit" checkbox (option csdt_show_powered_by, default off) decides it.
return self::credit_opted_in() ? '
This site is secured by
' . "\n" . ' CloudScale Cyber and Devtools' . "\n" . ' ' . "\n" . ' ❓ Plugin Help & Documentation' : '';
}
public static function generic_login_errors( \WP_Error $errors ): \WP_Error {
try {
$enum_codes = array( 'invalid_username', 'invalid_email', 'incorrect_password', 'invalidcombo' );
$replaced = false;
foreach ( $enum_codes as $code ) {
if ( $errors->get_error_message( $code ) ) {
$errors->remove( $code );
$replaced = true;
}
}
if ( $replaced ) {
$errors->add(
'authentication_failed',
'' . esc_html__( 'Error:', 'cloudscale-cyber-devtools' ) . ' ' .
esc_html__( 'Invalid username or password.', 'cloudscale-cyber-devtools' ) .
( self::credit_opted_in() ? ' Protected by CloudScale Cyber and Devtools.' : '' )
);
}
return $errors;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $errors;
}
}
public static function login_error_styles(): void {
try {
// A stylesheet, not a PHP string handed to wp_add_inline_style(): the rules are
// static, and WordPress.org reads a static string in that call as inline CSS.
wp_enqueue_style(
'csdt-login',
plugins_url( 'assets/cs-login.css', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array( 'login' ),
CSDT_DevTools::VERSION
);
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Shared stats recorder for all default-login-path probes (wp-login.php and wp-admin).
* Updates the daily count, last-hit metadata, and per-IP hits log in csdt_wplogin_blocked_stats.
*/
private static function record_default_login_probe( string $ip ): void {
if ( ! $ip || filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) {
return; }
// Hitting the real wp-login.php while a custom slug is active is never a
// legitimate visitor action, it is a scanner walking known paths.
CSDT_Auto_Block::record( 'probe', $ip );
$today = gmdate( 'Y-m-d' );
$now = time();
$stats = get_option( 'csdt_wplogin_blocked_stats', array() );
if ( ! isset( $stats['daily'] ) || ! is_array( $stats['daily'] ) ) {
$stats['daily'] = array();
}
$stats['daily'][ $today ] = ( $stats['daily'][ $today ] ?? 0 ) + 1;
$cutoff = gmdate( 'Y-m-d', strtotime( '-7 days' ) );
foreach ( array_keys( $stats['daily'] ) as $k ) {
if ( $k < $cutoff ) {
unset( $stats['daily'][ $k ] );
}
}
// Per-country 7-day rolling count for the map.
$country = CSDT_Geo::get_country( $ip );
if ( $country ) {
if ( ! isset( $stats['country_stats'] ) || ! is_array( $stats['country_stats'] ) ) {
$stats['country_stats'] = array();
}
if ( ! isset( $stats['country_stats'][ $country ] ) || ! is_array( $stats['country_stats'][ $country ] ) ) {
$stats['country_stats'][ $country ] = array();
}
$stats['country_stats'][ $country ][ $today ] = ( $stats['country_stats'][ $country ][ $today ] ?? 0 ) + 1;
foreach ( $stats['country_stats'] as $cc => $days ) {
foreach ( array_keys( $days ) as $day_key ) {
if ( $day_key < $cutoff ) {
unset( $stats['country_stats'][ $cc ][ $day_key ] );
}
}
if ( empty( $stats['country_stats'][ $cc ] ) ) {
unset( $stats['country_stats'][ $cc ] );
}
}
}
$stats['last_ts'] = $now;
$stats['last_ip'] = $ip;
unset( $stats['hits'] ); // removed: replaced by ip_stats
if ( ! isset( $stats['ip_stats'] ) || ! is_array( $stats['ip_stats'] ) ) {
$stats['ip_stats'] = array();
}
if ( ! isset( $stats['ip_stats'][ $ip ] ) || ! is_array( $stats['ip_stats'][ $ip ] ) ) {
$stats['ip_stats'][ $ip ] = array(
'last_ts' => 0,
'days' => array(),
);
}
$stats['ip_stats'][ $ip ]['last_ts'] = $now;
$stats['ip_stats'][ $ip ]['days'][ $today ] = ( $stats['ip_stats'][ $ip ]['days'][ $today ] ?? 0 ) + 1;
if ( $country ) {
$stats['ip_stats'][ $ip ]['country'] = $country;
}
foreach ( $stats['ip_stats'] as $ip_key => &$ip_data ) {
foreach ( array_keys( $ip_data['days'] ) as $day_key ) {
if ( $day_key < $cutoff ) {
unset( $ip_data['days'][ $day_key ] );
}
}
if ( empty( $ip_data['days'] ) ) {
unset( $stats['ip_stats'][ $ip_key ] );
}
}
unset( $ip_data );
if ( count( $stats['ip_stats'] ) > 200 ) {
uasort( $stats['ip_stats'], fn( $a, $b ) => $b['last_ts'] - $a['last_ts'] );
$stats['ip_stats'] = array_slice( $stats['ip_stats'], 0, 200, true );
}
update_option( 'csdt_wplogin_blocked_stats', $stats, false );
// Auto-block if this IP's 7-day probe total reaches the configured threshold.
// The default lives in CSDT_Auto_Block so the panel, this blocker and the
// migration cannot disagree about it; it used to be an inline '0' here, which
// meant a site that never opened the setting had no probe defence at all.
$threshold = class_exists( 'CSDT_Auto_Block' )
? CSDT_Auto_Block::probe_7d_threshold()
: (int) get_option( 'csdt_wplogin_auto_block_threshold', '10' );
if ( $threshold < 1 ) {
return; }
// The exemptions CSDT_Auto_Block already enforces apply here too, and this
// path was not asking. Without them a self-hosted site can block its OWN public
// IP: its monitoring goes out through the CDN and back, accumulating wp-login
// hits against its own address until the threshold trips, and every request
// from that network, its own health probes included, then gets 403 Access
// denied. A blocker that can lock out its own host is a worse outage
// than the probing it was defending against.
if ( class_exists( 'CSDT_Auto_Block' ) && CSDT_Auto_Block::is_exempt( $ip ) ) {
return;
}
if ( in_array( $ip, self::own_public_ips(), true ) ) {
return;
}
$blocklist = get_option( 'csdt_ip_blocklist', array() );
if ( ! is_array( $blocklist ) || isset( $blocklist[ $ip ] ) ) {
return; }
$seven_day_total = array_sum( $stats['ip_stats'][ $ip ]['days'] ?? array() );
if ( $seven_day_total >= $threshold ) {
// Written through CSDT_Auto_Block::add_entry() since 1.10.432, so this
// block carries an expiry and escalates on the same ladder as every
// other automatic block. It previously wrote its own shape with no
// expires_at at all, which made it permanent, the 79 immortal
// "wp-login probe" entries found on the live site came from an earlier
// version of exactly this code.
// Captured, not recomputed. next_auto_ttl() was called inline here and its
// value thrown away, so the alert could say what triggered the block but never
// how long it lasted, the only way to find out was to open the admin panel.
$probe_ttl = CSDT_Auto_Block::next_auto_ttl( $ip );
if ( ! class_exists( 'CSDT_Auto_Block' ) || ! CSDT_Auto_Block::add_entry(
$ip,
sprintf( 'Auto-blocked: %d wp-login.php probes in 7 days', $seven_day_total ),
CSDT_Auto_Block::SOURCE_AUTO,
$probe_ttl,
'probe'
) ) {
return;
}
$probe_offences = get_option( CSDT_Auto_Block::OPTION_OFFENCES, array() );
$probe_offence = is_array( $probe_offences ) ? (int) ( $probe_offences[ $ip ] ?? 1 ) : 1;
$probe_body = CSDT_Auto_Block::describe_block(
$ip,
sprintf(
/* translators: %d: number of probes. */
_n( '%d wp-login.php probe', '%d wp-login.php probes', $seven_day_total, 'cloudscale-cyber-devtools' ),
$seven_day_total
),
__( '7 days', 'cloudscale-cyber-devtools' ),
$probe_ttl,
$probe_offence
);
// The admin log keeps recent blocks, and, since 1.10.513, cannot let them
// crowd out other kinds of security event. It used to be plain FIFO over 20
// slots, which this path alone filled completely; see
// record_security_event() for what that hid.
self::record_security_event(
'attack',
"Probe IP auto-blocked: {$ip}",
$probe_body
);
// The ALERT goes through the shared bundler rather than straight out. This is the
// path that was noisy: ordinary background scanning produced a stream of
// near-identical WARNING messages, each about a different address doing exactly
// the same thing. Bundling only CSDT_Auto_Block's own alerts would have left this
// half of the noise in place, because it sends through a different function.
CSDT_Auto_Block::queue_alert(
array(
'ip' => $ip,
'trigger' => sprintf(
/* translators: %d: number of probes. */
_n( '%d wp-login.php probe', '%d wp-login.php probes', $seven_day_total, 'cloudscale-cyber-devtools' ),
$seven_day_total
),
'window' => __( '7 days', 'cloudscale-cyber-devtools' ),
'ttl' => $probe_ttl,
'offence' => $probe_offence,
)
);
}
}
/**
* This site's own public IP(s), delegated to CSDT_Auto_Block, which owns the
* "never block this" rules. Kept as a thin wrapper so both blockers and the
* enforcement path below agree on one answer.
*
* @return string[]
*/
public static function own_public_ips(): array {
if ( class_exists( 'CSDT_Auto_Block' ) && method_exists( 'CSDT_Auto_Block', 'own_public_ips' ) ) {
return CSDT_Auto_Block::own_public_ips();
}
$cached = get_option( 'csdt_own_public_ips', array() );
return ( isset( $cached['ips'] ) && is_array( $cached['ips'] ) ) ? $cached['ips'] : array();
}
public static function login_block_direct_access(): void {
try {
if ( CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) !== '1' ) {
return;
}
$slug = CSDT_Options::get( 'csdt_devtools_login_slug' );
if ( empty( $slug ) ) {
return;
}
// Allow through: arrived via the correct custom slug.
if ( defined( 'CSDT_LOGIN_CUSTOM_SLUG' ) && CSDT_LOGIN_CUSTOM_SLUG ) {
return;
}
// Allow through: WP-CLI, cron, XMLRPC, and REST don't use the browser login form.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
return;
}
if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
return;
}
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
return;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
// Allow through: safe wp-login.php actions (password reset emails, logout, postpass).
$action = self::requested_login_action( 'login' );
$safe = array( 'logout', 'lostpassword', 'rp', 'resetpass', 'postpass', 'register', 'csdt_devtools_2fa' );
if ( in_array( $action, $safe, true ) ) {
return;
}
// Record this blocked hit for the BF panel stats.
$ip = CSDT_DevTools::get_client_ip();
self::record_default_login_probe( $ip );
// Block, redirect direct /wp-login.php access to home.
wp_safe_redirect( home_url( '/' ) );
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Replaces wp_login_url() return value with the custom slug URL when enabled.
*
* @since 1.9.4
* @param string $url
* @param string $redirect
* @param bool $force_reauth
* @return string
*/
/**
* Intercepts unauthenticated wp-admin requests when Hide Login is enabled.
* Renders a branded 403 page so the custom login slug is never revealed via redirect.
*/
public static function login_admin_intercept(): void {
try {
if ( CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) !== '1' ) {
return;
}
if ( defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) ) {
return;
}
// WP-CLI (deploy health checks, cron-less admin_init smoke tests, etc.)
// doesn't use the browser login form and already requires server access.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
return;
}
// Also intercept /wp-login and /wp-login/ (without .php, WordPress routes
// these through index.php as a 404 rather than firing login_init).
$req_path = rtrim( (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ), PHP_URL_PATH ), '/' );
$home_path = rtrim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' );
$is_wp_login = ( $req_path === $home_path . '/wp-login' );
if ( ! is_admin() && ! $is_wp_login ) {
return;
}
if ( is_user_logged_in() ) {
return;
}
$ip = CSDT_DevTools::get_client_ip();
$site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
// Record in the shared wplogin blocked stats (same store as wp-login.php probes).
self::record_default_login_probe( $ip );
status_header( 403 );
header( 'Content-Type: text/html; charset=utf-8' );
echo '
Access Protected
';
// Page exits before wp_head(), so the stylesheet link is printed
// directly through the enqueue API rather than a literal tag.
wp_register_style( 'csdt-403', plugin_dir_url( __DIR__ ) . 'assets/cs-403.css', array(), CSDT_DevTools::VERSION );
wp_print_styles( 'csdt-403' );
echo '
Access Protected
Admin access is restricted
' . esc_html( $site_name ) . '
' . wp_kses_post( self::credit_block() ) . '
⚠ This access attempt has been logged. Your IP address (' . esc_html( $ip ) . ') is now being tracked.
';
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
public static function login_custom_url( string $url, string $redirect, bool $force_reauth ): string {
try {
if ( CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) !== '1' ) {
return $url;
}
$slug = CSDT_Options::get( 'csdt_devtools_login_slug' );
if ( empty( $slug ) ) {
return $url;
}
// If WordPress is trying to redirect to the login page because someone hit /wp-admin/
// while unauthenticated, send them to the home page instead, never reveal the slug.
if ( ! empty( $redirect ) && strpos( $redirect, '/wp-admin' ) !== false && ! is_user_logged_in() ) {
return home_url( '/' );
}
// Generalises the rule above to every caller. This closed the last
// disclosure vector: the "Log in" back-link that wp-login.php's
// lost-password screen renders from wp_login_url(), which put the slug
// into a page any anonymous visitor could fetch.
if ( ! self::may_reveal_login_slug() ) {
return $url;
}
$custom = home_url( '/' . $slug . '/' );
if ( ! empty( $redirect ) ) {
$custom = add_query_arg( 'redirect_to', rawurlencode( $redirect ), $custom );
}
if ( $force_reauth ) {
$custom = add_query_arg( 'reauth', '1', $custom );
}
return $custom;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $url;
}
}
/**
* May the secret login slug be written into this response?
*
* Only when the visitor already legitimately knows it: they either arrived
* ON the slug, or they are logged in (and can therefore reach wp-admin
* anyway). For anyone else the answer is no.
*
* This exists because of a real, verified disclosure. wp-login.php must stay
* reachable for logout, lost-password and password-reset links, so those
* actions are allow-listed in login_block_default(). But WordPress renders
* those pages using wp_lostpassword_url() / wp_logout_url(), which these
* filters rewrite to the slug, so an unauthenticated
* `GET /wp-login.php?action=lostpassword` printed the slug straight into the
* form action and handed the "hidden" login URL to anyone who asked. The
* plugin's own active scanner caught it (nuclei CVE-2024-2473, the same
* bypass class as WPS Hide Login), confirmed on production.
*
* Suppressing the rewrite cannot lock anyone out: the un-rewritten URLs point
* at wp-login.php, and those exact actions are already allowed through.
*
* @since 1.10.364
*/
private static function may_reveal_login_slug(): bool {
if ( defined( 'CSDT_LOGIN_CUSTOM_SLUG' ) && CSDT_LOGIN_CUSTOM_SLUG ) {
return true;
}
// site_url() can fire before pluggable.php is loaded, so this must never
// be the thing that fatals during bootstrap. No user context yet also
// means no reason to emit the slug.
if ( ! function_exists( 'is_user_logged_in' ) || ! did_action( 'plugins_loaded' ) ) {
return false;
}
return is_user_logged_in();
}
/**
* Replaces the logout URL when Hide Login is enabled.
*
* @since 1.9.4
* @param string $url
* @param string $redirect
* @return string
*/
public static function login_custom_logout_url( string $url, string $redirect ): string {
try {
if ( CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) !== '1' ) {
return $url;
}
if ( ! self::may_reveal_login_slug() ) {
return $url;
}
$slug = CSDT_Options::get( 'csdt_devtools_login_slug' );
if ( empty( $slug ) ) {
return $url;
}
$nonce = wp_create_nonce( 'log-out' );
$custom = home_url( '/' . $slug . '/?action=logout&_wpnonce=' . $nonce );
if ( ! empty( $redirect ) ) {
$custom = add_query_arg( 'redirect_to', rawurlencode( $redirect ), $custom );
}
return $custom;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $url;
}
}
/**
* Replaces the lost-password URL when Hide Login is enabled.
*
* @since 1.9.4
* @param string $url
* @param string $redirect
* @return string
*/
public static function login_custom_lostpassword_url( string $url, string $redirect ): string {
try {
if ( CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) !== '1' ) {
return $url;
}
// See may_reveal_login_slug(): an anonymous hit on the public
// wp-login.php?action=lostpassword used to print the slug here.
if ( ! self::may_reveal_login_slug() ) {
return $url;
}
$slug = CSDT_Options::get( 'csdt_devtools_login_slug' );
if ( empty( $slug ) ) {
return $url;
}
$custom = home_url( '/' . $slug . '/?action=lostpassword' );
if ( ! empty( $redirect ) ) {
$custom = add_query_arg( 'redirect_to', rawurlencode( $redirect ), $custom );
}
return $custom;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $url;
}
}
/**
* Rewrites network_site_url() calls that reference wp-login.php.
*
* @since 1.9.4
* @param string $url
* @param string $path
* @param string $scheme
* @return string
*/
public static function login_custom_network_url( string $url, string $path, ?string $scheme ): string {
try {
return self::login_rewrite_login_url( $url, $path );
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $url;
}
}
/**
* Rewrites site_url() calls that reference wp-login.php.
*
* @since 1.9.4
* @param string $url
* @param string $path
* @param string $scheme
* @param int $blog_id
* @return string
*/
public static function login_custom_site_url( string $url, string $path, ?string $scheme, $blog_id ): string {
try {
return self::login_rewrite_login_url( $url, $path );
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $url;
}
}
/**
* Helper: replaces wp-login.php in a URL with the custom slug.
*
* @since 1.9.4
* @param string $url
* @param string $path
* @return string
*/
private static function login_rewrite_login_url( string $url, string $path ): string {
if ( CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ) !== '1' ) {
return $url;
}
// THE disclosure fix. wp_lostpassword_url() and wp_logout_url() are built
// from network_site_url()/site_url(), so this helper, not the
// lostpassword_url/logout_url filters, is where the slug actually got
// written into pages served by the public wp-login.php. Guarding here
// covers every caller at once. See may_reveal_login_slug().
if ( ! self::may_reveal_login_slug() ) {
return $url;
}
$slug = CSDT_Options::get( 'csdt_devtools_login_slug' );
if ( empty( $slug ) || strpos( $path, 'wp-login.php' ) === false ) {
return $url;
}
return str_replace( 'wp-login.php', $slug . '/', $url );
}
// ── B. Two-Factor Authentication ─────────────────────────────────────
/**
* The "Remember Me" choice of the sign-on in progress, as wp_signon() resolved it.
*
* @var bool
*/
private static $signon_remember = false;
/**
* Records wp_signon()'s own "remember" decision for login_2fa_intercept().
*
* Hooked to `secure_signon_cookie`, which core applies with the resolved credentials
* BEFORE it authenticates, so the two-factor step can carry the choice through without
* reading the login form's POST body itself. It also honours callers that pass their own
* credentials to wp_signon() rather than posting core's form. Returns the value unchanged.
*
* @since 1.10.882
* @param bool $secure_cookie Whether the auth cookie should be secure.
* @param array $credentials The credentials wp_signon() is about to use.
* @return bool
*/
public static function capture_signon_remember( $secure_cookie, $credentials ) {
self::$signon_remember = is_array( $credentials ) && ! empty( $credentials['remember'] );
return $secure_cookie;
}
/**
* Intercepts successful authentication and triggers 2FA when required.
* Hooked to `authenticate` at priority 100 (after core password check at 20).
*
* @since 1.9.4
* @param \WP_User|\WP_Error|null $user
* @param string $username
* @param string $password
* @return \WP_User|\WP_Error|null
*/
public static function login_2fa_intercept( $user, string $username, string $password ) {
try {
// Only act on a successfully authenticated user.
if ( ! ( $user instanceof \WP_User ) ) {
return $user;
}
$method = self::login_2fa_method_for_user( $user );
if ( $method === 'off' ) {
return $user;
}
// Grace logins: allow up to N logins without 2FA being set up.
// Useful for automated test accounts or newly invited users.
$grace_limit = (int) CSDT_Options::get( 'csdt_devtools_2fa_grace_logins' );
if ( $grace_limit > 0 ) {
$grace_count = (int) get_user_meta( $user->ID, 'csdt_devtools_2fa_grace_count', true );
if ( $grace_count < $grace_limit ) {
update_user_meta( $user->ID, 'csdt_devtools_2fa_grace_count', $grace_count + 1 );
return $user; // Skip 2FA, grace login consumed.
}
}
// Avoid triggering 2FA during a 2FA verification POST itself.
$action = self::requested_login_action( '' );
if ( $action === 'csdt_devtools_2fa' ) {
return $user;
}
// Build the list of methods this user actually has available.
$available = self::login_2fa_available_methods( $user );
// Subset that the user has explicitly configured (vs email which is always a fallback).
$setup = self::login_2fa_setup_methods( $user );
// If multiple methods are registered, show a picker rather than auto-routing.
$initial_method = count( $available ) === 1 ? $available[0] : 'picker';
// Generate a short-lived pending token.
$token = wp_generate_password( 32, false, false );
$data = array(
'user_id' => $user->ID,
'method' => $initial_method,
'available' => $available,
'setup' => $setup,
'created' => time(),
'remember' => self::$signon_remember,
);
if ( $initial_method === 'email' ) {
// Generate + store OTP.
$otp = str_pad( (string) wp_rand( 0, 999999 ), 6, '0', STR_PAD_LEFT );
set_transient( CSDT_DevTools::LOGIN_OTP_TRANSIENT . $user->ID, wp_hash( $otp ), 600 );
// Send it.
$site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
$to = $user->user_email;
$subj = sprintf( '[%s] Your login code', $site );
$body = self::email_html_otp( $user->display_name, $site, $otp );
add_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
wp_mail( $to, $subj, $body );
remove_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
}
set_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token, $data, 600 );
// Redirect to the 2FA form.
$login_url = add_query_arg(
array(
'action' => 'csdt_devtools_2fa',
'csdt_devtools_token' => rawurlencode( $token ),
),
wp_login_url()
);
wp_safe_redirect( $login_url );
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
// Fail CLOSED. This used to return $user, the WP_User that has passed the
// PASSWORD only, so any throw on the way to the second factor (corrupt user
// meta, a transient or mail failure) completed the login without it. Failing
// open suits a rate limiter; it is a bypass for an authentication factor.
// A persistent fault here locks accounts out until it is fixed, which is the
// right way round; the logged exception says what to fix.
return new \WP_Error(
'csdt_2fa_unavailable',
__( 'The two-factor check could not be completed. Please try again; if this keeps happening, contact the site administrator.', 'cloudscale-cyber-devtools' )
);
}
}
/**
* Fired on `login_init`. Handles the 2FA code entry form: display and verification.
*
* @since 1.9.4
* @return void
*/
public static function login_2fa_handle(): void {
try {
$action = self::requested_login_action( '' );
if ( $action !== 'csdt_devtools_2fa' ) {
return;
}
$token = isset( $_REQUEST['csdt_devtools_token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['csdt_devtools_token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$pending = $token ? get_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token ) : false;
// Invalid or expired token → back to login.
if ( ! $pending || empty( $pending['user_id'] ) ) {
wp_safe_redirect( wp_login_url() );
exit;
}
$user_id = (int) $pending['user_id'];
$method = $pending['method'];
$available = isset( $pending['available'] ) && is_array( $pending['available'] ) ? $pending['available'] : array( $method );
$setup = isset( $pending['setup'] ) && is_array( $pending['setup'] ) ? $pending['setup'] : $available;
$error = '';
// ── Back-to-picker from any challenge screen ─────────────────────────
if ( isset( $_GET['csdt_devtools_back_to_picker'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// Always recompute available/setup fresh so stale transients
// without those keys still show the full method list.
$u = get_user_by( 'id', $user_id );
if ( $u instanceof \WP_User ) {
$pending['available'] = self::login_2fa_available_methods( $u );
$pending['setup'] = self::login_2fa_setup_methods( $u );
$available = $pending['available'];
$setup = $pending['setup'];
}
$pending['method'] = 'picker';
set_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token, $pending, 600 );
$url = add_query_arg(
array(
'action' => 'csdt_devtools_2fa',
'csdt_devtools_token' => rawurlencode( $token ),
),
wp_login_url()
);
wp_safe_redirect( $url );
exit;
}
// ── Method picker ────────────────────────────────────────────────────
// When multiple methods are registered, show a selection screen before
// the challenge so the user can choose how they want to authenticate.
if ( $method === 'picker' ) {
if ( isset( $_POST['csdt_devtools_method_choice'] ) ) {
$choice = sanitize_key( wp_unslash( $_POST['csdt_devtools_method_choice'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( in_array( $choice, $available, true ) ) {
$pending['method'] = $choice;
// Send email OTP now that the user chose email.
if ( $choice === 'email' ) {
$rate_key = 'csdt_devtools_pk_fb_' . $user_id;
$already_sent = get_transient( $rate_key );
if ( ! $already_sent ) {
$u = get_user_by( 'id', $user_id );
if ( $u instanceof \WP_User ) {
$otp = str_pad( (string) wp_rand( 0, 999999 ), 6, '0', STR_PAD_LEFT );
set_transient( CSDT_DevTools::LOGIN_OTP_TRANSIENT . $user_id, wp_hash( $otp ), 600 );
set_transient( $rate_key, 1, 30 );
$site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
add_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
wp_mail( $u->user_email, sprintf( '[%s] Your login code', $site ), self::email_html_otp( $u->display_name, $site, $otp ) );
remove_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
}
}
}
set_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token, $pending, 600 );
$url = add_query_arg(
array(
'action' => 'csdt_devtools_2fa',
'csdt_devtools_token' => rawurlencode( $token ),
),
wp_login_url()
);
wp_safe_redirect( $url );
exit;
}
}
self::login_2fa_render_picker( $token, $available, $setup );
exit;
}
// ── Passkey → email fallback ─────────────────────────────────────────
if ( $method === 'passkey' && ! empty( $_POST['csdt_devtools_pk_fallback'] ) ) {
// Only send a new OTP if one hasn't been sent in the last 30 seconds (prevents spam from double-clicks).
$rate_key = 'csdt_devtools_pk_fb_' . $user_id;
$already_sent = get_transient( $rate_key );
if ( ! $already_sent ) {
$user = get_user_by( 'id', $user_id );
if ( $user instanceof \WP_User ) {
$otp = str_pad( (string) wp_rand( 0, 999999 ), 6, '0', STR_PAD_LEFT );
set_transient( CSDT_DevTools::LOGIN_OTP_TRANSIENT . $user_id, wp_hash( $otp ), 600 );
set_transient( $rate_key, 1, 30 ); // block re-sends for 30s
$site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
add_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
wp_mail( $user->user_email, sprintf( '[%s] Your login code', $site ), self::email_html_otp( $user->display_name, $site, $otp ) );
remove_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
}
}
// Update the transient to use email method.
$pending['method'] = 'email';
set_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token, $pending, 600 );
$method = 'email';
}
// ── Passkey assertion (POST from cs-passkey login page) ──────────────
if ( $method === 'passkey' && isset( $_POST['csdt_devtools_pk_cred_id'] ) ) {
$result = CSDT_DevTools_Passkey::verify_login_assertion( $token, $user_id );
if ( $result === true ) {
delete_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token );
wp_set_auth_cookie( $user_id, self::login_should_remember( $pending ) );
$redirect = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url();
// Hooked on login_init, no output has started, so wp_safe_redirect() works correctly.
nocache_headers();
wp_safe_redirect( $redirect );
exit;
}
// Verification failed, re-render challenge with error.
$error = CSDT_Error_Text::in_seconds( $result->get_error_message() );
}
// ── Passkey challenge page (GET or re-render after failure) ──────────
if ( $method === 'passkey' && empty( $_POST['csdt_devtools_2fa_code'] ) ) {
CSDT_DevTools_Passkey::render_login_challenge( $token, $user_id, $error, $available );
// render_login_challenge() exits.
}
// Handle code submission.
if ( isset( $_POST['csdt_devtools_2fa_code'] ) ) {
if ( ! isset( $_POST['csdt_devtools_2fa_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['csdt_devtools_2fa_nonce'] ) ), 'csdt_devtools_2fa_verify_' . $token ) ) {
$error = __( 'Security check failed. Please try again.', 'cloudscale-cyber-devtools' );
} else {
$code = preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_POST['csdt_devtools_2fa_code'] ) ) );
$user = get_user_by( 'id', $user_id );
$valid = false;
if ( $user instanceof \WP_User ) {
if ( $method === 'email' ) {
$stored = get_transient( CSDT_DevTools::LOGIN_OTP_TRANSIENT . $user_id );
if ( $stored && hash_equals( $stored, wp_hash( $code ) ) ) {
$valid = true;
delete_transient( CSDT_DevTools::LOGIN_OTP_TRANSIENT . $user_id );
}
} elseif ( $method === 'totp' ) {
$secret = get_user_meta( $user_id, 'csdt_devtools_totp_secret', true );
if ( $secret ) {
$valid = self::totp_verify( (string) $secret, $code );
}
}
}
if ( $valid ) {
delete_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token );
// Complete the login.
wp_set_auth_cookie( $user_id, self::login_should_remember( $pending ) );
$redirect = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url();
wp_safe_redirect( $redirect );
exit;
} else {
// Brute-force counter on the 2FA stage. Without this, an
// attacker who has the password can keep submitting 6-digit
// codes against a single transient (≈10 min TTL) at request
// rate. After 5 fails: invalidate the transient (forcing
// the attacker back through the password stage) and fire
// wp_login_failed so the IP-based brute-force tracker
// counts it just like a password failure.
$pending['2fa_fail_count'] = (int) ( $pending['2fa_fail_count'] ?? 0 ) + 1;
if ( $pending['2fa_fail_count'] >= 5 ) {
delete_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token );
$username = '';
if ( $user instanceof \WP_User ) {
$username = $user->user_login;
}
// This plugin's own failed-login handlers, called directly and in the order
// wp_login_failed runs them, rather than firing core's hook from a plugin.
self::record_failed_login( $username );
self::on_login_failed( $username );
CSDT_Activity_Log::on_login_failed( $username );
CSDT_Auto_Block::on_login_failed();
$error = __( 'Too many invalid codes. Please log in again.', 'cloudscale-cyber-devtools' );
} else {
set_transient( CSDT_DevTools::LOGIN_2FA_TRANSIENT . $token, $pending, 600 );
$remaining = 5 - $pending['2fa_fail_count'];
/* translators: %d = attempts remaining before lockout. */
$error = sprintf( __( 'Invalid code. %d attempts remaining before you have to re-enter your password.', 'cloudscale-cyber-devtools' ), $remaining );
}
}
}
}
// Render the 2FA form.
self::login_2fa_render_form( $token, $method, $error, $user_id, count( $available ) > 1 );
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Outputs the 2FA code entry page using WordPress's own login styles.
*
* @since 1.9.4
* @param string $token Pending auth token.
* @param string $method 'email' or 'totp'.
* @param string $error Optional error message.
* @return void
*/
private static function login_2fa_render_form( string $token, string $method, string $error = '', int $user_id = 0, bool $has_picker = false ): void {
login_header( __( 'Two-Factor Authentication', 'cloudscale-cyber-devtools' ), '', null );
$nonce = wp_create_nonce( 'csdt_devtools_2fa_verify_' . $token );
if ( $method === 'email' && $user_id ) {
$u = get_user_by( 'id', $user_id );
$email = $u instanceof \WP_User ? $u->user_email : '';
if ( $email ) {
[ $local, $domain ] = explode( '@', $email, 2 );
$masked = ( strlen( $local ) > 2
? substr( $local, 0, 1 ) . str_repeat( '*', strlen( $local ) - 2 ) . substr( $local, -1 )
: substr( $local, 0, 1 ) . '***' )
. '@' . $domain;
$method_txt = sprintf(
/* translators: %s = masked email address */
__( 'Enter the 6-digit code sent to %s', 'cloudscale-cyber-devtools' ),
$masked
);
} else {
$method_txt = __( 'Enter the 6-digit code sent to your email address.', 'cloudscale-cyber-devtools' );
}
} else {
$method_txt = __( 'Enter the 6-digit code from your authenticator app.', 'cloudscale-cyber-devtools' );
}
$icon = $method === 'email' ? '📧' : '📱';
$picker_url = $has_picker
? add_query_arg(
array(
'action' => 'csdt_devtools_2fa',
'csdt_devtools_token' => rawurlencode( $token ),
'csdt_devtools_back_to_picker' => '1',
),
wp_login_url()
)
: '';
?>
ID ) ) ) {
return 'passkey';
}
// If force is on and user is admin, enforce the site method.
if ( $force && user_can( $user, 'manage_options' ) && $site_method !== 'off' ) {
// If TOTP forced but user hasn't set it up, fall back to email.
if ( $site_method === 'totp' && get_user_meta( $user->ID, 'csdt_devtools_totp_enabled', true ) !== '1' ) {
return 'email';
}
return $site_method;
}
// Per-user TOTP.
if ( get_user_meta( $user->ID, 'csdt_devtools_totp_enabled', true ) === '1' ) {
return 'totp';
}
// Per-user email 2FA.
if ( get_user_meta( $user->ID, 'csdt_devtools_2fa_email_enabled', true ) === '1' ) {
return 'email';
}
// Fall back to site-wide default.
if ( $site_method !== 'off' ) {
if ( $site_method === 'passkey' ) {
return 'email'; // no passkeys registered, fall back to email
}
// TOTP as site default only applies if user has it set up.
if ( $site_method === 'totp' ) {
return 'email'; // safe fallback
}
return $site_method;
}
// Required by role (CSDT_Password_Policy). Reached only when nothing above applied, so
// the forced-administrator branch and every enrolled method keep their precedence; an
// account in a listed role that has enrolled nothing gets the emailed code.
if ( class_exists( 'CSDT_Password_Policy' ) && CSDT_Password_Policy::role_requires_2fa( $user ) ) {
return 'email';
}
return 'off';
}
/**
* Returns every 2FA method that is actually available for the given user.
* Used to decide whether to show a method picker (multiple) or go straight
* to the challenge (single).
*
* @param \WP_User $user
* @return string[] Non-empty array from: 'passkey', 'totp', 'email'
*/
private static function login_2fa_available_methods( \WP_User $user ): array {
$methods = array();
if ( ! empty( CSDT_DevTools_Passkey::get_passkeys( $user->ID ) ) ) {
$methods[] = 'passkey';
}
if ( get_user_meta( $user->ID, 'csdt_devtools_totp_enabled', true ) === '1' ) {
$methods[] = 'totp';
}
// Email is always a valid fallback when 2FA is required for this user.
$methods[] = 'email';
return $methods;
}
/**
* Returns the subset of methods the user has explicitly configured.
* Used to show the "Setup" badge in the picker, passkey and TOTP are
* only here when actively registered; email only if explicitly enabled.
*
* @param \WP_User $user
* @return string[]
*/
private static function login_2fa_setup_methods( \WP_User $user ): array {
$setup = array();
if ( ! empty( CSDT_DevTools_Passkey::get_passkeys( $user->ID ) ) ) {
$setup[] = 'passkey';
}
if ( get_user_meta( $user->ID, 'csdt_devtools_totp_enabled', true ) === '1' ) {
$setup[] = 'totp';
}
if ( get_user_meta( $user->ID, 'csdt_devtools_2fa_email_enabled', true ) === '1' ) {
$setup[] = 'email';
}
return $setup;
}
/**
* Renders the method-selection screen when a user has multiple 2FA options.
*
* @param string $token 2FA transient token.
* @param string[] $available Methods available for this user.
* @return void
*/
private static function login_2fa_render_picker( string $token, array $available, array $setup = array() ): void {
login_header( __( 'Two-Factor Authentication', 'cloudscale-cyber-devtools' ), '', null );
$labels = array(
'passkey' => array(
'icon' => '🔑',
'label' => __( 'Use a Passkey', 'cloudscale-cyber-devtools' ),
'hint' => __( 'Biometric or device PIN', 'cloudscale-cyber-devtools' ),
),
'totp' => array(
'icon' => '📱',
'label' => __( 'Google Authenticator', 'cloudscale-cyber-devtools' ),
'hint' => __( 'Code from your authenticator app', 'cloudscale-cyber-devtools' ),
),
'email' => array(
'icon' => '📧',
'label' => __( 'Send me an email code', 'cloudscale-cyber-devtools' ),
'hint' => __( 'One-time code sent to your email', 'cloudscale-cyber-devtools' ),
),
);
?>
= 8 ) {
$bits -= 8;
$output .= chr( ( $buffer >> $bits ) & 0xFF );
}
}
return $output;
}
/**
* Computes a 6-digit HOTP code for the given key and counter (RFC 4226 / 6238).
*
* @since 1.9.4
* @param string $secret_b32 Base32-encoded shared secret.
* @param int $counter TOTP counter value (floor(unix_time / 30)).
* @return string Zero-padded 6-digit string.
*/
private static function totp_compute( string $secret_b32, int $counter ): string {
$key = self::base32_decode( $secret_b32 );
// Pack counter as 8-byte big-endian integer.
$counter_bytes = pack( 'N*', 0 ) . pack( 'N*', $counter );
$hmac = hash_hmac( 'sha1', $counter_bytes, $key, true );
$offset = ord( $hmac[19] ) & 0x0F;
$code = (
( ( ord( $hmac[ $offset ] ) & 0x7F ) << 24 ) |
( ( ord( $hmac[ $offset + 1 ] ) & 0xFF ) << 16 ) |
( ( ord( $hmac[ $offset + 2 ] ) & 0xFF ) << 8 ) |
( ord( $hmac[ $offset + 3 ] ) & 0xFF )
) % 1000000;
return str_pad( (string) $code, 6, '0', STR_PAD_LEFT );
}
/**
* Verifies a TOTP code against the secret, allowing ±1 time-step for clock drift.
*
* @since 1.9.4
* @param string $secret_b32 Base32-encoded shared secret.
* @param string $code 6-digit code to verify.
* @return bool
*/
private static function totp_verify( string $secret_b32, string $code ): bool {
$counter = (int) floor( time() / 30 );
for ( $offset = -1; $offset <= 1; $offset++ ) {
if ( hash_equals( self::totp_compute( $secret_b32, $counter + $offset ), $code ) ) {
return true;
}
}
return false;
}
/**
* Builds the otpauth:// URI used to provision authenticator apps via QR code.
*
* @since 1.9.4
* @param string $secret_b32 Base32-encoded secret.
* @param string $user_email User email shown in the app.
* @return string Full otpauth:// URI.
*/
private static function totp_provisioning_uri( string $secret_b32, string $user_email ): string {
$issuer = rawurlencode( get_bloginfo( 'name' ) );
$account = rawurlencode( $user_email );
return 'otpauth://totp/' . $issuer . ':' . $account
. '?secret=' . rawurlencode( $secret_b32 )
. '&issuer=' . $issuer
. '&algorithm=SHA1&digits=6&period=30';
}
// ── D. AJAX handlers ─────────────────────────────────────────────────
/**
* AJAX: returns the 14-day failed login log for the brute-force panel.
*/
public static function ajax_bf_log_fetch(): void {
check_ajax_referer( CSDT_DevTools::LOGIN_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Forbidden', 403 );
}
try {
$log = get_option( 'csdt_devtools_bf_log', array() );
if ( ! is_array( $log ) ) {
$log = array();
}
$cutoff = time() - 14 * DAY_IN_SECONDS;
$log = array_values( array_filter( $log, fn( $e ) => isset( $e[0] ) && $e[0] >= $cutoff ) );
// UTC midnight, consistent with the UTC time() timestamps stored in $log
// (mktime() would use the server-local timezone and skew "today").
$today_start = (int) ( floor( time() / DAY_IN_SECONDS ) * DAY_IN_SECONDS );
$today_count = count( array_filter( $log, fn( $e ) => $e[0] >= $today_start ) );
// Aggregate per-country failed login counts for the map.
$cc_stats_raw = get_option( 'csdt_bf_country_stats', array() );
$countries_bf = array();
if ( is_array( $cc_stats_raw ) ) {
foreach ( $cc_stats_raw as $cc => $days ) {
if ( is_array( $days ) ) {
$countries_bf[ $cc ] = array_sum( $days );
}
}
}
$blocklist = get_option( 'csdt_ip_blocklist', array() );
$blocked_ips = is_array( $blocklist ) ? array_keys( $blocklist ) : array();
// API attack log, from csdt_security_events, type=api_attack, last 14 days.
$sec_events = get_option( 'csdt_security_events', array() );
$api_log = array();
$countries_api = array();
if ( is_array( $sec_events ) ) {
foreach ( $sec_events as $ev ) {
// Both api_attack and rest_fail are "API attacks" for map/table purposes.
if ( ! in_array( $ev['type'] ?? '', array( 'api_attack', 'rest_fail' ), true ) ) {
continue; }
if ( ( $ev['time'] ?? 0 ) < $cutoff ) {
continue; }
// Parse IP from detail string "IP: x.x.x.x".
$ip = '';
$cc = '';
if ( preg_match( '/IP:\s*([\d.a-fA-F:]+)/', $ev['detail'] ?? '', $m ) ) {
$ip = $m[1];
}
// Country stored in detail as "· CC", use it directly to avoid geo lookup overhead.
if ( preg_match( '/·\s*([A-Z]{2})\s*$/', $ev['detail'] ?? '', $mc ) ) {
$cc = $mc[1];
} elseif ( $ip ) {
$cc = CSDT_Geo::get_country( $ip );
}
$api_log[] = array( $ev['time'], $ev['title'], $ip, $cc );
if ( $cc ) {
$countries_api[ $cc ] = ( $countries_api[ $cc ] ?? 0 ) + 1;
}
}
}
wp_send_json_success(
array(
'log' => $log,
'now' => time(),
'today_count' => $today_count,
'countries_bf' => $countries_bf,
'blocked_ips' => $blocked_ips,
'api_log' => $api_log,
'countries_api' => $countries_api,
)
);
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
/**
* AJAX: saves Hide Login and 2FA site-wide settings.
*
* @since 1.9.4
* @return void
*/
// ── Changing a login control needs a browser and a fresh passkey ──────
//
// The controls on this screen are the ones that keep everyone else out: the 2FA method,
// forced 2FA for administrators, the hidden login page, brute-force limits. A stolen
// session cookie should not be enough to switch any of them off, and nothing that cannot
// perform a WebAuthn ceremony, WP-CLI, cron, REST, XML-RPC, another plugin calling
// update_option(), has any business changing them at all.
//
// So a write is allowed only when the user has completed a passkey assertion with user
// verification in the last STEPUP_WINDOW seconds, in this session. Two layers enforce it:
// ajax_login_save() asks first and returns stepup_required so the page can run the
// ceremony and retry, and gate_login_security_write() vetoes at pre_update_option, which
// catches every other path including the ones that cannot be asked.
//
// Two deliberate limits. A user with NO passkey registered is not locked out: the write
// proceeds from a browser and the change is recorded as having gone through without a
// step-up, because requiring a factor the account does not have would make the setting
// unchangeable, which is its own kind of outage. And defining
// CSDT_ALLOW_LOGIN_SETTINGS_WITHOUT_PASSKEY in wp-config.php switches the whole gate off:
// that needs filesystem access, which is already game over.
const STEPUP_WINDOW = 300;
const STEPUP_AT_META = 'csdt_devtools_stepup_at';
const STEPUP_TOKEN_META = 'csdt_devtools_stepup_token';
/**
* The settings a step-up protects. Named individually, not matched by prefix.
*
* A prefix looked tidier and was wrong: `csdt_devtools_(2fa|login|bf|brute)` also matches
* csdt_devtools_bf_log, which CSDT_Perf_Monitor writes on every failed login attempt, in
* requests with no logged-in user at all. Gating that would have stopped brute-force logging
* dead, the plugin would have gone quiet about attacks while looking healthy. These are
* policy switches; logs and counters are not.
*/
const GATED_OPTIONS = array(
'csdt_devtools_2fa_method',
'csdt_devtools_2fa_force_admins',
'csdt_devtools_2fa_grace_logins',
'csdt_devtools_login_hide_enabled',
'csdt_devtools_login_slug',
'csdt_devtools_brute_force_enabled',
'csdt_devtools_brute_force_attempts',
'csdt_devtools_brute_force_lockout',
'csdt_devtools_enum_protect',
'csdt_devtools_bf_auto_block_threshold',
'csdt_devtools_session_duration',
'csdt_wplogin_auto_block_threshold',
'csdt_honeypot_2fa_enabled',
// Which credential the test-session endpoint accepts. Switching it from 'token' back to
// 'secret' re-enables a password-in-a-file route to a WordPress session, which is
// exactly the class of change this gate exists for.
'csdt_test_session_auth',
// Which domains a passkey is valid on. Widening it from this host to a parent
// domain lets every other host under that domain prompt for the credential, so
// it belongs behind the same step-up as the 2FA method itself.
'csdt_devtools_passkey_rp_id',
// CSDT_Password_Policy. Removing a role from the 2FA list, or switching the breached-
// password check off, is a downgrade of exactly the kind this gate exists for.
'csdt_pwned_check_mode',
'csdt_password_min_length',
'csdt_2fa_required_roles',
);
/** Set while the plugin makes its own remediation writes. See internal_option_write(). */
private static $internal_write = false;
/** Whether this request already recorded a change made without a step-up. */
private static $no_passkey_noted = false;
/**
* Runs a callback whose option writes are the plugin's own, not a user's request.
*
* The Fix Recipes turn forced 2FA ON. That is a write to a gated option from a page load
* where no step-up has happened, so without this the plugin's own hardening advice would be
* silently vetoed by the plugin. Scoped as narrowly as a try/finally allows, so an exception
* cannot leave the gate open.
*
* @param callable $fn Work to run.
* @return mixed Whatever $fn returns.
*/
public static function internal_option_write( callable $fn ) {
self::$internal_write = true;
try {
return $fn();
} finally {
self::$internal_write = false;
}
}
public static function is_login_security_option( string $option ): bool {
return in_array( $option, self::GATED_OPTIONS, true );
}
/** Records a successful passkey step-up for the CURRENT session only. */
public static function mark_stepup( int $user_id ): void {
update_user_meta( $user_id, self::STEPUP_AT_META, time() );
// Bound to this session, so a stamp earned in one browser cannot authorise another.
update_user_meta( $user_id, self::STEPUP_TOKEN_META, self::current_session_token() );
}
/**
* A fingerprint of the current session, or '' when there is no session to read.
*
* The step-up stamp has to be tied to one browser session, so it needs something that
* identifies the session. It is never the session token itself: what is stored is its
* SHA-256, the same derivation core uses for the verifier it keeps in the session_tokens
* user meta, so nothing in this plugin's meta can be replayed as a cookie. The token is
* read through core's own accessor, wp_get_session_token(), not by parsing the cookie.
*
* Earlier versions stored the raw token. A stamp written by that code no longer
* matches, which costs one more passkey prompt inside a five minute window.
*/
private static function current_session_token(): string {
if ( ! function_exists( 'wp_get_session_token' ) ) {
return '';
}
$token = (string) wp_get_session_token();
return '' !== $token ? hash( 'sha256', $token ) : '';
}
/** Whether this session has a step-up inside the window. */
public static function stepup_fresh( int $user_id ): bool {
$at = (int) get_user_meta( $user_id, self::STEPUP_AT_META, true );
if ( $at <= 0 || ( time() - $at ) > self::STEPUP_WINDOW ) {
return false;
}
$token = (string) get_user_meta( $user_id, self::STEPUP_TOKEN_META, true );
$now = self::current_session_token();
// No token either side means a context with no cookie, which is not a browser.
return '' !== $token && '' !== $now && hash_equals( $token, $now );
}
/** True when the user could complete a ceremony at all. */
/**
* Whether this account has a passkey registered.
*
* THIS RETURNED FALSE FOR EVERYONE UNTIL 1.10.584, and two gates depended on it.
*
* CSDT_DevTools_Passkey stores credentials as `wp_json_encode( $creds )`, a JSON STRING in
* user meta. This function tested `is_array()` on that string, which is never true, so every
* account looked like it had no passkey however many it had registered:
*
* - stepup_blocker() treats "no passkey" as a pass-through, because it cannot require what
* an account does not have. So the passkey step-up on GATED_OPTIONS, the 2FA method, the
* force-admins flag, the hidden login slug, the test-session auth mode, never actually
* required verification for anybody. Each write was allowed and logged as 'stepup_absent'
* with the reassuring note "The account has no passkey registered", so the gate reported
* its own failure as normal operation.
* - mint_blocker() refuses a CI token without a passkey, so the token could never be minted
* by anyone. An administrator with two registered passkeys was told they had none,
* which is why the whole feature sat unused:
* the account was fine, the check was broken. Nobody could have completed that step.
*
* Found because the new setup wizard printed both answers next to each other, "2 registered"
* above a disabled button saying "Register a passkey for this account first". Two readers of
* one meta key that disagreed, which is why there is now only one.
*
* @param int $user_id Account to test.
* @return bool
*/
public static function user_has_passkey( int $user_id ): bool {
// The passkey class owns this meta and is the only thing that should decode it. Guarded
// because this file is loaded first; the fallback reads the same JSON shape rather than
// the array shape that never existed.
if ( class_exists( 'CSDT_DevTools_Passkey' ) ) {
return ! empty( CSDT_DevTools_Passkey::get_passkeys( $user_id ) );
}
$raw = get_user_meta( $user_id, 'csdt_devtools_passkeys', true );
$keys = is_string( $raw ) ? json_decode( $raw, true ) : $raw;
return is_array( $keys ) && ! empty( $keys );
}
/** A context that cannot run WebAuthn, and so may not change these settings. */
private static function non_browser_context(): bool {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
return true;
}
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
return true;
}
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
return true;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return true;
}
return false;
}
/**
* Why this write may not proceed, or '' when it may.
*
* @param int $user_id Acting user.
* @return string '' = allowed, otherwise a machine-readable reason.
*/
public static function stepup_blocker( int $user_id ): string {
if ( defined( 'CSDT_ALLOW_LOGIN_SETTINGS_WITHOUT_PASSKEY' ) && CSDT_ALLOW_LOGIN_SETTINGS_WITHOUT_PASSKEY ) {
return '';
}
if ( self::non_browser_context() ) {
return 'non_browser';
}
if ( ! $user_id ) {
return 'no_user';
}
if ( ! self::user_has_passkey( $user_id ) ) {
// Cannot require what the account does not have. Recorded rather than refused, once
// per request, so a save of several fields does not produce several entries.
if ( ! self::$no_passkey_noted ) {
self::$no_passkey_noted = true;
self::record_security_event(
'stepup_absent',
'A login security setting was changed without passkey verification.',
'The account has no passkey registered. Register one to require verification.'
);
}
return '';
}
return self::stepup_fresh( $user_id ) ? '' : 'stepup_required';
}
/**
* Backstop veto on every login-security write, whatever asked for it.
*
* Hooked at pre_update_option priority 3, after the scoped-test-role veto, so the two
* cannot mask each other. Returning $old_value makes update_option() a no-op.
*
* @param mixed $value Incoming value.
* @param string $option Option name.
* @param mixed $old_value Stored value.
* @return mixed
*/
public static function gate_login_security_write( $value, $option = '', $old_value = null ) {
if ( '' === (string) $option || ! self::is_login_security_option( (string) $option ) ) {
return $value;
}
// A write that changes nothing needs no ceremony, and blocking it would break the
// save of an unrelated field in the same section.
if ( $value === $old_value ) {
return $value;
}
// Creating the option for the first time is a default being installed, not a control
// being weakened. update_option() routes to add_option() through this same filter, so
// without this a fresh install could never write its own defaults.
if ( false === $old_value ) {
return $value;
}
// The plugin's own remediation, e.g. a Fix Recipe enabling forced 2FA.
if ( self::$internal_write ) {
return $value;
}
$user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0;
$blocker = self::stepup_blocker( $user_id );
if ( '' === $blocker ) {
return $value;
}
self::record_security_event(
'stepup_blocked',
sprintf( 'Refused a change to %s without a fresh passkey verification.', $option ),
'Reason: ' . $blocker
);
return $old_value;
}
public static function ajax_login_save(): void {
check_ajax_referer( CSDT_DevTools::LOGIN_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
// Ask for the passkey before writing anything, so the page can run the ceremony and
// retry rather than discovering half its fields were vetoed. gate_login_security_write()
// still guards the writes themselves, for every caller that cannot be asked.
$blocker = self::stepup_blocker( get_current_user_id() );
if ( 'stepup_required' === $blocker ) {
wp_send_json_error(
array(
'code' => 'stepup_required',
'message' => __( 'Verify with your passkey to change login security settings.', 'cloudscale-cyber-devtools' ),
),
403
);
}
if ( 'non_browser' === $blocker ) {
wp_send_json_error(
array(
'code' => 'non_browser',
'message' => __( 'Login security settings can only be changed from a browser.', 'cloudscale-cyber-devtools' ),
),
403
);
}
try {
// Snapshot current security state for downgrade detection.
$old_state = array(
'hide_enabled' => (string) CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ),
'bf_enabled' => (string) CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ),
'force_2fa' => (string) CSDT_Options::get( 'csdt_devtools_2fa_force_admins' ),
'tfa_method' => (string) CSDT_Options::get( 'csdt_devtools_2fa_method' ),
'enum_protect' => (string) CSDT_Options::get( 'csdt_devtools_enum_protect' ),
);
// EVERY field below is optional, and an absent field leaves its option ALONE.
//
// This handler rewrites the whole login-security section from one POST, and it used to
// read every field with `isset( $_POST[x] ) ? … : `, where the default for a
// security control is the OFF value. So any save that did not happen to carry the
// complete form silently switched off 2FA, forced-admin 2FA, the hidden login page,
// brute-force protection and enumeration protection together. On 2026-08-07 that fired
// as "Site-wide 2FA method has been set to OFF (was: totp)" from a request that had
// nothing to do with 2FA.
//
// An unsubmitted field is not a request to disable anything, so it is not treated as
// one. Enumerated fields that arrive with an unrecognised value (including an empty
// string, which sanitize_key turns into one) keep their stored value too, and the
// response names what was ignored rather than pretending it was applied.
$untouched = array();
// Hide Login
$hide = (string) $old_state['hide_enabled'];
if ( isset( $_POST['hide_enabled'] ) ) {
$hide = '1' === sanitize_text_field( wp_unslash( $_POST['hide_enabled'] ) ) ? '1' : '0';
update_option( 'csdt_devtools_login_hide_enabled', $hide );
} else {
$untouched[] = 'hide_enabled';
}
/*
* Passkey RP ID. Empty is a MEANINGFUL value here -- it clears the widening
* and returns this site to its own host -- so it is accepted, unlike the
* numeric fields where empty means "unchanged".
*
* A value this host is not part of is REFUSED rather than stored. Storing it
* would be worse than useless: rp_id() ignores a value that does not cover
* the host, so the screen would show a setting that does nothing, and the
* operator would believe passkeys were shared across sites that cannot share
* them. Validated with the passkey class's own rule, not a second copy.
*/
if ( isset( $_POST['passkey_rp_id'] ) ) {
$rp_in = strtolower( trim( sanitize_text_field( wp_unslash( $_POST['passkey_rp_id'] ) ) ) );
if ( ! class_exists( 'CSDT_DevTools_Passkey' ) ) {
// Nothing here can validate the value, and storing an unvalidated one
// is how a setting comes to read as applied while doing nothing.
$untouched[] = 'passkey_rp_id';
} elseif ( '' === $rp_in ) {
delete_option( 'csdt_devtools_passkey_rp_id' );
} elseif ( CSDT_DevTools_Passkey::rp_id_covers( $rp_in, CSDT_DevTools_Passkey::site_host() ) ) {
update_option( 'csdt_devtools_passkey_rp_id', $rp_in );
} else {
wp_send_json_error(
sprintf(
/* translators: 1: submitted domain, 2: this site's host */
__( '%1$s is not a domain this site is part of. The passkey domain must be %2$s or a parent of it, such as its registrable domain. Leave it blank to scope passkeys to this site alone.', 'cloudscale-cyber-devtools' ),
$rp_in,
CSDT_DevTools_Passkey::site_host()
)
);
}
} else {
$untouched[] = 'passkey_rp_id';
}
$slug = (string) CSDT_Options::get( 'csdt_devtools_login_slug' );
if ( isset( $_POST['login_slug'] ) ) {
$submitted_slug = sanitize_title( wp_unslash( $_POST['login_slug'] ) );
$reserved = array( 'wp-login', 'wp-admin', 'login', 'admin', 'dashboard' );
if ( in_array( $submitted_slug, $reserved, true ) ) {
wp_send_json_error( __( 'That slug is reserved. Please choose a different one.', 'cloudscale-cyber-devtools' ) );
}
// Hiding the login page with no slug leaves no way in, so that combination is
// refused rather than written.
if ( '' === $submitted_slug && '1' === $hide ) {
wp_send_json_error( __( 'Hide Login needs a slug. Enter one, or turn Hide Login off.', 'cloudscale-cyber-devtools' ) );
}
$slug = $submitted_slug;
update_option( 'csdt_devtools_login_slug', $slug );
} else {
$untouched[] = 'login_slug';
}
// 2FA
$method = (string) $old_state['tfa_method'];
if ( isset( $_POST['method'] ) ) {
$submitted = sanitize_key( wp_unslash( $_POST['method'] ) );
if ( in_array( $submitted, array( 'off', 'email', 'totp' ), true ) ) {
$method = $submitted;
update_option( 'csdt_devtools_2fa_method', $method );
} else {
// Was 'off' before. An empty or malformed value is not a decision to disable
// two-factor authentication.
$untouched[] = 'method';
}
} else {
$untouched[] = 'method';
}
$force = (string) $old_state['force_2fa'];
if ( isset( $_POST['force_admins'] ) ) {
$force = '1' === sanitize_text_field( wp_unslash( $_POST['force_admins'] ) ) ? '1' : '0';
update_option( 'csdt_devtools_2fa_force_admins', $force );
} else {
$untouched[] = 'force_admins';
}
// Session duration
$valid_durations = array( 'default', '1', '7', '14', '30', '90', '365' );
if ( isset( $_POST['session_duration'] ) ) {
$duration = sanitize_key( wp_unslash( $_POST['session_duration'] ) );
if ( in_array( $duration, $valid_durations, true ) ) {
update_option( 'csdt_devtools_session_duration', $duration );
} else {
$untouched[] = 'session_duration';
}
} else {
$untouched[] = 'session_duration';
}
// Brute-force protection
$bf_enabled = (string) $old_state['bf_enabled'];
if ( isset( $_POST['bf_enabled'] ) ) {
$bf_enabled = '1' === sanitize_text_field( wp_unslash( $_POST['bf_enabled'] ) ) ? '1' : '0';
update_option( 'csdt_devtools_brute_force_enabled', $bf_enabled );
} else {
$untouched[] = 'bf_enabled';
}
if ( isset( $_POST['bf_attempts'] ) ) {
$bf_attempts = (int) sanitize_text_field( wp_unslash( $_POST['bf_attempts'] ) );
if ( $bf_attempts >= 1 && $bf_attempts <= 100 ) {
update_option( 'csdt_devtools_brute_force_attempts', (string) $bf_attempts );
} else {
$untouched[] = 'bf_attempts';
}
}
if ( isset( $_POST['bf_lockout'] ) ) {
$bf_lockout = (int) sanitize_text_field( wp_unslash( $_POST['bf_lockout'] ) );
if ( $bf_lockout >= 1 && $bf_lockout <= 1440 ) {
update_option( 'csdt_devtools_brute_force_lockout', (string) $bf_lockout );
} else {
$untouched[] = 'bf_lockout';
}
}
$bf_enum_protect = (string) $old_state['enum_protect'];
if ( isset( $_POST['bf_enum_protect'] ) ) {
$bf_enum_protect = '1' === sanitize_text_field( wp_unslash( $_POST['bf_enum_protect'] ) ) ? '1' : '0';
update_option( 'csdt_devtools_enum_protect', $bf_enum_protect );
} else {
$untouched[] = 'bf_enum_protect';
}
if ( isset( $_POST['bf_auto_block_threshold'] ) ) {
$bf_auto_block = (int) sanitize_text_field( wp_unslash( $_POST['bf_auto_block_threshold'] ) );
if ( $bf_auto_block >= 0 && $bf_auto_block <= 1000 ) {
update_option( 'csdt_devtools_bf_auto_block_threshold', (string) $bf_auto_block );
} else {
$untouched[] = 'bf_auto_block_threshold';
}
}
if ( isset( $_POST['wplogin_auto_block_threshold'] ) ) {
$wplogin_auto_block = (int) sanitize_text_field( wp_unslash( $_POST['wplogin_auto_block_threshold'] ) );
if ( $wplogin_auto_block >= 0 && $wplogin_auto_block <= 10000 ) {
update_option( 'csdt_wplogin_auto_block_threshold', (string) $wplogin_auto_block );
} else {
$untouched[] = 'wplogin_auto_block_threshold';
}
}
if ( isset( $_POST['honeypot_2fa_enabled'] ) ) {
$honeypot = '1' === sanitize_text_field( wp_unslash( $_POST['honeypot_2fa_enabled'] ) ) ? '1' : '0';
update_option( 'csdt_honeypot_2fa_enabled', $honeypot );
} else {
$untouched[] = 'honeypot_2fa_enabled';
}
// Grace logins
if ( isset( $_POST['grace_logins'] ) ) {
$grace_logins = (int) sanitize_text_field( wp_unslash( $_POST['grace_logins'] ) );
if ( $grace_logins >= 0 && $grace_logins <= 10 ) {
update_option( 'csdt_devtools_2fa_grace_logins', (string) $grace_logins );
} else {
$untouched[] = 'grace_logins';
}
}
// Telegram notification preferences. Absent means "leave my alerting alone", losing
// alerts is how a downgrade goes unnoticed.
if ( isset( $_POST['telegram_login_valid_user'] ) ) {
update_option( 'csdt_telegram_login_valid_user', '1' === sanitize_text_field( wp_unslash( $_POST['telegram_login_valid_user'] ) ) ? '1' : '0' );
} else {
$untouched[] = 'telegram_login_valid_user';
}
if ( isset( $_POST['telegram_login_invalid_user'] ) ) {
update_option( 'csdt_telegram_login_invalid_user', '1' === sanitize_text_field( wp_unslash( $_POST['telegram_login_invalid_user'] ) ) ? '1' : '0' );
} else {
$untouched[] = 'telegram_login_invalid_user';
}
// Check for security control downgrades and fire alert if needed. Read back rather than
// trusting the locals, so what is compared is what is actually stored.
$new_state = array(
'hide_enabled' => (string) CSDT_Options::get( 'csdt_devtools_login_hide_enabled' ),
'bf_enabled' => (string) CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ),
'force_2fa' => (string) CSDT_Options::get( 'csdt_devtools_2fa_force_admins' ),
'tfa_method' => (string) CSDT_Options::get( 'csdt_devtools_2fa_method' ),
'enum_protect' => (string) CSDT_Options::get( 'csdt_devtools_enum_protect' ),
);
self::check_settings_downgrade( $old_state, $new_state );
$new_url = $hide === '1' && $slug ? home_url( '/' . $slug . '/' ) : wp_login_url();
wp_send_json_success(
array(
'login_url' => $new_url,
// Names every field the request did not carry, so a partial save is visible
// instead of looking like a full one.
'untouched' => array_values( array_unique( $untouched ) ),
)
);
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
/**
* AJAX: generates a new TOTP secret and returns the QR code URL for setup.
* Stores the secret as a pending (unconfirmed) user meta key.
*
* @since 1.9.4
* @return void
*/
public static function ajax_totp_setup_start(): void {
check_ajax_referer( CSDT_DevTools::LOGIN_NONCE, 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( 'Unauthorized', 403 );
}
try {
$user_id = get_current_user_id();
$secret = self::totp_generate_secret();
// Store as pending until the user verifies their first code.
update_user_meta( $user_id, 'csdt_devtools_totp_secret_pending', $secret );
$email = wp_get_current_user()->user_email;
$uri = self::totp_provisioning_uri( $secret, $email );
wp_send_json_success(
array(
'otpauth' => $uri,
'secret' => $secret,
)
);
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
/**
* AJAX: verifies the first TOTP code to activate the pending secret.
*
* @since 1.9.4
* @return void
*/
public static function ajax_totp_setup_verify(): void {
check_ajax_referer( CSDT_DevTools::LOGIN_NONCE, 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( 'Unauthorized', 403 );
}
try {
$code = isset( $_POST['code'] ) ? preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_POST['code'] ) ) ) : '';
$user_id = get_current_user_id();
$secret = get_user_meta( $user_id, 'csdt_devtools_totp_secret_pending', true );
if ( ! $secret ) {
wp_send_json_error( __( 'No pending setup found. Please start setup again.', 'cloudscale-cyber-devtools' ) );
}
if ( strlen( $code ) !== 6 ) {
wp_send_json_error( __( 'Please enter a 6-digit code.', 'cloudscale-cyber-devtools' ) );
}
if ( ! self::totp_verify( $secret, $code ) ) {
wp_send_json_error( __( 'Code incorrect. Check your app\'s time sync and try again.', 'cloudscale-cyber-devtools' ) );
}
// Activate: promote pending secret to live.
update_user_meta( $user_id, 'csdt_devtools_totp_secret', $secret );
update_user_meta( $user_id, 'csdt_devtools_totp_enabled', '1' );
delete_user_meta( $user_id, 'csdt_devtools_totp_secret_pending' );
// If user had email 2FA, disable it (TOTP is preferred).
delete_user_meta( $user_id, 'csdt_devtools_2fa_email_enabled' );
// Security state changed, destroy all other open sessions.
wp_destroy_other_sessions();
wp_send_json_success( array( 'message' => __( 'Authenticator app activated!', 'cloudscale-cyber-devtools' ) ) );
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
/**
* AJAX: disables 2FA for the current user (email or TOTP).
*
* @since 1.9.4
* @return void
*/
public static function ajax_2fa_disable(): void {
check_ajax_referer( CSDT_DevTools::LOGIN_NONCE, 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( 'Unauthorized', 403 );
}
try {
$method = isset( $_POST['method'] ) ? sanitize_key( wp_unslash( $_POST['method'] ) ) : '';
$user_id = get_current_user_id();
if ( $method === 'totp' ) {
delete_user_meta( $user_id, 'csdt_devtools_totp_secret' );
delete_user_meta( $user_id, 'csdt_devtools_totp_secret_pending' );
update_user_meta( $user_id, 'csdt_devtools_totp_enabled', '0' );
} elseif ( $method === 'email' ) {
update_user_meta( $user_id, 'csdt_devtools_2fa_email_enabled', '0' );
delete_user_meta( $user_id, 'csdt_devtools_email_verify_pending' );
} else {
wp_send_json_error( 'Unknown method.' );
}
// Security state changed, destroy all other open sessions.
wp_destroy_other_sessions();
// Recorded and alerted: an account with its second factor removed is one
// password away from the site, and a session that has been taken over is
// exactly the one that would do this quietly.
$who = wp_get_current_user();
$login = is_object( $who ) && isset( $who->user_login ) ? (string) $who->user_login : (string) $user_id;
$admin = is_object( $who ) && in_array( 'administrator', (array) ( $who->roles ?? array() ), true );
$ip = self::get_client_ip();
self::record_security_event( 'downgrade', sprintf( '2FA (%s) disabled for %s', $method, $login ), "From {$ip}" );
self::send_alert(
'Second factor removed from an account',
sprintf( "%s disabled their %s second factor.\n\nAccount: %s%s\nIP: %s\n\nIf this was not you, the session that did it is not yours: sign in, change the password, and re-enrol.", $login, $method, $login, $admin ? ' (administrator)' : '', $ip ),
$admin ? 'urgent' : 'high',
'warning,unlock'
);
wp_send_json_success( array( 'message' => __( '2FA disabled.', 'cloudscale-cyber-devtools' ) ) );
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
/**
* AJAX: sends a verification email with a callback link.
* Email 2FA is only activated once the user clicks the link (10-min TTL).
* Reuses this handler for both first-enable and Resend.
*
* Pre-send diagnostics come from CloudScale Backup's csbr_run_email_diagnostics(), the callback of its `cloudscale_email_diagnostics`
* filter, the CloudScale Backup & Restore plugin hooks this when active,
* providing port/MTA/relay checks. If nothing hooks the filter we fall back
* to wp_mail_failed to surface the actual SMTP transport error instead.
*
* @since 1.9.4
* @return void
*/
public static function ajax_email_2fa_enable(): void {
check_ajax_referer( CSDT_DevTools::LOGIN_NONCE, 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( 'Unauthorized', 403 );
}
try {
$user = wp_get_current_user();
$user_id = $user->ID;
// ── Pre-send diagnostics (port / MTA / relay) ─────────────────────
// Returns [ 'warning' => string, 'fatal' => bool ] or null when no
// plugin has registered diagnostics for this environment.
// CloudScale Backup & Restore supplies them when active. Called directly
// rather than through its cloudscale_email_diagnostics filter: a hook name
// outside this plugin's own prefix is a review finding however it is shared.
$diag = function_exists( 'csbr_run_email_diagnostics' ) ? csbr_run_email_diagnostics() : null;
$warning = is_array( $diag ) ? (string) ( $diag['warning'] ?? '' ) : '';
$fatal = is_array( $diag ) && ! empty( $diag['fatal'] );
if ( $fatal ) {
wp_send_json_error(
array(
'message' => __( 'Email cannot be sent from this server, see warning below.', 'cloudscale-cyber-devtools' ),
'port_warning' => $warning,
)
);
}
// ── Capture the actual SMTP transport error via wp_mail_failed ─────
// Used when no external diagnostic plugin is active; gives the real
// error string rather than a guessed port-probe message.
$transport_error = '';
$on_mail_failed = static function ( \WP_Error $err ) use ( &$transport_error ): void {
$transport_error = CSDT_Error_Text::in_seconds( $err->get_error_message() );
};
add_action( 'wp_mail_failed', $on_mail_failed );
// ── Generate a single-use verification token (1-hour TTL) ────────
$token = wp_generate_password( 32, false, false );
$transient = CSDT_DevTools::EMAIL_VERIFY_TRANSIENT . $token;
set_transient( $transient, array( 'user_id' => $user_id ), 3600 );
update_user_meta( $user_id, 'csdt_devtools_email_verify_pending', '1' );
// The link is built here for this logged-in user, so it carries a nonce that
// email_2fa_confirm_check() verifies before it reads the token.
$callback = wp_nonce_url(
self::email_2fa_landing( array( 'csdt_devtools_email_verify' => rawurlencode( $token ) ) ),
'csdt_email_2fa_verify'
);
$site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
add_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
$sent = wp_mail(
$user->user_email,
sprintf( '[%s] Verify your email for 2FA', $site ),
self::email_html_verify( $user->display_name, $site, $callback )
);
remove_filter( 'wp_mail_content_type', array( __CLASS__, 'email_content_type_html' ) );
remove_action( 'wp_mail_failed', $on_mail_failed );
if ( ! $sent ) {
delete_transient( $transient );
delete_user_meta( $user_id, 'csdt_devtools_email_verify_pending' );
// Surface the real SMTP error if captured, otherwise the warning from diagnostics.
$detail = $transport_error ?: $warning ?: __( 'Check your WordPress mail configuration.', 'cloudscale-cyber-devtools' );
// Flag when SMTP isn't configured so the UI can prompt the user to set it up.
$smtp_not_configured = CSDT_Options::get( 'csdt_devtools_smtp_enabled' ) !== '1'
|| '' === trim( (string) get_option( 'csdt_devtools_smtp_host', '' ) );
wp_send_json_error(
array(
/* translators: %s is the SMTP error message explaining why the email failed */
'message' => sprintf( __( 'Email not sent: %s', 'cloudscale-cyber-devtools' ), $detail ),
'port_warning' => $warning,
'smtp_not_configured' => $smtp_not_configured,
)
);
}
$msg = sprintf(
/* translators: %s: email address */
__( 'Verification email sent to %s. Click the link to activate 2FA.', 'cloudscale-cyber-devtools' ),
$user->user_email
);
wp_send_json_success(
array(
'message' => $msg . ( $warning ? ' ' . __( '(See warning below.)', 'cloudscale-cyber-devtools' ) : '' ),
'port_warning' => $warning,
)
);
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
/**
* Where the email-2FA verification link, and the redirects after it, land.
*
* The plugin's Login Security screen needs manage_options. A user enrolling from their own
* profile page (CSDT_Password_Policy) cannot open it, so their link lands on profile.php,
* where admin_init runs email_2fa_confirm_check() just the same.
*
* @param array $args Query arguments to add.
* @return string
*/
private static function email_2fa_landing( array $args = array() ): string {
$base = current_user_can( 'manage_options' )
? admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=login' )
: admin_url( 'profile.php' );
return add_query_arg( $args, $base );
}
/** Sets wp_mail content type to HTML (used temporarily around branded emails). */
public static function email_content_type_html(): string {
return 'text/html';
}
/**
* Returns the branded HTML wrapper used by all CS security emails.
*
* @param string $inner_html Body content (already escaped).
* @return string Full HTML document.
*/
private static function email_html_wrap( string $inner_html ): string {
$site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
return '
' . esc_html( $site_name ) . '
🔒 ' . esc_html( $site_name ) . '
Account security
' . $inner_html . '
You\'re receiving this because you have an account on this site.
If you didn\'t request this, you can safely ignore it.
';
}
/**
* HTML email body for the 2FA one-time login code.
*
* @param string $display_name User's display name.
* @param string $site Site name (already decoded).
* @param string $otp 6-digit code.
* @return string Full HTML email.
*/
private static function email_html_otp( string $display_name, string $site, string $otp ): string {
$inner = '
Hi ' . esc_html( $display_name ) . ',
Your one-time login code for ' . esc_html( $site ) . ':
' . esc_html( $otp ) . '
⏰ This code expires in 10 minutes.
⚠️ If you did not attempt to log in, please change your password immediately.
';
return self::email_html_wrap( $inner );
}
/**
* HTML email body for the email 2FA verification link.
*
* @param string $display_name User's display name.
* @param string $site Site name (already decoded).
* @param string $verify_url Full verification URL.
* @return string Full HTML email.
*/
private static function email_html_verify( string $display_name, string $site, string $verify_url ): string {
$inner = '
Hi ' . esc_html( $display_name ) . ',
You requested to enable Email Two-Factor Authentication on ' . esc_html( $site ) . '.
Click the button below to verify your email address and activate 2FA.
';
return self::email_html_wrap( $inner );
}
/**
* Handles the email verification callback link.
* Runs on admin_init, activates email 2FA when a valid token is present.
*
* @since 1.9.4
* @return void
*/
public static function email_2fa_confirm_check(): void {
try {
if ( ! is_user_logged_in() ) {
return;
}
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'csdt_email_2fa_verify' ) ) {
return;
}
if ( ! isset( $_GET['csdt_devtools_email_verify'] ) ) {
return;
}
$token = sanitize_text_field( wp_unslash( $_GET['csdt_devtools_email_verify'] ) );
$transient = CSDT_DevTools::EMAIL_VERIFY_TRANSIENT . $token;
$data = get_transient( $transient );
if ( ! $data || empty( $data['user_id'] ) ) {
// Expired or invalid, redirect back without activating.
wp_safe_redirect( wp_nonce_url( self::email_2fa_landing( array( 'email_verify_expired' => '1' ) ), 'csdt_email_2fa_notice' ) );
exit;
}
$user_id = (int) $data['user_id'];
// Verify the token belongs to the currently logged-in user.
if ( $user_id !== get_current_user_id() ) {
wp_safe_redirect( self::email_2fa_landing() );
exit;
}
// If already activated on a previous click (e.g. email client prefetch), just redirect to success.
if ( ! empty( $data['activated'] ) ) {
wp_safe_redirect( wp_nonce_url( self::email_2fa_landing( array( 'email_2fa_activated' => '1' ) ), 'csdt_email_2fa_notice' ) );
exit;
}
// Activate email 2FA.
update_user_meta( $user_id, 'csdt_devtools_2fa_email_enabled', '1' );
delete_user_meta( $user_id, 'csdt_devtools_email_verify_pending' );
// Mark the transient as used (keep it alive for 10 min so re-clicks show success, not "expired").
set_transient(
$transient,
array(
'user_id' => $user_id,
'activated' => true,
),
600
);
// Security state changed, destroy all other open sessions for this user.
wp_destroy_other_sessions();
wp_safe_redirect( wp_nonce_url( self::email_2fa_landing( array( 'email_2fa_activated' => '1' ) ), 'csdt_email_2fa_notice' ) );
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Destroys all other sessions when a user resets their password.
*
* @since 1.9.4
* @param \WP_User $user The user whose password was reset.
* @return void
*/
public static function on_password_reset( \WP_User $user ): void {
try {
// Destroy all sessions so the newly-reset password must be used everywhere.
WP_Session_Tokens::get_instance( $user->ID )->destroy_all();
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Destroys all other sessions when a user's email or password changes.
*
* @since 1.9.4
* @param int $user_id Updated user ID.
* @param \WP_User $old_userdata User data before the update.
* @return void
*/
public static function on_profile_update( int $user_id, \WP_User $old_userdata ): void {
try {
$new_user = get_userdata( $user_id );
if ( ! $new_user ) {
return;
}
$email_changed = $old_userdata->user_email !== $new_user->user_email;
$password_changed = $old_userdata->user_pass !== $new_user->user_pass;
if ( $email_changed || $password_changed ) {
// If the currently-logged-in user changed their own account, keep their
// current session alive; destroy all others. For admin-changed accounts
// (different user_id) destroy every session outright.
if ( get_current_user_id() === $user_id ) {
wp_destroy_other_sessions();
} else {
WP_Session_Tokens::get_instance( $user_id )->destroy_all();
}
}
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
// ── Honeypot 2FA, fake 2FA screen on invalid credentials ────────────────
//
// When enabled, any failed login (wrong username OR wrong password) redirects
// to a fake 6-digit PIN screen instead of showing a login error. Whatever the
// attacker types is silently rejected and they land on the dark "being watched"
// screen. Real accounts with 2FA are unaffected, they go through the real 2FA
// flow before ever reaching wp_login_failed.
private const HONEYPOT_ACTION = 'csdt_honeypot_2fa';
/**
* Hooked to `wp_login_failed`. Redirects failed logins to the honeypot 2FA screen.
*/
public static function honeypot_redirect( string $username ): void {
try {
if ( CSDT_Options::get( 'csdt_honeypot_2fa_enabled' ) !== '1' ) {
return;
}
// Don't double-intercept real 2FA flows.
$action = self::requested_login_action( '' );
if ( $action === 'csdt_devtools_2fa' || $action === self::HONEYPOT_ACTION ) {
return;
}
$url = add_query_arg( 'action', self::HONEYPOT_ACTION, wp_login_url() );
wp_safe_redirect( $url );
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Hooked to `login_init`. Handles GET (display) and POST (always-fail) for the honeypot screen.
*/
public static function honeypot_handle(): void {
try {
// Off means off. honeypot_redirect() has always checked this switch; this handler
// did not, so the fake screen, and the write below it, answered on every install
// whether or not the feature had ever been turned on.
if ( CSDT_Options::get( 'csdt_honeypot_2fa_enabled' ) !== '1' ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only: decides whether this request is for the honeypot screen at all. The POST branch below verifies the form nonce before it records anything.
$action = self::requested_login_action( '' );
if ( $action !== self::HONEYPOT_ACTION ) {
return;
}
// POST, attacker submitted the fake PIN.
if ( 'POST' === ( isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) {
$ip = self::get_client_ip();
// The event is an option write, and this screen is reachable logged out, so it is
// recorded only for a submission of the form this plugin rendered, and once per
// address per hour. The security log keeps 24 entries of a type: without both
// limits, two dozen anonymous POSTs would push every genuine attack record out.
// The watched screen renders either way, so the honeypot keeps its cover.
$nonce = isset( $_POST['_csdt_hp'] ) ? sanitize_text_field( wp_unslash( $_POST['_csdt_hp'] ) ) : '';
$throttle = 'csdt_hp_seen_' . md5( (string) $ip );
if ( $ip && wp_verify_nonce( $nonce, self::HONEYPOT_ACTION ) && ! get_transient( $throttle ) ) {
set_transient( $throttle, 1, HOUR_IN_SECONDS );
$cc = CSDT_Geo::get_country( $ip );
self::record_security_event( 'attack', 'Honeypot 2FA triggered', "IP: {$ip}" . ( $cc ? " · {$cc}" : '' ) );
}
// Show the dark "being watched" screen, same HTML as the blocked probe screen.
self::honeypot_render_watched( $ip );
exit;
}
// GET, show the fake 2FA PIN entry form.
self::honeypot_render_pin_form();
exit;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/** Render the fake 6-digit PIN form using WP's login chrome. */
private static function honeypot_render_pin_form(): void {
login_header( __( 'Two-Factor Authentication', 'cloudscale-cyber-devtools' ), '', null );
?>
Access Protected
';
// Page exits before wp_head(), so the stylesheet link is printed
// directly through the enqueue API rather than a literal tag.
wp_register_style( 'csdt-403', plugin_dir_url( __DIR__ ) . 'assets/cs-403.css', array(), CSDT_DevTools::VERSION );
wp_print_styles( 'csdt-403' );
echo '
Security Alert
Unauthorised access attempt
' . esc_html( $site_name ) . '
' . wp_kses_post( self::credit_block() ) . '
⚠ This authentication attempt has been logged. IP ' . esc_html( $ip ) . ' is now being tracked.
';
}
// ── IP Blocklist ─────────────────────────────────────────────────────────
/**
* Enforce the blocklist on every entry point, as early as the request allows.
*
* Hooked to `plugins_loaded` priority 1. Until 1.10.431 this ran on
* `parse_request` priority 1 with an `is_admin()` early return, and that
* combination left the blocklist covering the endpoints an attacker does not
* need while leaving open the ones they do:
*
* - `parse_request` only fires inside WP::main(), which `wp-login.php` and
* `xmlrpc.php` never reach. A blocked address could keep attempting
* logins, including a `system.multicall` carrying hundreds of credential
* pairs, which is the very signal that got it blocked.
* - `admin-ajax.php` and `admin-post.php` both satisfy `is_admin()`, so the
* early return skipped them entirely. `wp_ajax_nopriv_*` is the most
* commonly exploited unauthenticated surface in vulnerable plugins.
*
* Only front-end page loads and the REST API were actually covered.
* `plugins_loaded` fires on all of those entry points, and is early enough
* that a refused request never reaches another plugin's `init` handler.
*
* Enforcing inside wp-admin is what `is_admin()` was really protecting
* against, an administrator whose own address got blocked being locked out.
* A capability bypass replaces it, which is strictly stronger: `is_admin()`
* exempted anyone who merely asked for an admin URL, logged in or not.
*
* @since 1.10.431 Runs on plugins_loaded; capability bypass replaces is_admin().
*/
public static function enforce_ip_blocklist(): void {
try {
// WP-CLI and cron are not remote traffic: neither carries a
// meaningful client address, and refusing cron would quietly break
// every scheduled task on the site.
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
return;
}
// Fast path: an autoloaded flag records whether the (non-autoloaded)
// blocklist has any entries, so the common case, no blocked IPs, costs
// zero extra DB queries on every frontend request. Only read the full
// blocklist option when the flag says entries exist (or is uninitialised).
$active = get_option( 'csdt_ip_blocklist_active', null );
if ( '0' === $active ) {
return; }
$list = get_option( 'csdt_ip_blocklist', array() );
if ( empty( $list ) || ! is_array( $list ) ) {
if ( '0' !== $active ) {
update_option( 'csdt_ip_blocklist_active', '0' ); }
return; }
if ( '1' !== $active ) {
update_option( 'csdt_ip_blocklist_active', '1' ); }
$ip = self::get_client_ip();
if ( ! $ip || filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) {
return; }
// Belt and braces: even if an entry for our own egress exists from before
// this guard, it must not be enforced. Otherwise the fix only helps sites
// that were never bitten.
if ( isset( $list[ $ip ] ) && in_array( $ip, self::own_public_ips(), true ) ) {
unset( $list[ $ip ] );
update_option( 'csdt_ip_blocklist', $list, false );
if ( empty( $list ) ) {
update_option( 'csdt_ip_blocklist_active', '0' );
}
return;
}
if ( isset( $list[ $ip ] ) ) {
// Auto-blocks carry an expiry so a temporary block releases itself
// even if cron is not running. Manual blocks have no expires_at
// and stay in force until removed by an administrator.
//
// Normalised on read since 1.10.432, so an entry written by one of
// the three legacy paths, none of which stored expires_at, is
// judged by the same policy as a current one. Without this, a
// legacy automatic block stayed in force for ever, and would keep
// doing so until the migration happened to run. Reading it here
// means the very first request after upgrade behaves correctly.
$entry = class_exists( 'CSDT_Auto_Block' )
? CSDT_Auto_Block::normalise_entry( $list[ $ip ], $ip )
: array( 'expires_at' => is_array( $list[ $ip ] ) ? (int) ( $list[ $ip ]['expires_at'] ?? 0 ) : 0 );
$expires = (int) ( $entry['expires_at'] ?? 0 );
if ( $expires > 0 && $expires < time() ) {
unset( $list[ $ip ] );
update_option( 'csdt_ip_blocklist', $list, false );
if ( empty( $list ) ) {
update_option( 'csdt_ip_blocklist_active', '0' );
}
return;
}
// Never refuse a site administrator. This is the guard that makes
// it safe to enforce inside wp-admin, admin-ajax and admin-post.
if ( self::request_is_site_admin() ) {
return;
}
// Set here as well: core's XML-RPC die handler never sets a status, so xmlrpc.php would answer 200.
status_header( 403 );
wp_die( esc_html__( 'Access denied.', 'cloudscale-cyber-devtools' ), '', array( 'response' => 403 ) );
}
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Whether this request carries a valid auth cookie for a user who can manage
* the site.
*
* Called only once the client address is already on the blocklist, so neither
* the cookie validation nor the capability lookup touches ordinary traffic.
*
* Conservative in one direction and deliberately not in the other: a cookie it
* cannot verify counts as "not an administrator", but a missing pluggable
* layer reports true. `pluggable.php` is required by wp-settings.php before
* `plugins_loaded` fires, so that branch is unreachable on a normal request,
* it exists so a future load-order change degrades to "a blocked attacker got
* through", which is recoverable, rather than "every administrator is locked
* out of a live site", which is not.
*
* @since 1.10.431
*/
public static function request_is_site_admin(): bool {
return self::request_user_can( 'manage_options' );
}
/**
* Whether this request carries a valid auth cookie for a user with a capability.
*
* Generalised from request_is_site_admin() in 1.10.471 so the firewall can share it.
* Three consumers now need "who is this, before init", the IP blocklist, country
* rules, and CSDT_Firewall, and three copies of cookie validation is three places
* for a lockout guard to drift. That is exactly how csdt_ip_blocklist ended up with
* four writers and 79 immortal blocks.
*
* Fails OPEN if the pluggable layer is missing, for the reason given on the caller
* side: that branch is unreachable on a normal request, and if a future load-order
* change made it reachable, "a request went uninspected" is recoverable while
* "every administrator is locked out of a live site" is not.
*
* @param string $capability Capability to test, e.g. 'manage_options', 'edit_posts'.
* @since 1.10.471
*/
public static function request_user_can( string $capability ): bool {
if ( ! function_exists( 'wp_validate_auth_cookie' ) || ! function_exists( 'user_can' ) ) {
return true;
}
// Passing an empty cookie makes wp_validate_auth_cookie() read $_COOKIE
// itself, so there is no superglobal to sanitise here. It returns false
// when the cookie is absent, malformed, tampered with or expired.
$user_id = wp_validate_auth_cookie( '', 'logged_in' );
if ( ! $user_id ) {
return false;
}
return (bool) user_can( (int) $user_id, $capability );
}
public static function ajax_ip_block(): void {
check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 ); }
try {
$ip = sanitize_text_field( wp_unslash( $_POST['ip'] ?? '' ) );
$reason = sanitize_text_field( wp_unslash( $_POST['reason'] ?? 'Manual block' ) );
if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
wp_send_json_error( 'Invalid IP address.' ); }
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) {
wp_send_json_error( 'Private and reserved IP addresses cannot be blocked.' );
}
// Manual blocks have no expiry by design, an administrator chose this
// address and only an administrator removes it. Tagged explicitly as
// manual since 1.10.432 so it is distinguishable from a legacy
// automatic entry, which the migration releases.
if ( ! CSDT_Auto_Block::add_entry( $ip, $reason, CSDT_Auto_Block::SOURCE_MANUAL, 0 ) ) {
wp_send_json_error( 'That address is already on the blocklist.' );
}
wp_send_json_success( array( 'ip' => $ip ) );
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
public static function ajax_ip_unblock(): void {
check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 ); }
try {
$ip = sanitize_text_field( wp_unslash( $_POST['ip'] ?? '' ) );
$list = get_option( 'csdt_ip_blocklist', array() );
if ( is_array( $list ) ) {
unset( $list[ $ip ] );
update_option( 'csdt_ip_blocklist', $list, false );
update_option( 'csdt_ip_blocklist_active', empty( $list ) ? '0' : '1' ); }
wp_send_json_success( array( 'ip' => $ip ) );
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
/** Delegates to the central helper which validates CF-Connecting-IP only
* when REMOTE_ADDR is actually a Cloudflare edge. */
/*
* ─── Failed-login recording and its signal ──────────────────────────────
*
* Both moved here from CSDT_Perf_Monitor on 2026-08-17 (plugin-split-plan.md stage 2). Neither
* was ever about performance: record_failed_login() writes the 14-day capped brute-force log
* that csdt_devtools_bf_log holds, and failed_login_signal() reads it to answer whether the site
* is under attack now. This class already read that log and carried a comment saying the perf
* monitor wrote it, the placement, not the code, was the problem.
*
* HOOK ORDER IS LOAD-BEARING. record_failed_login() must run BEFORE
* CSDT_Login::on_login_failed(), which reads the counters this updates. Both sit on
* wp_login_failed at the default priority, so REGISTRATION ORDER decides, and the registration
* in cloudscale-cyber-devtools.php keeps the recorder ahead of the reader. Moving the two methods into one
* class does not change that and does not make it safe to reorder them.
*/
public static function record_failed_login( string $username ): void {
// 1-hour rolling window.
$c1h = (int) get_transient( 'csdt_devtools_failed_logins_1h' );
set_transient( 'csdt_devtools_failed_logins_1h', $c1h + 1, HOUR_IN_SECONDS );
// 24-hour rolling window.
$c24h = (int) get_transient( 'csdt_devtools_failed_logins_24h' );
set_transient( 'csdt_devtools_failed_logins_24h', $c24h + 1, DAY_IN_SECONDS );
// Persistent 14-day rolling log [timestamp, username, ip, country], capped at 500 entries.
$log = get_option( 'csdt_devtools_bf_log', array() );
if ( ! is_array( $log ) ) {
$log = array();
}
$cutoff = time() - 14 * DAY_IN_SECONDS;
$log = array_values( array_filter( $log, fn( $e ) => isset( $e[0] ) && $e[0] >= $cutoff ) );
if ( count( $log ) >= 500 ) {
array_shift( $log );
}
$ip = self::get_client_ip();
$country = CSDT_Geo::get_country( $ip );
// If WP passed an empty username (REST API / application-password path),
// try PHP_AUTH_USER which holds the Basic Auth username they sent.
$logged_user = $username;
if ( '' === $logged_user && ! empty( $_SERVER['PHP_AUTH_USER'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- value is sanitised on the next line
$logged_user = sanitize_user( wp_unslash( $_SERVER['PHP_AUTH_USER'] ), true );
}
if ( '' === $logged_user ) {
$logged_user = '[REST API]';
}
$log[] = array( time(), $logged_user, $ip, $country );
update_option( 'csdt_devtools_bf_log', $log, false );
// Per-country 14-day rolling count for the map.
if ( $country ) {
$cc_stats = get_option( 'csdt_bf_country_stats', array() );
if ( ! is_array( $cc_stats ) ) {
$cc_stats = array();
}
$today = gmdate( 'Y-m-d' );
if ( ! isset( $cc_stats[ $country ] ) || ! is_array( $cc_stats[ $country ] ) ) {
$cc_stats[ $country ] = array();
}
$cc_stats[ $country ][ $today ] = ( $cc_stats[ $country ][ $today ] ?? 0 ) + 1;
$cutoff_day = gmdate( 'Y-m-d', strtotime( '-14 days' ) );
foreach ( $cc_stats as $cc => $days ) {
foreach ( array_keys( $days ) as $day_key ) {
if ( $day_key < $cutoff_day ) {
unset( $cc_stats[ $cc ][ $day_key ] );
}
}
if ( empty( $cc_stats[ $cc ] ) ) {
unset( $cc_stats[ $cc ] );
}
}
update_option( 'csdt_bf_country_stats', $cc_stats, false );
}
// Per-IP failed login index, keyed by IP for future dashboard use.
if ( $ip ) {
$ip_index = get_option( 'csdt_devtools_failed_login_ips', array() );
if ( ! is_array( $ip_index ) ) {
$ip_index = array();
}
$clean_user = sanitize_user( $username, true );
$now = time();
if ( isset( $ip_index[ $ip ] ) ) {
++$ip_index[ $ip ]['count'];
$ip_index[ $ip ]['last_seen'] = $now;
$ip_index[ $ip ]['times'][] = $now;
if ( count( $ip_index[ $ip ]['times'] ) > 50 ) {
array_shift( $ip_index[ $ip ]['times'] );
}
if ( ! in_array( $clean_user, $ip_index[ $ip ]['usernames'], true ) ) {
$ip_index[ $ip ]['usernames'][] = $clean_user;
if ( count( $ip_index[ $ip ]['usernames'] ) > 20 ) {
array_shift( $ip_index[ $ip ]['usernames'] );
}
}
} else {
$ip_index[ $ip ] = array(
'count' => 1,
'first_seen' => $now,
'last_seen' => $now,
'times' => array( $now ),
'usernames' => array( $clean_user ),
);
}
// Purge IPs not seen in the last 90 days.
$cutoff_ip = time() - 90 * DAY_IN_SECONDS;
$ip_index = array_filter( $ip_index, fn( $e ) => $e['last_seen'] >= $cutoff_ip );
// Cap at 1000 unique IPs, drop the oldest by last_seen.
if ( count( $ip_index ) > 1000 ) {
uasort( $ip_index, fn( $a, $b ) => $a['last_seen'] <=> $b['last_seen'] );
$ip_index = array_slice( $ip_index, -1000, null, true );
}
update_option( 'csdt_devtools_failed_login_ips', $ip_index, false );
}
// Track invalid (nonexistent) username attempts separately
if ( ! username_exists( $username ) ) {
$inv = get_option( 'csdt_invalid_user_log', array() );
if ( ! is_array( $inv ) ) {
$inv = array();
}
$inv[] = array( time(), sanitize_user( $username, true ), $ip );
if ( count( $inv ) > 200 ) {
$inv = array_slice( $inv, -200 );
}
update_option( 'csdt_invalid_user_log', $inv, false );
}
// ── Per-address lock, whatever the username ──────────────────────────
self::note_failed_login_ip( $ip );
// ── Brute-force per-account lockout ──────────────────────────────────
if ( CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ) !== '1' || empty( $username ) ) {
return;
}
$max_attempts = max( 1, (int) CSDT_Options::get( 'csdt_devtools_brute_force_attempts' ) );
$lockout_secs = max( 60, (int) CSDT_Options::get( 'csdt_devtools_brute_force_lockout' ) * MINUTE_IN_SECONDS );
$slug = md5( strtolower( $username ) );
$count_key = 'csdt_devtools_bf_count_' . $slug;
$lock_key = 'csdt_devtools_bf_lock_' . $slug;
$attempts = (int) get_transient( $count_key ) + 1;
if ( $attempts >= $max_attempts ) {
// Threshold reached, lock the account and clear the counter.
$locked = set_transient( $lock_key, time() + $lockout_secs, $lockout_secs );
if ( ! $locked ) {
// The lockout did not persist, an attacker is NOT rate-limited.
// Log so the operator can find out the brute-force guard failed.
if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) {
CSDT_DevTools::log( "CSDT_Login: set_transient failed for brute-force lockout on '{$username}', lockout NOT enforced." );
}
}
delete_transient( $count_key );
// Send throttled alert, only for valid accounts (real attack vector), at most once per 2 hrs.
if ( username_exists( $username ) ) {
$notif_key = 'csdt_devtools_bf_notif_' . $slug;
if ( ! get_transient( $notif_key ) ) {
set_transient( $notif_key, 1, 2 * HOUR_IN_SECONDS );
$site = wp_specialchars_decode( get_bloginfo( 'name' ) ?: home_url(), ENT_QUOTES );
$admin_url = admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=login' );
$subject = sprintf( '[%s] REAL ACCOUNT under brute-force attack: %s', $site, $username );
$body = sprintf(
"Account '%s' is a valid WordPress account and has been locked after %d consecutive failed login attempts.\n\nThis is an active credential-stuffing or brute-force attack targeting a real account.\n\nAccount will be locked for %d minutes. If this recurs, enable 2FA immediately.\n\nView login attempts: %s",
$username,
$max_attempts,
$lockout_secs / MINUTE_IN_SECONDS,
$admin_url
);
self::send_alert( $subject, $body, 'urgent', 'skull,warning' );
}
}
} else {
// Still within the window, keep counting.
$counted = set_transient( $count_key, $attempts, $lockout_secs * 2 );
if ( ! $counted ) {
if ( is_callable( array( 'CSDT_DevTools', 'log' ) ) ) {
CSDT_DevTools::log( "CSDT_Login: set_transient failed for brute-force counter on '{$username}', attempts not tracked." );
}
}
}
// ── Auto IP block after N failures in 1 hour ─────────────────────────
// Default threshold: 10 failures/hour. Configurable via option.
// Skips already-blocked IPs and local/private ranges.
if ( ! $ip ) {
return; }
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) {
return; }
$auto_block_threshold = (int) CSDT_Options::get( 'csdt_devtools_bf_auto_block_threshold' );
if ( $auto_block_threshold < 1 ) {
return; }
// Check if already manually or auto-blocked.
$blocklist = get_option( 'csdt_ip_blocklist', array() );
if ( ! is_array( $blocklist ) ) {
$blocklist = array(); }
if ( isset( $blocklist[ $ip ] ) ) {
return; }
// Count how many failures this IP had in the last hour using the ip_index times array.
$ip_index = get_option( 'csdt_devtools_failed_login_ips', array() );
$hour_ago = time() - HOUR_IN_SECONDS;
$recent_hits = 0;
if ( isset( $ip_index[ $ip ]['times'] ) ) {
$recent_hits = count( array_filter( $ip_index[ $ip ]['times'], fn( $t ) => $t >= $hour_ago ) );
}
if ( $recent_hits >= $auto_block_threshold ) {
// Written through CSDT_Auto_Block::add_entry() since 1.10.432. This
// path used to write its own shape with no expires_at, making every
// block it created permanent, contradicting CSDT_Auto_Block's stated
// policy while blocking for the same reason (failed logins) that
// CSDT_Auto_Block already counts. One writer, one ladder, one expiry.
if ( ! class_exists( 'CSDT_Auto_Block' ) || ! CSDT_Auto_Block::add_entry(
$ip,
sprintf( 'Auto-blocked: %d failed logins in 1 hour', $recent_hits ),
CSDT_Auto_Block::SOURCE_AUTO,
CSDT_Auto_Block::next_auto_ttl( $ip ),
'login'
) ) {
return;
}
// Record security event + alert.
self::record_security_event(
'attack',
"IP auto-blocked: {$ip}",
"Reason: {$recent_hits} failed logins in 1 hour"
);
self::send_alert(
"IP auto-blocked: {$ip}",
"Blocked after {$recent_hits} failed login attempts in 1 hour.\nIP: {$ip}",
'high',
'rotating_light'
);
}
}
/**
* Transient key for one address's failed-login counter or lock.
*
* @param string $kind count|lock|notif.
* @param string $ip Address.
* @return string
*/
public static function ip_lock_key( string $kind, string $ip ): string {
return 'csdt_devtools_bf_ip_' . $kind . '_' . md5( $ip );
}
/**
* Failures from one address allowed inside the lockout window, whatever the
* username. Four times the per-account allowance: a person mistyping one
* password does not reach it, a script spraying one password across a list
* of usernames does inside a minute.
*
* @return int
*/
public static function ip_lock_threshold(): int {
$per_account = max( 1, (int) CSDT_Options::get( 'csdt_devtools_brute_force_attempts' ) );
return max( 10, $per_account * 4 );
}
/**
* A failed login from an address: count it, and lock the address at the threshold.
*
* WHY: the per-account lockout is keyed on the USERNAME (`csdt_devtools_bf_lock_`
* plus its hash). That means one address can try two hundred accounts once each
* and never trip anything, and it means anyone who knows the administrator's
* login can lock the administrator out at will by failing five times. The
* auto-blocker counts per address, but only when it has been switched on. This
* is the per-address half that needs no opt-in: a transient counter, a
* transient lock, released by time, nothing written to the blocklist.
*
* The same exemptions as the auto-blocker: the site's own address, a proxy hop
* and an administrator's current address are never counted, so the owner
* cannot lock themselves out from the machine they are working on.
*
* @param string $ip Address that failed.
* @return bool Whether the address is now locked.
*/
public static function note_failed_login_ip( string $ip ): bool {
try {
if ( '' === $ip || CSDT_Options::get( 'csdt_devtools_brute_force_enabled' ) !== '1' ) {
return false;
}
if ( class_exists( 'CSDT_Auto_Block' ) && CSDT_Auto_Block::is_exempt( $ip ) ) {
return false;
}
$window = max( 60, (int) CSDT_Options::get( 'csdt_devtools_brute_force_lockout' ) * MINUTE_IN_SECONDS );
$threshold = self::ip_lock_threshold();
$count_key = self::ip_lock_key( 'count', $ip );
$count = (int) get_transient( $count_key ) + 1;
set_transient( $count_key, $count, $window );
if ( $count < $threshold ) {
return false;
}
$locked = set_transient( self::ip_lock_key( 'lock', $ip ), time() + $window, $window );
delete_transient( $count_key );
if ( ! $locked && is_callable( array( 'CSDT_DevTools', 'log' ) ) ) {
CSDT_DevTools::log( "CSDT_Login: set_transient failed for the per-address lock on {$ip}, lock NOT enforced." );
}
// Once per address per two hours, like the account lock's own alert.
$notif_key = self::ip_lock_key( 'notif', $ip );
if ( ! get_transient( $notif_key ) ) {
set_transient( $notif_key, 1, 2 * HOUR_IN_SECONDS );
self::record_security_event( 'ip_lock', sprintf( 'Address %s locked out of the login form', $ip ), sprintf( '%d failed logins across any usernames inside %d minutes', $threshold, (int) ( $window / MINUTE_IN_SECONDS ) ) );
$site = wp_specialchars_decode( get_bloginfo( 'name' ) ?: home_url(), ENT_QUOTES );
self::send_alert(
sprintf( '[%s] Address locked out after password spraying: %s', $site, $ip ),
sprintf(
"%s failed %d logins inside %d minutes across any usernames and is locked out of the login form for %d minutes.\n\nThis is one address trying many accounts, which the per-account lock cannot see. The auto-blocker (Protect > Blocking) turns a repeat into a block on the address.\n\nLogin attempts: %s",
$ip,
$threshold,
(int) ( $window / MINUTE_IN_SECONDS ),
(int) ( $window / MINUTE_IN_SECONDS ),
admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG . '&tab=login' )
),
'high',
'no_entry,warning'
);
}
return $locked;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return false;
}
}
/**
* The error to return when the address is locked, or null.
*
* Generic on purpose: the message never says which usernames exist, and it
* never says the lock is per address, so a script learns nothing from it
* that it did not already know.
*
* @param string $ip Address.
* @return \WP_Error|null
*/
public static function ip_lock_error( string $ip ) {
if ( '' === $ip ) {
return null;
}
$until = get_transient( self::ip_lock_key( 'lock', $ip ) );
if ( false === $until ) {
return null;
}
$remaining = (int) $until - time();
if ( $remaining <= 0 ) {
delete_transient( self::ip_lock_key( 'lock', $ip ) );
return null;
}
$mins = max( 1, (int) ceil( $remaining / 60 ) );
return new \WP_Error(
'csdt_devtools_address_locked',
sprintf(
/* translators: %d: minutes remaining */
_n( 'Too many failed login attempts. Please try again in %d minute.', 'Too many failed login attempts. Please try again in %d minutes.', $mins, 'cloudscale-cyber-devtools' ),
$mins
)
);
}
public static function failed_login_signal(): array {
$log = get_option( 'csdt_devtools_bf_log', array() );
if ( ! is_array( $log ) ) {
$log = array();
}
$now = time();
$h1 = 0;
$h24 = 0;
$newest = 0;
$own = 0;
// Which addresses, and the busiest one. A message that says "10 failed logins" and does not
// say from where cannot be acted on: the reader's only next step is to go and find out, which
// is the part the alert was supposed to save them.
$by_ip = array();
$can_ask = class_exists( 'CSDT_Auto_Block' ) && method_exists( 'CSDT_Auto_Block', 'is_exempt' );
foreach ( $log as $entry ) {
$when = isset( $entry[0] ) ? (int) $entry[0] : 0;
if ( $when < $now - DAY_IN_SECONDS ) {
continue;
}
$ip = isset( $entry[2] ) ? (string) $entry[2] : '';
// An address we would never block is not an attacker. Counted separately rather than
// dropped, so the panel can still say "and 10 from your own devices" instead of
// pretending nothing happened.
if ( $can_ask && '' !== $ip && CSDT_Auto_Block::is_exempt( $ip ) ) {
++$own;
continue;
}
++$h24;
if ( $when >= $now - HOUR_IN_SECONDS ) {
++$h1;
if ( '' !== $ip ) {
$by_ip[ $ip ] = ( $by_ip[ $ip ] ?? 0 ) + 1;
}
if ( $when > $newest ) {
$newest = $when;
}
}
}
arsort( $by_ip );
$top_ip = '' !== (string) key( $by_ip ) ? (string) key( $by_ip ) : '';
return array(
'h1' => $h1,
'h24' => $h24,
'newest' => $newest,
'own' => $own,
'top_ip' => $top_ip,
'top_hits' => $top_ip !== '' ? (int) $by_ip[ $top_ip ] : 0,
'sources' => count( $by_ip ),
);
}
private static function get_client_ip(): string {
return CSDT_DevTools::get_client_ip();
}
// ─── Security event log ──────────────────────────────────────────────
/** Total security events kept. One option row, so this can be generous. */
const SEC_EVENT_MAX = 60;
/**
* Most of the log any ONE type may occupy. See record_security_event() for why.
* 40% of the cap, so two noisy types still cannot fill it between them.
*/
const SEC_EVENT_MAX_PER_TYPE = 24;
/**
* Append an entry to the security events log (shown on the dashboard widget).
*
* Rolling 30-day window, capped, and, since 1.10.513, capped PER TYPE as well.
* That last part is the whole point of this docblock.
*
* It was a flat 20 entries with `array_shift()` on overflow: plain FIFO. That is fine
* while events arrive at similar rates and silently destroys the log when they do not.
* The 7-day wp-login probe rule blocks addresses continuously, 130 in fifteen hours on
* the live site, and each one wrote here, evicting one older event. Every one of the 20
* slots was found holding a probe auto-block. Gone with them: 2FA downgrades, REST
* failures, and the api_attack entries that the API attack log reads out of this same
* option over a FOURTEEN-day window. That panel was showing nothing, and looked for all
* the world like a site with no API attacks.
*
* The probe path's own comment says "the admin log keeps every block individually,
* always". Under FIFO that was simply untrue, and untrue in the direction that hides
* attacks rather than inventing them.
*
* So overflow no longer drops the oldest entry outright. It drops the oldest entry of
* whichever type is currently most numerous, which means a flood of one kind of event
* can never evict the last example of another kind. The newest event always survives.
*
* @since 1.10.513 Per-type ceiling; cap raised 20 → 60.
* @param string $type 'downgrade' | 'attack' | 'rest_fail' | 'api_attack' | 'alert'
* @param string $title Short description shown in the widget.
* @param string $detail Extra context (username, IP, etc.)
* @param string $level For type 'alert': critical|error|warning|info. Empty otherwise.
*/
public static function record_security_event( string $type, string $title, string $detail = '', string $level = '' ): void {
$events = get_option( 'csdt_security_events', array() );
if ( ! is_array( $events ) ) {
$events = array();
}
$cutoff = time() - 30 * DAY_IN_SECONDS;
$events = array_values( array_filter( $events, fn( $e ) => ( $e['time'] ?? 0 ) >= $cutoff ) );
$event = array(
'time' => time(),
'type' => $type,
'title' => $title,
'detail' => $detail,
);
if ( '' !== $level ) {
$event['level'] = $level;
}
$events[] = $event;
update_option( 'csdt_security_events', self::trim_security_events( $events ), false );
}
/**
* Enforce the total and per-type ceilings, oldest-of-the-noisiest-type first.
*
* Separated out so it is testable without a database, and so the eviction rule is
* stated in one place rather than inline in a writer.
*
* @since 1.10.513
* @param array> $events Events, oldest first.
* @return array> Trimmed events, oldest first.
*/
public static function trim_security_events( array $events ): array {
$events = array_values( $events );
// Per-type ceiling first: drop the oldest of any type that is over its share.
$counts = array();
foreach ( $events as $e ) {
$t = (string) ( $e['type'] ?? '' );
$counts[ $t ] = ( $counts[ $t ] ?? 0 ) + 1;
}
foreach ( $counts as $type => $n ) {
$excess = $n - self::SEC_EVENT_MAX_PER_TYPE;
if ( $excess < 1 ) {
continue;
}
foreach ( $events as $i => $e ) {
if ( $excess < 1 ) {
break;
}
if ( (string) ( $e['type'] ?? '' ) === (string) $type ) {
unset( $events[ $i ] );
--$excess;
}
}
$events = array_values( $events );
}
// Then the total cap: always take from whichever type has the most to spare.
// The number to drop is worked out up front rather than re-counting each pass, so
// the loop has a fixed bound and PHPCS's DisallowSizeFunctionsInLoops is satisfied.
$excess_total = count( $events ) - self::SEC_EVENT_MAX;
for ( $drop = 0; $drop < $excess_total; $drop++ ) {
$counts = array();
foreach ( $events as $e ) {
$t = (string) ( $e['type'] ?? '' );
$counts[ $t ] = ( $counts[ $t ] ?? 0 ) + 1;
}
arsort( $counts );
$noisiest = (string) array_key_first( $counts );
$removed = false;
foreach ( $events as $i => $e ) {
if ( (string) ( $e['type'] ?? '' ) === $noisiest ) {
unset( $events[ $i ] );
$removed = true;
break;
}
}
if ( ! $removed ) {
// Cannot happen, the noisiest type is present by construction, but an
// unbounded while loop with no guaranteed exit is not something to ship.
array_shift( $events );
}
$events = array_values( $events );
}
return array_values( $events );
}
// ─── Telegram helper ──────────────────────────────────────────────────
public static function send_alert( string $title, string $body, string $priority = 'high', string $tags = 'warning' ): void {
$level_map = array(
'urgent' => 'critical',
'high' => 'error',
'default' => 'warning',
'low' => 'info',
);
$level = $level_map[ $priority ] ?? 'warning';
try {
CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $title . "\n\n" . $body, 'Login Monitor', $level );
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
// ─── Hook: alert on ANY write to a security-critical option ──────────
// Catches WP-CLI, direct DB edits, or any path that bypasses ajax_login_save.
private const SECURITY_OPTION_ALERTS = array(
'csdt_devtools_login_hide_enabled' => array(
'off_val' => '0',
'label' => 'Hide Login URL has been DISABLED, wp-login.php is now publicly accessible.',
),
'csdt_devtools_brute_force_enabled' => array(
'off_val' => '0',
'label' => 'Brute-force account lockout has been DISABLED.',
),
'csdt_devtools_2fa_force_admins' => array(
'off_val' => '0',
'label' => 'Force 2FA for administrators has been TURNED OFF.',
),
'csdt_devtools_enum_protect' => array(
'off_val' => '0',
'label' => 'Account enumeration protection has been DISABLED.',
),
// The method itself, not only the admin enforcement flag: `wp option update
// csdt_devtools_2fa_method off` switched every second factor off site-wide and
// nothing said so, because only the UI save path alerted.
'csdt_devtools_2fa_method' => array(
'off_val' => 'off',
'label' => 'Two-factor authentication has been switched OFF site-wide (method set to off).',
),
);
public static function on_option_updated( string $option, $old_value, $new_value ): void {
try {
if ( ! isset( self::SECURITY_OPTION_ALERTS[ $option ] ) ) {
return;
}
$cfg = self::SECURITY_OPTION_ALERTS[ $option ];
// Only alert when the value is being switched OFF (old was on, new is off).
if ( (string) $old_value !== $cfg['off_val'] && (string) $new_value === $cfg['off_val'] ) {
$ip = self::get_client_ip();
$who = is_user_logged_in() ? wp_get_current_user()->user_login : 'system/cli';
self::record_security_event( 'downgrade', $cfg['label'], "By: {$who} from {$ip}" );
self::send_alert( 'Security control downgraded', $cfg['label'] . "\n\nChanged by: {$who}\nIP: {$ip}", 'urgent', 'rotating_light,warning' );
}
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
// ─── Hook: alert on failed login attempt ──────────────────────────────
/**
* Hooked to `wp_login_failed`. Fires a Telegram alert based on whether the
* username is a known WordPress account (valid) or not (enumeration attempt).
*/
public static function on_login_failed( string $username ): void {
try {
// Skip REST API / application-password failures, those are handled by
// on_rest_auth_failed() via application_password_failed_authentication.
// Both hooks fire for the same REST request, so we'd send two alerts.
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
// Also skip if this looks like a Basic-Auth REST call (REST_REQUEST may not
// be defined yet when application_password_failed_authentication fires first).
$auth_hdr = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ?? '' ) );
if ( '' === $auth_hdr ) {
$auth_hdr = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '' ) );
}
if ( $auth_hdr && stripos( $auth_hdr, 'Basic ' ) === 0 ) {
return;
}
$notify_valid = CSDT_Options::get( 'csdt_telegram_login_valid_user' ) === '1';
$notify_invalid = CSDT_Options::get( 'csdt_telegram_login_invalid_user' ) === '1';
$is_valid_user = (bool) get_user_by( 'login', $username );
if ( ! $is_valid_user ) {
$is_valid_user = (bool) get_user_by( 'email', $username );
}
$ip = self::get_client_ip();
// Always record valid-username attacks to the security event log, this
// powers the "accounts targeted" count in the widget regardless of alert settings.
if ( $is_valid_user ) {
self::record_security_event(
'attack',
"Login attack on '{$username}'",
"IP: {$ip}"
);
}
// Telegram alerts, only fire if the relevant option is enabled.
if ( ! $notify_valid && ! $notify_invalid ) {
return;
}
if ( $is_valid_user && ! $notify_valid ) {
return;
}
if ( ! $is_valid_user && ! $notify_invalid ) {
return;
}
// Country code (was missing/undefined in previous version).
$cc = $ip ? CSDT_Geo::get_country( $ip ) : '';
$location = $ip . ( $cc ? " \xc2\xb7 {$cc}" : '' );
// Attempt count and lockout status. record_failed_login() runs first, same hook, same
// priority, registered earlier in cloudscale-cyber-devtools.php, so the counters are already updated
// by the time we are here. It used to be CSDT_Perf_Monitor::perf_track_failed_login();
// it moved into this class on 2026-08-17, which changed the name and nothing else.
$slug = md5( strtolower( $username ) );
$count_key = 'csdt_devtools_bf_count_' . $slug;
$lock_key = 'csdt_devtools_bf_lock_' . $slug;
$max_attempts = max( 1, (int) CSDT_Options::get( 'csdt_devtools_brute_force_attempts' ) );
$lockout_mins = max( 1, (int) CSDT_Options::get( 'csdt_devtools_brute_force_lockout' ) );
$just_locked = (bool) get_transient( $lock_key );
$attempt_num = $just_locked ? $max_attempts : max( 1, (int) get_transient( $count_key ) );
if ( $just_locked ) {
$status = "BLOCKED: locked for {$lockout_mins} min after {$max_attempts} failed attempt(s)";
} else {
$remaining = max( 0, $max_attempts - $attempt_num );
$status = "Attempt {$attempt_num} of {$max_attempts}, {$remaining} more before lockout";
}
// Warn if the 'username' looks like a password typed in the wrong field.
// Heuristic: 8+ chars with both digits and non-alphanumerics (e.g. "ndrew-30@@&").
$looks_like_pw = strlen( $username ) >= 8
&& (bool) preg_match( '/[0-9]/', $username )
&& (bool) preg_match( '/[^a-zA-Z0-9]/', $username );
$pw_warning = $looks_like_pw
? "\nWARNING: username looks like a password, typed in wrong field?"
: '';
if ( $is_valid_user ) {
$priority = $just_locked ? 'urgent' : 'high';
$tags = $just_locked ? 'rotating_light,lock' : 'rotating_light';
$title = $just_locked
? 'Account locked: brute-force threshold hit'
: 'Failed login: known account targeted';
$body = "Account: {$username}\nIP: {$location}\n{$status}{$pw_warning}";
} else {
$priority = 'default';
$tags = 'warning';
$title = 'Login probe: unknown account';
$body = "Unknown account: {$username}\nIP: {$location}\n{$status}{$pw_warning}";
}
self::send_alert( $title, $body, $priority, $tags );
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
// ─── Hook: alert on REST API application-password failure ─────────────
/**
* Hooked to `application_password_failed_authentication`.
* Fires when a REST API request uses bad application-password credentials.
*/
public static function on_rest_auth_failed( \WP_Error $error ): void {
try {
$ip = self::get_client_ip();
$cc = $ip ? CSDT_Geo::get_country( $ip ) : '';
// ── Gather every useful signal available at this hook ──────────────
// Path probed, strip query string to avoid leaking tokens.
$raw_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$path = strtok( $raw_uri, '?' ) ?: '/wp-json/';
// HTTP method.
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'POST';
// Auth type: Basic vs Bearer vs other.
$auth_hdr = isset( $_SERVER['HTTP_AUTHORIZATION'] )
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) )
: ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] )
? sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) )
: '' );
if ( stripos( $auth_hdr, 'Basic ' ) === 0 ) {
$auth_type = 'Basic Auth (Application Password)';
} elseif ( stripos( $auth_hdr, 'Bearer ' ) === 0 ) {
$auth_type = 'Bearer Token';
} else {
$auth_type = $auth_hdr ? 'Unknown (' . substr( $auth_hdr, 0, 10 ) . '…)' : 'Basic Auth';
}
// Username tried, available in PHP_AUTH_USER before WP resets it.
$tried_user = isset( $_SERVER['PHP_AUTH_USER'] )
? sanitize_user( wp_unslash( $_SERVER['PHP_AUTH_USER'] ), true )
: '';
// Generic reason only, never reveal whether the username exists or not.
// Suppressing the WP error code (incorrect_password vs invalid_username)
// prevents account enumeration via the notification channel.
$reason = 'Authentication failed';
// User agent (truncated).
$ua = isset( $_SERVER['HTTP_USER_AGENT'] )
? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 80 )
: '·';
// ── Record + alert ─────────────────────────────────────────────────
$detail = "IP: {$ip}" . ( $cc ? " · {$cc}" : '' ) . ( $tried_user ? " · user:{$tried_user}" : '' );
self::record_security_event( 'api_attack', 'REST API auth failure', $detail );
$body = "🔌 REST API brute-force attempt\n";
$body .= "─────────────────────────\n";
$body .= "Path: {$method} {$path}\n";
$body .= "Auth type: {$auth_type}\n";
$body .= 'Username: ' . ( $tried_user ?: '(none)' ) . "\n";
$body .= "IP: {$ip}" . ( $cc ? " · {$cc}" : '' ) . "\n";
$body .= "Reason: {$reason}\n";
$body .= "Agent: {$ua}";
if ( get_option( 'csdt_telegram_rest_auth_fail', '1' ) === '1' ) {
self::send_alert( 'REST API auth failure', $body, 'high', 'rotating_light' );
}
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
}
}
/**
* Hooked to `rest_authentication_errors` at priority 1 (early).
* Returns a 400 Bad Request immediately when a Basic Auth header is present
* but contains no username, this is a malformed request from a bot/scanner.
* A legitimate client always provides a username.
*/
public static function rest_reject_empty_basic_auth( $result ) {
try {
// Only act if no authentication has been established yet.
if ( $result !== null ) {
return $result;
}
$auth = isset( $_SERVER['HTTP_AUTHORIZATION'] )
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) )
: ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] )
? sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) )
: '' );
if ( ! $auth || stripos( $auth, 'Basic ' ) !== 0 ) {
return $result; // Not Basic Auth, leave alone.
}
// Decode Basic Auth credentials.
$decoded = base64_decode( substr( $auth, 6 ), true );
$username = $decoded ? (string) strstr( $decoded, ':', true ) : '';
// 1. Empty username, malformed request from a bot.
if ( '' === $username ) {
return new \WP_Error( 'rest_bad_request', 'Malformed credentials.', array( 'status' => 400 ) );
}
// 2. Username too long, credential stuffing / buffer overflow attempt.
// WP usernames have a practical limit of ~60 chars; reject anything over 100.
if ( strlen( $username ) > 100 ) {
return new \WP_Error( 'rest_bad_request', 'Malformed credentials.', array( 'status' => 400 ) );
}
// 3. Invalid characters, WP usernames are alphanumeric + limited punctuation.
// Reject control characters, null bytes, angle brackets, SQL-injection fragments.
if ( preg_match( '/[\x00-\x1f\x7f<>\'";\\\\]/', $username ) ) {
return new \WP_Error( 'rest_bad_request', 'Malformed credentials.', array( 'status' => 400 ) );
}
return $result;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $result;
}
}
/**
* Hooked to `rest_authentication_errors` at priority 99.
* Replaces WP's default credential-revealing error ("Unknown username",
* "The provided password is an application password") with a generic 401
* so attackers cannot enumerate valid usernames via the REST API.
*/
public static function rest_generic_auth_error( $result ) {
try {
// Only replace WP's own auth errors, leave other errors (e.g. nonce failures) untouched.
if ( is_wp_error( $result ) ) {
$reveal_codes = array(
'invalid_username',
'invalid_email',
'incorrect_password',
'application_passwords_disabled',
'application_passwords_disabled_for_user',
);
foreach ( $reveal_codes as $code ) {
if ( $result->get_error_message( $code ) ) {
return new \WP_Error(
'rest_forbidden',
'Authentication failed.',
array( 'status' => 401 )
);
}
}
}
return $result;
} catch ( \Throwable $e ) {
CSDT_Monitor::log_exception( $e, __METHOD__ );
return $result;
}
}
// ─── Hook: alert on security control downgrade ────────────────────────
/**
* Called from ajax_login_save() before writing new settings.
* Fires an alert for any security control that is being turned off or weakened.
*/
private static function check_settings_downgrade( array $old, array $new ): void {
$alerts = array();
// Hide Login disabled.
if ( $old['hide_enabled'] === '1' && $new['hide_enabled'] === '0' ) {
$alerts[] = 'Hide Login URL has been DISABLED, wp-login.php is now publicly accessible.';
}
// Brute-force protection disabled.
if ( $old['bf_enabled'] === '1' && $new['bf_enabled'] === '0' ) {
$alerts[] = 'Brute-force account lockout has been DISABLED.';
}
// Force 2FA turned off.
if ( $old['force_2fa'] === '1' && $new['force_2fa'] === '0' ) {
$alerts[] = 'Force 2FA for administrators has been TURNED OFF.';
}
// 2FA method turned off.
if ( $old['tfa_method'] !== 'off' && $new['tfa_method'] === 'off' ) {
$alerts[] = 'Site-wide 2FA method has been set to OFF (was: ' . $old['tfa_method'] . ').';
}
// Account enumeration protection disabled.
if ( $old['enum_protect'] === '1' && $new['enum_protect'] === '0' ) {
$alerts[] = 'Account enumeration protection has been DISABLED.';
}
if ( empty( $alerts ) ) {
return;
}
$who = wp_get_current_user();
$name = $who && $who->exists() ? $who->user_login : 'unknown';
$ip = self::get_client_ip();
$body = implode( "\n", $alerts ) . "\n\nChanged by: {$name}\nIP: {$ip}";
// Record every downgrade to the security event log for the widget.
foreach ( $alerts as $alert ) {
self::record_security_event( 'downgrade', $alert, "By: {$name} from {$ip}" );
}
self::send_alert( 'Security control downgraded', $body, 'urgent', 'rotating_light,warning' );
}
}