Log -> Block, * defaulting to Log. A curated feed this site has never seen its own traffic against * must not start refusing visitors the day it is installed. * * @package CSDT_DevTools * @since 1.10.669 */ defined( 'ABSPATH' ) || exit; class CSDT_Ip_Reputation { /** Not evaluated at all. */ const MODE_OFF = 'off'; /** Evaluated and recorded, nothing refused. The shipped default. */ const MODE_LOG = 'log'; /** Evaluated and refused. */ const MODE_BLOCK = 'block'; /** Current mode. */ const OPTION_MODE = 'csdt_ip_reputation_mode'; /** * Off until an admin turns it on. * * Was self::MODE_LOG (log-only, evaluated but nothing refused) until WordPress.org * round 24 (WPORG-AGENT-002) found the daily refresh cron fetching the reputation list * from api.cloudscale.consulting on a default install: MODE_LOG is not MODE_OFF, and * refresh() below runs whenever is_enabled() is true, which log-only already was. * mode_labels() still offers Log only as the recommended first step once turned on. */ const DEFAULT_MODE = self::MODE_OFF; /** The cached CIDR list itself. Not autoloaded: read once per request only when enabled. */ const OPTION_CIDRS = 'csdt_ip_reputation_cidrs'; /** source name, count and fetched_at, bundled, see refresh(). Not autoloaded. */ const OPTION_META = 'csdt_ip_reputation_meta'; /** Set on a failed refresh, cleared on the next success. '' when there is nothing to report. */ const OPTION_LAST_ERROR = 'csdt_ip_reputation_last_error'; /** WP-Cron hook that refills OPTION_CIDRS from the proxy. */ const CRON_HOOK = 'csdt_refresh_ip_reputation'; /** The one upstream this plugin ever contacts for this feature. Already-declared host. */ const PROXY_ENDPOINT = 'https://api.cloudscale.consulting/ip-reputation-feed'; /** * A response with fewer than this many CIDRs is rejected client-side too, even * though the proxy already enforces its own floor, a compromised or buggy proxy * this site depends on must not be able to shrink an enabled feature's coverage to * near nothing without it being visible as a refresh failure. */ const MIN_PLAUSIBLE_CIDRS = 50; /** * Growth ceiling on the stored list. Bounds the per-request CIDR-match cost by * construction; FireHOL level1 has run in the low hundreds for years, so this is * headroom, not a limit anyone is expected to reach. */ const MAX_CIDRS = 4000; /** Rule id this feature logs matches under, in the shared firewall event log. */ const RULE_ID = 'ip_reputation'; /** The three modes, in the order they are offered. Same shape as CSDT_Firewall::mode_labels(). */ public static function mode_labels(): array { return array( self::MODE_OFF => __( 'Off: do not check addresses against the list', 'cloudscale-cyber-devtools' ), self::MODE_LOG => __( 'Log only: record what would be refused, refuse nothing', 'cloudscale-cyber-devtools' ), self::MODE_BLOCK => __( 'Block: refuse matching addresses', 'cloudscale-cyber-devtools' ), ); } /** Current mode, validated. An unrecognised stored value reads as OFF. */ public static function mode(): string { $mode = (string) get_option( self::OPTION_MODE, self::DEFAULT_MODE ); // Validated against the constants, not mode_labels(): this runs on plugins_loaded, // before init, where a translation call is a WordPress 6.7+ notice on every request. return in_array( $mode, array( self::MODE_OFF, self::MODE_LOG, self::MODE_BLOCK ), true ) ? $mode : self::MODE_OFF; } /** Whether the engine should check anything at all. */ public static function is_enabled(): bool { return self::MODE_OFF !== self::mode(); } /** Whether a match should be refused rather than merely recorded. */ public static function is_blocking(): bool { return self::MODE_BLOCK === self::mode(); } /** The cached CIDR list, or an empty array when never fetched. */ public static function cidrs(): array { $cidrs = get_option( self::OPTION_CIDRS, array() ); return is_array( $cidrs ) ? $cidrs : array(); } /** Feed metadata: source, count and fetched_at, or an empty array when never fetched. */ public static function meta(): array { $meta = get_option( self::OPTION_META, array() ); return is_array( $meta ) ? $meta : array(); } /** * Whether this address is on the cached list, and which entry matched. * * Pure and side-effect free, so it can be tested directly and reused unchanged by * both enforce() and the Block-mode save guard. * * @param string $ip Client address, already resolved. * @return string Empty string to allow, otherwise the CIDR that matched. */ public static function refuse_reason( string $ip ): string { if ( ! self::is_enabled() ) { return ''; } if ( '' === $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return ''; } // A private or reserved address is an unresolved proxy hop, exactly as in // CSDT_Auto_Block::is_exempt(), a curated feed of PUBLIC malicious networks has // no legitimate entry here, so treating one as a hit would be the resolver // misbehaving, not a real match. if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return ''; } if ( class_exists( 'CSDT_Login' ) && in_array( $ip, CSDT_Login::own_public_ips(), true ) ) { return ''; } if ( ! class_exists( 'CSDT_DevTools' ) ) { return ''; } foreach ( self::cidrs() as $cidr ) { if ( CSDT_DevTools::ip_matches_cidr( $ip, (string) $cidr ) ) { return (string) $cidr; } } return ''; } /** * Why a promotion to Block must be refused, or '' when it is safe. * * Same shape as CSDT_Geo_Block::validate() and CSDT_Firewall::validate_mode(): a * change that would lock the administrator out is REFUSED, not warned about. Unlike * the firewall's guard this needs no history, a CIDR match is a fact about the * list right now, checkable directly, with no log to consult. * * @param string $mode Requested mode. * @param string $own_ip The administrator's own resolved address, '' if unknown. * @return string Empty when safe, otherwise the reason to show. */ public static function validate_mode( string $mode, string $own_ip ): string { if ( ! array_key_exists( $mode, self::mode_labels() ) ) { return __( 'That is not one of the three modes. Nothing was saved.', 'cloudscale-cyber-devtools' ); } if ( self::MODE_BLOCK !== $mode ) { return ''; // Off and Log cannot refuse anyone, so nothing to guard. } if ( '' === $own_ip ) { // Fails open on purpose: refusing to enable a feature because IT COULD NOT // CHECK is the wrong trade when the feature being enabled is itself a // blocklist. Same posture CSDT_Firewall takes for a broken log. return ''; } $hit = self::refuse_reason( $own_ip ); if ( '' === $hit ) { return ''; } return sprintf( /* translators: 1: the administrator's own address, 2: the CIDR range that matched it. */ __( 'Not switched to Block. Your own address (%1$s) matches a network on the list (%2$s), so Block would start refusing you. This is most often a shared hosting IP that a previous tenant got listed for. Leave this in Log only, or contact support if you believe the address should not be on the list.', 'cloudscale-cyber-devtools' ), $own_ip, $hit ); } // ── Enforcement ────────────────────────────────────────────────────────── /** * Hooked to `plugins_loaded` priority 4, after the IP blocklist (1), country * rules (2) and the pattern-matching firewall (3). Those are all more specific to * THIS site (an address that offended here, a country the owner chose, a payload * shape) than a crowd-sourced list nobody has tuned against this site's traffic, * so they get first refusal and this never pays for a CIDR walk on a request * something cheaper already decided about. */ public static function enforce(): void { try { if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return; } if ( ! self::is_enabled() ) { return; // One autoloaded read on the common path. } $ip = class_exists( 'CSDT_DevTools' ) ? CSDT_DevTools::get_client_ip() : ''; $reason = self::refuse_reason( $ip ); if ( '' === $reason ) { return; } // Never refuse an administrator, same capability bypass as the IP blocklist, // country rules and the firewall. if ( class_exists( 'CSDT_Login' ) && CSDT_Login::request_is_site_admin() ) { return; } self::record_match( $ip, self::is_blocking() ? 'blocked' : 'logged' ); if ( self::is_blocking() ) { // 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 ) { // Fails OPEN, always, same posture as every other control on this path. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Records a match into the shared firewall event log, in Log mode as well as Block. * * ITS OWN try/catch, deliberately separate from enforce()'s. CSDT_Firewall's own * history is the reason: recording used to share the caller's catch, and a throw * while logging was swallowed by the SAME catch that guards the block, so a * broken log silently disabled enforcement too. Recording a failure to record must * never look like a failure to refuse. * * @param string $ip Matched address. * @param string $action 'logged' or 'blocked'. */ private static function record_match( string $ip, string $action ): void { try { if ( class_exists( 'CSDT_Storage' ) ) { CSDT_Storage::record_match( $ip, self::RULE_ID, $action ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } // ── Feed refresh ───────────────────────────────────────────────────────── /** * Refills the cached CIDR list from the proxy. WP-Cron callback, and also called * directly by the panel's "Refresh now" button. * * Never shrinks the stored list to empty on a failure of any kind, a bad fetch * KEEPS the old list, exactly like CSDT_DevTools::refresh_cf_ip_ranges(). An * enabled feature silently losing its whole list because one refresh had a bad * network day would be worse than the list going one day stale. * * @return bool True when the stored list was updated from a fresh fetch. */ public static function refresh(): bool { // Off means no request. The daily event stays scheduled so turning the feature back // on needs no rescheduling, but a list nobody consults is not fetched. if ( ! self::is_enabled() ) { return false; } $resp = wp_remote_get( self::PROXY_ENDPOINT, array( 'timeout' => 20, 'sslverify' => true, 'user-agent' => 'CloudScale-Cyber-DevTools/' . ( defined( 'CSDT_DevTools::VERSION' ) ? CSDT_DevTools::VERSION : '0' ), 'headers' => array( 'Accept' => 'application/json' ), ) ); $r = CSDT_Http::json( $resp ); if ( 0 === $r['code'] ) { // The seconds form (CSDT_Http gives it), not the raw message: WordPress's HTTP // transport reports a curl timeout in milliseconds ("...after 20000 // milliseconds"), which nobody can match against a timeout set in seconds. update_option( self::OPTION_LAST_ERROR, $r['error'], false ); return false; } $code = $r['code']; if ( 200 !== $code ) { /* translators: %d: HTTP status code returned by the proxy. */ update_option( self::OPTION_LAST_ERROR, sprintf( __( 'Feed returned HTTP %d.', 'cloudscale-cyber-devtools' ), $code ), false ); return false; } $data = $r['data']; if ( null === $data || ! isset( $data['cidrs'] ) || ! is_array( $data['cidrs'] ) ) { update_option( self::OPTION_LAST_ERROR, __( 'Feed response was not understood.', 'cloudscale-cyber-devtools' ), false ); return false; } // Validate the SHAPE of each entry rather than trusting the proxy's own // validation blindly: this site's copy is the one actually used to refuse // traffic, so it re-checks rather than inheriting whatever the upstream sent. $cidrs = array(); foreach ( $data['cidrs'] as $cidr ) { $cidr = (string) $cidr; if ( '' === $cidr || strpos( $cidr, '/' ) === false ) { continue; } if ( filter_var( explode( '/', $cidr )[0], FILTER_VALIDATE_IP ) ) { $cidrs[] = $cidr; } if ( count( $cidrs ) >= self::MAX_CIDRS ) { break; } } if ( count( $cidrs ) < self::MIN_PLAUSIBLE_CIDRS ) { /* translators: %d: number of CIDR entries that parsed as valid. */ update_option( self::OPTION_LAST_ERROR, sprintf( __( 'Feed returned an implausibly short list (%d entries); keeping the previous copy.', 'cloudscale-cyber-devtools' ), count( $cidrs ) ), false ); return false; } update_option( self::OPTION_CIDRS, array_values( array_unique( $cidrs ) ), false ); update_option( self::OPTION_META, array( 'source' => sanitize_text_field( (string) ( $data['source'] ?? '' ) ), 'count' => count( $cidrs ), 'fetched_at' => time(), ), false ); update_option( self::OPTION_LAST_ERROR, '', false ); return true; } // ── Save / panel ───────────────────────────────────────────────────────── /** The country-independent address the current administrator is browsing from. */ public static function admin_ip(): string { return class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; } /** * Saves the mode, refusing a promotion to Block that would lock the administrator * out. Same nonce/capability shape as CSDT_Firewall::ajax_save() and * CSDT_Geo_Block::ajax_save(). */ 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 { $mode = sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } // Outside the try, as in CSDT_Firewall::ajax_save(): a refusal here is the // guard working as designed, and the catch-all must not rewrite it into // "Internal error". $refusal = self::validate_mode( $mode, self::admin_ip() ); if ( '' !== $refusal ) { wp_send_json_error( $refusal ); return; } update_option( self::OPTION_MODE, $mode ); wp_send_json_success( array( 'msg' => self::MODE_OFF === $mode ? __( 'Saved. IP reputation checking is off.', 'cloudscale-cyber-devtools' ) : __( 'Saved.', 'cloudscale-cyber-devtools' ), ) ); } /** Manual "Refresh now" button. */ public static function ajax_refresh(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } if ( self::refresh() ) { $meta = self::meta(); wp_send_json_success( array( /* translators: %d: number of CIDR entries now cached. */ 'msg' => sprintf( __( 'Refreshed: %d networks loaded.', 'cloudscale-cyber-devtools' ), (int) ( $meta['count'] ?? 0 ) ), ) ); return; } wp_send_json_error( (string) get_option( self::OPTION_LAST_ERROR, '' ) ?: __( 'Refresh failed.', 'cloudscale-cyber-devtools' ) ); } /** * Renders the IP Reputation panel, on the Firewall tab after the Request Firewall * panel, the network-layer sibling to that pattern-layer control. */ public static function render_panel(): void { $mode = self::mode(); $meta = self::meta(); $count = (int) ( $meta['count'] ?? 0 ); $fetched = (int) ( $meta['fetched_at'] ?? 0 ); $source = (string) ( $meta['source'] ?? '' ); $last_err = (string) get_option( self::OPTION_LAST_ERROR, '' ); $counts = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::match_counts() : null; $stat = is_array( $counts ) ? ( $counts[ self::RULE_ID ] ?? array() ) : null; $log_broken = null === $counts; ?>
🛰️ 'What this checks, and what it does not', 'rec' => 'Read this first', 'html' => 'The request firewall matches attack PATTERNS, a payload shape in a URL or a form field. This checks something different: whether the visiting address itself belongs to a network already known to be hijacked, compromised, or run for abuse. Neither replaces the other.

The list is FireHOL\'s level1 netset (which itself merges Spamhaus DROP/EDROP and several other public feeds), fetched once by CloudScale\'s proxy and shared by every site running this plugin rather than each one polling the source itself.', ), array( 'name' => 'No network call on your request path', 'rec' => 'How it works', 'html' => 'A daily background job pulls the current list into this site\'s own settings. Checking a visitor\'s address is a local comparison against that cached copy, nothing here waits on, or depends on the availability of, any external service while a visitor is on the page. If a refresh fails, the previous list keeps being used rather than the feature going blind or slow.', ), array( 'name' => 'Start in Log only, and stay there for a week', 'rec' => 'Recommended', 'html' => 'Log only records what would have been refused and refuses nothing, the same reasoning as the request firewall\'s own default. A crowd-sourced list has never been tested against your traffic specifically, and shared-hosting addresses occasionally end up on one of these lists because of a previous tenant. Read what it would have refused before switching to Block.', ), array( 'name' => 'The lockout guard', 'rec' => 'Honest limits', 'html' => 'Promoting to Block is REFUSED, by name, if your own current address already matches an entry on the list, most often exactly the shared-hosting scenario above. You are never refused while signed in as an administrator, in any mode.', ), ) ); ?>
0 ) : ?>

' . esc_html( number_format_i18n( $count ) ) . '', '' . esc_html( '' !== $source ? $source : 'unknown' ) . '', esc_html( $fetched > 0 ? human_time_diff( $fetched ) : __( 'never', 'cloudscale-cyber-devtools' ) ) ); ?>