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.

✔️ Verify Email & Activate 2FA

⏰ This link expires in 1 hour.

Or copy this URL: ' . esc_html( $verify_url ) . '

'; 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' ); } }