$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(); ?>
🌍 'Where the country comes from', 'rec' => 'How it works', 'html' => 'Cloudflare tells us the country on every request in the CF-IPCountry header, at no cost and with no lookup. When that header is absent the downloaded DB-IP Lite database is used instead.

If neither can identify a visitor, the visitor is allowed. A location that cannot be determined is not treated as suspicious, because refusing on a failed lookup is how a geo rule takes a site offline.', ), array( 'name' => 'The lockout risk, honestly', 'rec' => 'Read this first', 'html' => 'A country rule is much easier to trip by accident than an IP block. Travelling, a VPN exit in another country, or a mobile network routing through a neighbouring one will all change your apparent country.

Three guards are in place. You are never refused while signed in as an administrator. The country you are browsing from now cannot be added to a block list, and cannot be left out of an allow list, and the save is refused rather than merely warned about. And an allow list with nothing in it will not save at all, because it would refuse the entire internet.

What none of that covers: if you are signed out and travelling, and your temporary country is blocked, you will not be able to reach the login page. Keep Login and admin only scope and a short list, or use an allow list containing every country you actually travel to.', ), array( 'name' => 'Block list or allow list', 'rec' => 'Choose carefully', 'html' => 'Block list refuses the countries you name and allows everyone else. This is the safer choice and the default: a mistake affects one country.

Allow list refuses everyone except the countries you name. Much stronger, and appropriate for a site serving one region, but a mistake affects the whole world. Only use it if you know where your genuine visitors are.', ), array( 'name' => 'Scope', 'rec' => 'Recommended', 'html' => 'Login and admin only covers 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.

Whole site refuses every request, including public pages. Use it only when you are certain no genuine reader or crawler comes from those countries.', ), array( 'name' => 'What it does not do', 'rec' => 'Honest limits', 'html' => 'A country rule is a filter, not a wall. A determined attacker uses a VPN or a proxy in a permitted country and the rule sees that country instead. It is worth having because it removes a large amount of automated noise cheaply, not because it stops a targeted attacker.

It also does not apply to WP-CLI or scheduled tasks, and never refuses your own server\'s address.', ), ) ); ?>

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

$n ) { $parts[] = esc_html( $cc ) . ' (' . (int) $n . ')'; } echo esc_html( implode( ', ', $parts ) ); ?>