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; ?>