$rows ) { if ( (string) $day < $cutoff || ! is_array( $rows ) ) { continue; } foreach ( $rows as $cc => $n ) { $totals[ $cc ] = (int) ( $totals[ $cc ] ?? 0 ) + (int) $n; } } arsort( $totals ); return $totals; } /** * Saves the settings, refusing any combination that would lock the owner out. * * The refusals are deliberate and are NOT warnings. A warning beside a Save * button is read as decoration; the only reliable guard against a self-inflicted * outage is to decline the save and say why. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $raw_enabled = isset( $_POST['enabled'] ) ? sanitize_text_field( wp_unslash( $_POST['enabled'] ) ) : ''; $enabled = ( '' !== $raw_enabled && '0' !== $raw_enabled ); $mode = 'allow' === sanitize_text_field( wp_unslash( $_POST['mode'] ?? 'block' ) ) ? 'allow' : 'block'; $scope = 'site' === sanitize_text_field( wp_unslash( $_POST['scope'] ?? 'login' ) ) ? 'site' : 'login'; $codes = self::parse_codes( sanitize_text_field( wp_unslash( $_POST['countries'] ?? '' ) ) ); $mine = self::admin_country(); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } // Outside the try on purpose. A refusal here is the guard working as designed, // and wrapping it would let the catch-all rewrite a precise explanation into // "Internal error", which is exactly what happened the first time this ran. $refusal = self::validate( $enabled, $mode, $codes, $mine ); if ( '' !== $refusal ) { wp_send_json_error( $refusal ); return; } update_option( self::OPTION_ENABLED, $enabled ? '1' : '0' ); update_option( self::OPTION_MODE, $mode ); update_option( self::OPTION_SCOPE, $scope ); update_option( self::OPTION_COUNTRIES, implode( ', ', $codes ) ); wp_send_json_success( array( 'msg' => $enabled ? 'Saved and active.' : 'Saved. Country rules are off.', 'countries' => implode( ', ', $codes ), ) ); } /** * Refuses the request when country rules say so. Hooked to `plugins_loaded` * priority 1, alongside the IP blocklist, so a refusal never reaches another * plugin's startup code. * * Reuses CSDT_Login::request_is_site_admin() rather than reimplementing it: two * copies of a lockout guard is two places for it to drift apart, and this one * carries the same consequence. */ public static function enforce(): void { try { // Neither carries a meaningful client address, and refusing cron would // break every scheduled task. if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return; } // One autoloaded read on the common path, so a site not using this pays // almost nothing for it. if ( CSDT_Options::get( self::OPTION_ENABLED ) !== '1' ) { return; } if ( 'site' !== self::scope() && ! self::is_login_or_admin_request() ) { return; } $reason = self::refuse_reason( CSDT_DevTools::get_client_ip() ); if ( '' === $reason ) { return; } // Never refuse an administrator. Country is far easier to trip // accidentally than an IP: travel, a VPN exit, or a mobile network // routing through another country all do it. if ( class_exists( 'CSDT_Login' ) && CSDT_Login::request_is_site_admin() ) { return; } self::record_refusal( $reason ); // 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 ) { // A geo lookup problem must never be fatal. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Whether this request targets the login form or the admin. * * is_admin() covers wp-admin, admin-ajax.php and admin-post.php. wp-login.php * reaches none of those, so it is matched on the script name, and the hidden * login slug is matched by the constant CSDT_Login sets when it serves it. */ public static function is_login_or_admin_request(): bool { if ( is_admin() ) { return true; } if ( defined( 'CSDT_LOGIN_CUSTOM_SLUG' ) && CSDT_LOGIN_CUSTOM_SLUG ) { return true; } if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return true; } $script = isset( $_SERVER['SCRIPT_NAME'] ) ? basename( sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) ) : ''; return in_array( $script, array( 'wp-login.php', 'xmlrpc.php' ), true ); } /** * Renders the Country Rules panel on the Login Security tab. * * States the lockout risk in the panel rather than burying it in help text, * because the panel is where the decision is made. */ public static function render_panel(): void { $enabled = CSDT_Options::get( self::OPTION_ENABLED ) === '1'; $mode = self::mode(); $scope = self::scope(); $codes = self::countries(); $mine = self::admin_country(); $stats = self::recent_stats(); ?>
CF-IPCountry header, at no cost and with no lookup. When that header is absent the downloaded DB-IP Lite database is used instead.wp-login.php, your hidden login page, xmlrpc.php, admin-ajax.php, admin-post.php and everything under /wp-admin/. Your public pages stay reachable from everywhere, so search engines and readers are unaffected. This blocks the attacks without touching your audience, and is the recommended setting.' . esc_html( $mine ) . '' ); ?>