120, 'ajax' => 120, 'xmlrpc' => 30, 'comment' => 10, ); /** Human labels for the request classes, for the panel. */ public static function class_labels(): array { return array( 'rest' => __( 'REST API (/wp-json/)', 'cloudscale-cyber-devtools' ), 'ajax' => __( 'admin-ajax.php (anonymous)', 'cloudscale-cyber-devtools' ), 'xmlrpc' => __( 'xmlrpc.php', 'cloudscale-cyber-devtools' ), 'comment' => __( 'wp-comments-post.php', 'cloudscale-cyber-devtools' ), ); } public static function mode_labels(): array { return array( self::MODE_OFF => __( 'Off', 'cloudscale-cyber-devtools' ), self::MODE_LOG => __( 'Log only (record, refuse nothing)', 'cloudscale-cyber-devtools' ), self::MODE_BLOCK => __( 'Block (429 Too Many Requests)', 'cloudscale-cyber-devtools' ), ); } public static function mode(): string { $mode = (string) get_option( self::OPTION_MODE, self::DEFAULT_MODE ); return in_array( $mode, array( self::MODE_OFF, self::MODE_LOG, self::MODE_BLOCK ), true ) ? $mode : self::DEFAULT_MODE; } public static function is_enabled(): bool { return self::MODE_OFF !== self::mode(); } public static function is_blocking(): bool { return self::MODE_BLOCK === self::mode(); } /** Per-class limits, after the site's own filter. */ public static function limits(): array { $limits = self::DEFAULT_LIMITS; if ( function_exists( 'apply_filters' ) ) { $filtered = apply_filters( 'csdt_ratelimit_limits', $limits ); if ( is_array( $filtered ) ) { foreach ( self::DEFAULT_LIMITS as $k => $v ) { $limits[ $k ] = max( 1, (int) ( $filtered[ $k ] ?? $v ) ); } } } return $limits; } /** * Whether a counter can be kept at all. * * Only a persistent object cache gives an atomic increment that survives the * request. The default WordPress cache is per-request memory, where every * count would start at zero and the limiter would never fire while reading * as if it did. */ public static function has_counter_store(): bool { return function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() && function_exists( 'wp_cache_incr' ) && function_exists( 'wp_cache_add' ); } /** * Which class of request this is, or '' for one that is not limited. * * Path-based, on the raw request, before WordPress has parsed anything: the * limit has to be cheap and it has to run before the endpoint does. * * @param string $uri REQUEST_URI. * @param string $query QUERY_STRING. * @return string rest|ajax|xmlrpc|comment|'' */ public static function classify( string $uri, string $query = '' ): string { $path = strtolower( (string) wp_parse_url( $uri, PHP_URL_PATH ) ); if ( '' === $path ) { $path = strtolower( $uri ); } if ( preg_match( '#(^|/)xmlrpc\.php$#', $path ) ) { return 'xmlrpc'; } if ( preg_match( '#(^|/)admin-ajax\.php$#', $path ) ) { return 'ajax'; } if ( preg_match( '#(^|/)wp-comments-post\.php$#', $path ) ) { return 'comment'; } if ( preg_match( '#(^|/)wp-json(/|$)#', $path ) || preg_match( '/(^|&)rest_route=/', $query ) ) { return 'rest'; } return ''; } /** * Count this request and decide. Pure of side effects on the response: the * caller exits. Returns what happened so it can be tested. * * @param string $class rest|ajax|xmlrpc|comment. * @param string $ip Client address. * @param int $now Timestamp. * @return array{action:string,count:int,limit:int} action: allow|log|block|inert|exempt */ public static function decide( string $class, string $ip, int $now ): array { $limits = self::limits(); $limit = (int) ( $limits[ $class ] ?? 0 ); if ( $limit < 1 ) { return array( 'action' => 'allow', 'count' => 0, 'limit' => 0 ); } if ( ! self::has_counter_store() ) { update_option( self::OPTION_INERT, $now, false ); return array( 'action' => 'inert', 'count' => 0, 'limit' => $limit ); } if ( get_option( self::OPTION_INERT ) ) { delete_option( self::OPTION_INERT ); } // Fixed one-minute buckets: the bucket is the minute, the TTL is two so a // straddling request still sees the previous count expire on its own. $key = $class . ':' . $ip . ':' . (int) floor( $now / 60 ); wp_cache_add( $key, 0, self::CACHE_GROUP, 120 ); $count = wp_cache_incr( $key, 1, self::CACHE_GROUP ); $count = false === $count ? 1 : (int) $count; if ( $count <= $limit ) { return array( 'action' => 'allow', 'count' => $count, 'limit' => $limit ); } return array( 'action' => self::is_blocking() ? 'block' : 'log', 'count' => $count, 'limit' => $limit ); } /** * plugins_loaded, priority 4: after the blocklist, country rules and the firewall. */ public static function enforce(): void { try { $verdict = self::evaluate_request(); if ( 'block' === $verdict ) { header( 'Retry-After: 60' ); // Set here as well: core's XML-RPC die handler never sets a status, so xmlrpc.php would answer 200. status_header( 429 ); wp_die( esc_html__( 'Too many requests.', 'cloudscale-cyber-devtools' ), '', array( 'response' => 429 ) ); } } 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__ ); } } } /** * Everything enforce() does except the exit, so it can be driven by a test. * * @return string allow|log|block|skip|inert|exempt */ public static function evaluate_request(): string { if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return 'skip'; } if ( ! self::is_enabled() ) { return 'skip'; // One autoloaded read on the common path. } // plugins_loaded runs before wp_magic_quotes(), so $unslash is false. Path // classification only; never echoed or stored. $uri = CSDT_Request::server( 'REQUEST_URI', 8192, false ); $query = CSDT_Request::server( 'QUERY_STRING', 8192, false ); $class = self::classify( $uri, $query ); if ( '' === $class ) { return 'skip'; } $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; if ( '' === $ip ) { return 'skip'; } // Own egress, proxy hops, an administrator's own address: never counted, // the same exemptions the auto-blocker applies. if ( class_exists( 'CSDT_Auto_Block' ) && CSDT_Auto_Block::is_exempt( $ip ) ) { return 'exempt'; } // A signed-in user is not the traffic this limits: the block editor alone // makes dozens of REST calls a minute. The cookie is validated, not merely // present, so a forged one buys nothing. if ( function_exists( 'wp_validate_auth_cookie' ) && false !== wp_validate_auth_cookie( '', 'logged_in' ) ) { return 'exempt'; } $d = self::decide( $class, $ip, time() ); if ( 'log' === $d['action'] || 'block' === $d['action'] ) { self::record( $ip, $class, $d ); } return $d['action']; } /** * A request over the limit: into the match log, and counted by the auto-blocker. * * The match log is what the panel reads; the auto-blocker is what turns a * sustained flood into a block on the address, on its own threshold and ladder. * Only over-limit requests reach here, so this is not a write per request. * * @param string $ip Client address. * @param string $class Request class. * @param array $d decide() output. */ private static function record( string $ip, string $class, array $d ): void { try { // One log row per address per class per minute, not one per refused // request: the count is in the row, and a flood would otherwise write a // row per request, which is the cost this design exists to avoid. if ( (int) $d['count'] !== (int) $d['limit'] + 1 ) { return; } if ( class_exists( 'CSDT_Storage' ) ) { CSDT_Storage::record_match( $ip, self::RULE_PREFIX . $class, 'block' === $d['action'] ? 'blocked' : 'logged', array( 'limit' => (int) $d['limit'] ) ); } if ( class_exists( 'CSDT_Auto_Block' ) ) { CSDT_Auto_Block::record( 'ratelimit', $ip ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** For the liveness checker. */ public static function status(): array { return array( 'mode' => self::mode(), 'enabled' => self::is_enabled(), 'store' => self::has_counter_store(), 'inert_at' => (int) get_option( self::OPTION_INERT, 0 ), ); } // ── Admin ────────────────────────────────────────────────────────────── 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 ); } $mode = sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ); if ( ! in_array( $mode, array( self::MODE_OFF, self::MODE_LOG, self::MODE_BLOCK ), true ) ) { wp_send_json_error( __( 'Unknown mode.', 'cloudscale-cyber-devtools' ) ); return; } // Refused rather than saved-and-inert: a Block that counts nothing is the // product contradicting itself, and the panel already explains why. if ( self::MODE_BLOCK === $mode && ! self::has_counter_store() ) { wp_send_json_error( __( 'Block needs a persistent object cache (Redis or Memcached). Without one there is no counter to compare against, so nothing would be refused. Install one under Performance > Object Cache first.', 'cloudscale-cyber-devtools' ) ); return; } update_option( self::OPTION_MODE, $mode ); wp_send_json_success( array( 'msg' => self::MODE_OFF === $mode ? __( 'Saved. Request limiting is off.', 'cloudscale-cyber-devtools' ) : __( 'Saved.', 'cloudscale-cyber-devtools' ), ) ); } public static function render_panel(): void { $mode = self::mode(); $store = self::has_counter_store(); $limits = self::limits(); $counts = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::match_counts() : null; $log_broken = null === $counts; ?>
⏱️ 'Volume, not shape', 'rec' => 'Read this first', 'html' => 'The request firewall matches the SHAPE of a payload. This counts VOLUME: how many anonymous requests one address makes to the REST API, to admin-ajax.php, to xmlrpc.php and to wp-comments-post.php (comment submissions) in a minute. User enumeration a page at a time, credential stuffing through the REST auth path, XML-RPC amplification, a hammered anonymous ajax action and a comment flood each look harmless one request at a time. Limits per address per minute: REST ' . (int) $limits['rest'] . ', admin-ajax ' . (int) $limits['ajax'] . ', XML-RPC ' . (int) $limits['xmlrpc'] . ', comments ' . (int) $limits['comment'] . '. Comment submissions follow this panel\'s mode like the others: in Log only an address over the limit is recorded and its comment still goes through. Signed-in users are never counted (the block editor alone makes dozens of REST calls a minute), and an administrator is never refused.', ), array( 'name' => 'Counted in memory, never in the database', 'rec' => 'How it works', 'html' => 'Each request is one atomic increment in the persistent object cache (Redis or Memcached), which is the only place a per-request counter is affordable on the busiest endpoints a site has. Nothing is written to the database on the request path. Only a request OVER the limit is recorded, once per address per minute, in the same match log as a firewall rule, and counted by the auto-blocker so a sustained flood becomes a block on the address.', ), array( 'name' => 'No object cache, no limiter', 'rec' => 'Honest limits', 'html' => 'Without a persistent object cache there is no counter that survives the request, so the limiter is INERT and this panel says so rather than reading as protection. Block mode is refused until one is installed (Performance > Object Cache). This is deliberate: a control that silently counted nothing while showing "Block" is the failure this plugin exists to expose.', ), array( 'name' => 'Start in Log only', 'rec' => 'Recommended', 'html' => 'Log only records what would have been refused and refuses nothing. Read the counts for a few days before switching to Block: a headless front end or a busy comment form can be a legitimate high-rate anonymous client, and the limits can be raised per site with the csdt_ratelimit_limits filter.', ), ) ); ?>
Object Cache.', 'cloudscale-cyber-devtools' ); ?>
$label ) { $stat = is_array( $counts ) ? ( $counts[ self::RULE_PREFIX . $class ] ?? array() ) : array(); echo ' '; printf( /* translators: 1: request class, 2: over-limit minutes, 3: addresses. */ esc_html__( '%1$s: %2$s over-limit minutes from %3$s addresses.', 'cloudscale-cyber-devtools' ), esc_html( $label ), esc_html( number_format_i18n( (int) ( $stat['hits'] ?? 0 ) ) ), esc_html( number_format_i18n( (int) ( $stat['ips'] ?? 0 ) ) ) ); } ?>