0 || self::MODE_ENFORCE === self::mode(); } /** * Seconds a submission must still wait for the gap; 0 lets it through. * * @param int $last When the last comment was accepted; 0 = never, or unknown. * @param int $gap The gap, seconds; 0 = off. * @param int $now Unix time. * @param bool $exempt Whether the submitter may moderate comments. */ public static function gap_wait( int $last, int $gap, int $now, bool $exempt ): int { // A stamp in the future (a clock that was set back) is ignored, never obeyed. if ( $gap <= 0 || $exempt || $last <= 0 || $last > $now ) { return 0; } return max( 0, $last + $gap - $now ); } /** * Applies the gap to one submission, and stamps it when it is let through. * * @param int $now Unix time. * @param bool $exempt Whether the submitter may moderate comments. * @return int Seconds to wait, 0 when accepted. */ public static function evaluate_gap( int $now, bool $exempt ): int { $gap = self::min_gap(); if ( $gap <= 0 || $exempt ) { return 0; } $wait = self::gap_wait( (int) get_option( self::OPTION_GAP_LAST, 0 ), $gap, $now, false ); if ( 0 === $wait ) { update_option( self::OPTION_GAP_LAST, $now, false ); } else { self::gap_refused( $now, $gap ); } return $wait; } /** * Counts a comment the gap refused, and alerts at most once an hour when there are enough * of them to be a flood rather than a coincidence. * * @param int $now Unix time. * @param int $gap The gap, seconds. */ private static function gap_refused( int $now, int $gap ): void { if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() || ! CSDT_Storage::record_event( self::SITE_KEY, self::GAP_REFUSED_SIGNAL, $now ) ) { return; } $count = CSDT_Storage::count_events( self::SITE_KEY, self::GAP_REFUSED_SIGNAL, $now - self::WINDOW ); if ( null === $count || $count < self::GAP_ALERT_AFTER ) { return; } if ( ! class_exists( 'CSDT_Telegram' ) || '1' !== (string) CSDT_Options::get( 'csdt_notify_telegram_enabled' ) ) { return; } if ( ! class_exists( 'CSDT_Alert_Window' ) || ! CSDT_Alert_Window::claim( self::GAP_ALERT_KEY, self::ALERT_EVERY, $now ) ) { return; } $msg = sprintf( /* translators: 1: refused comments, 2: minutes, 3: seconds between comments */ __( 'Comment flood: %1$d comments refused (HTTP 429) in the last %2$d minutes, each sent less than %3$d seconds after another one. The site is accepting at most one comment every %3$d seconds; moderators can still comment. If this is real traffic, shorten the gap under Firewall > Comment Flood Protection.', 'cloudscale-cyber-devtools' ), $count, (int) ( self::WINDOW / 60 ), $gap ); CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $msg, 'CloudScale Cyber Devtools', 'warning' ); } /** * What happens to one submission, given the count that includes it. * * @param int|null $count Submissions in the window, this one included; null = unknown. * @param int $limit The ceiling. * @param string $mode off|report|enforce. * @param bool $exempt Whether the submitter may moderate comments. * @return string allow|report|refuse */ public static function decide( ?int $count, int $limit, string $mode, bool $exempt ): string { if ( self::MODE_OFF === $mode || null === $count || $count <= $limit ) { return 'allow'; } return ( self::MODE_ENFORCE === $mode && ! $exempt ) ? 'refuse' : 'report'; } /** * Counts one submission and decides. Separate from inspect() so it can be tested. * * @param int $now Unix time. * @param bool $exempt Whether the submitter may moderate comments. * @return array{action:string,count:?int,limit:int} */ public static function evaluate( int $now, bool $exempt ): array { $mode = self::mode(); $limit = self::limit(); if ( self::MODE_OFF === $mode ) { return array( 'action' => 'allow', 'count' => null, 'limit' => $limit ); } $count = null; if ( class_exists( 'CSDT_Storage' ) && CSDT_Storage::is_available() && CSDT_Storage::record_event( self::SITE_KEY, self::SIGNAL, $now ) ) { $count = CSDT_Storage::count_events( self::SITE_KEY, self::SIGNAL, $now - self::WINDOW ); } $action = self::decide( $count, $limit, $mode, $exempt ); if ( 'allow' !== $action ) { self::over_ceiling( $now, (int) $count, $limit, $mode ); } return array( 'action' => $action, 'count' => $count, 'limit' => $limit, ); } /** * Over the ceiling: recorded once per window, alerted at most once per ALERT_EVERY. * * @param int $now Unix time. * @param int $count Submissions in the window. * @param int $limit The ceiling. * @param string $mode report|enforce. */ private static function over_ceiling( int $now, int $count, int $limit, string $mode ): void { if ( ! class_exists( 'CSDT_Alert_Window' ) || ! CSDT_Alert_Window::claim( self::ALERT_KEY, self::WINDOW, $now ) ) { return; } update_option( self::OPTION_LAST, array( 'at' => $now, 'count' => $count, 'limit' => $limit, 'mode' => $mode, ), false ); if ( ! class_exists( 'CSDT_Telegram' ) || '1' !== (string) CSDT_Options::get( 'csdt_notify_telegram_enabled' ) ) { return; } if ( ! CSDT_Alert_Window::claim( self::CEILING_ALERT_KEY, self::ALERT_EVERY, $now ) ) { return; } $minutes = (int) ( self::WINDOW / 60 ); if ( self::MODE_ENFORCE === $mode ) { /* translators: 1: comment submissions, 2: minutes, 3: ceiling */ $tpl = __( 'Comment flood: %1$d comment submissions in %2$d minutes, over the site-wide ceiling of %3$d. New comments are paused (HTTP 429) until the rate drops; moderators can still comment.', 'cloudscale-cyber-devtools' ); } else { /* translators: 1: comment submissions, 2: minutes, 3: ceiling */ $tpl = __( 'Comment flood: %1$d comment submissions in %2$d minutes, over the site-wide ceiling of %3$d. Report mode, so nothing was refused. To pause comments while over the ceiling, set it to Enforce under Firewall > Comment Flood Protection.', 'cloudscale-cyber-devtools' ); } $msg = sprintf( $tpl, $count, $minutes, $limit ); CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $msg, 'CloudScale Cyber Devtools', 'warning' ); } /** * Counts a submission. Hooked to preprocess_comment, first, so every submission is * counted whatever the later checks decide. Never modifies the comment; fails open. * * @param array $commentdata Comment data. * @return array Unchanged. */ public static function inspect( $commentdata ) { self::$refuse = false; self::$refuse_wait = 0; try { if ( ! is_array( $commentdata ) || ( self::MODE_OFF === self::mode() && self::min_gap() <= 0 ) ) { return $commentdata; } // Pingbacks and trackbacks arrive through xmlrpc.php, which the request limiter covers. $type = (string) ( $commentdata['comment_type'] ?? '' ); if ( '' !== $type && 'comment' !== $type ) { return $commentdata; } $exempt = function_exists( 'current_user_can' ) && current_user_can( 'moderate_comments' ); $now = time(); // The ceiling counts every submission, one the gap is about to refuse included. $d = self::evaluate( $now, $exempt ); self::$refuse_wait = self::evaluate_gap( $now, $exempt ); self::$refuse = 'refuse' === $d['action'] || self::$refuse_wait > 0; } catch ( \Throwable $e ) { self::$refuse = false; self::$refuse_wait = 0; if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } return $commentdata; } /** * Refuses the submission while over the ceiling in Enforce mode. Hooked to * pre_comment_approved after the comment guard, so it sees every other verdict. * * @param int|string|WP_Error $approved The verdict so far. * @param array $commentdata Comment data. * @return int|string|WP_Error */ public static function apply( $approved, $commentdata = array() ) { if ( ! self::$refuse || ( function_exists( 'is_wp_error' ) && is_wp_error( $approved ) ) ) { return $approved; } self::$refuse = false; $wait = self::$refuse_wait; if ( $wait > 0 ) { self::$refuse_wait = 0; if ( ! headers_sent() ) { header( 'Retry-After: ' . (int) $wait ); } return CSDT_Comment_Guard::refusal( 'csdt_comments_too_fast', __( 'Comments are arriving too quickly. Wait a few seconds, go back and send yours again.', 'cloudscale-cyber-devtools' ), 429 ); } if ( ! headers_sent() ) { header( 'Retry-After: ' . (int) self::WINDOW ); } return CSDT_Comment_Guard::refusal( 'csdt_comments_paused', __( 'Comments are temporarily paused, try again in a few minutes.', 'cloudscale-cyber-devtools' ), 429 ); } /** * Submissions counted in the current window, for the panel; null when unknown. */ public static function current_count(): ?int { if ( ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { return null; } return CSDT_Storage::count_events( self::SITE_KEY, self::SIGNAL, time() - self::WINDOW ); } // ── Admin UI ───────────────────────────────────────────────────────── /** Renders the Comment Flood Protection panel. */ public static function render_panel(): void { $mode = self::mode(); $limit = self::limit(); $gap = self::min_gap(); $count = self::MODE_OFF === $mode ? null : self::current_count(); $last = get_option( self::OPTION_LAST, null ); $cf_ok = CSDT_CF_Comment_Rule::is_configured(); $cf_last = CSDT_CF_Comment_Rule::last_state(); $labels = array( self::MODE_OFF => __( 'Off', 'cloudscale-cyber-devtools' ), self::MODE_REPORT => __( 'Report (alert, refuse nothing)', 'cloudscale-cyber-devtools' ), self::MODE_ENFORCE => __( 'Enforce (pause comments with 429)', 'cloudscale-cyber-devtools' ), ); ?>
wp-comments-post.php per address per minute. A flood spread across hundreds of addresses passes all of them. This counts every comment submission on the site in a rolling ' . (int) ( self::WINDOW / 60 ) . ' minute window and acts when the total passes the ceiling.',
),
array(
'name' => 'Report, the default',
'rec' => 'How it works',
'html' => 'In Report mode a submission over the ceiling is let through as normal. The plugin records when it happened (shown in this panel) and sends at most one alert an hour through your usual alert channel, subject to the alert mute and the hourly alert cap. Nothing is refused.',
),
array(
'name' => 'Enforce',
'rec' => 'Optional',
'html' => 'In Enforce mode, while the site is over the ceiling, new comments are refused with HTTP 429 and the message "Comments are temporarily paused, try again in a few minutes." Signed-in users who can moderate comments are never refused. It lifts by itself when the window\'s total falls back under the ceiling.' . esc_html( CSDT_CF_Comment_Rule::REF ) . ': a POST to ' . esc_html( CSDT_CF_Comment_Rule::comment_path() ) . ' more than ' . (int) CSDT_CF_Comment_Rule::REQUESTS_PER_PERIOD . ' times in ' . (int) CSDT_CF_Comment_Rule::PERIOD . ' seconds from one address is blocked for ' . (int) CSDT_CF_Comment_Rule::MITIGATION_TIMEOUT . ' seconds. Those are the values Cloudflare\'s Free plan accepts.
Cloudflare Cache Purge.', 'cloudscale-cyber-devtools' ); } elseif ( null === $cf_last ) { esc_html_e( 'Not checked yet. Press Check status.', 'cloudscale-cyber-devtools' ); } else { echo esc_html( sprintf( /* translators: 1: date and time, 2: status message */ __( 'As of %1$s: %2$s', 'cloudscale-cyber-devtools' ), wp_date( 'Y-m-d H:i', (int) $cf_last['at'] ), (string) $cf_last['message'] ) ); } ?>