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' ), ); ?>
🌊 'Why a site-wide ceiling', 'rec' => 'Read this first', 'html' => 'Every other comment limit is per address: WordPress refuses a second comment from one address within 15 seconds, Comment Guard marks the sixth in ten minutes as spam, and Request Rate Limits counts 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.

Pick a ceiling well above your busiest real ten minutes: on a site that gets a few comments a day, 30 is generous. Watch Report mode first; this panel shows the current count.', ), array( 'name' => 'A gap between comments', 'rec' => 'Optional, off by default', 'html' => 'The ceiling is a ten minute total, so a burst of comments in a single second is still inside it. Seconds between comments is the other half: once one comment is accepted, the next one from anybody is refused with HTTP 429 until that many seconds have passed. At 5, the site accepts at most one comment every five seconds, twelve a minute, however many addresses are sending them.

It works by itself, with the ceiling Off, in Report or in Enforce, and needs no Cloudflare. The person refused sees "Comments are arriving too quickly. Wait a few seconds, go back and send yours again." Signed-in users who can moderate comments are never refused. Set it to 0 to switch it off.

When ' . (int) self::GAP_ALERT_AFTER . ' or more comments are refused this way inside ' . (int) ( self::WINDOW / 60 ) . ' minutes, one alert is sent through your usual alert channel, at most once an hour. One or two refusals are treated as coincidence and send nothing.

The trade: it is site-wide, so two real readers who press Send within the gap of each other means the second has to send again. On a site with a few comments a day that almost never happens; on a very busy one keep it short. It is not per address because WordPress already makes one address wait 15 seconds between comments.', ), array( 'name' => 'Cloudflare rate-limit rule', 'rec' => 'Optional, sites behind Cloudflare', 'html' => 'Behind Cloudflare a flood can be refused at the edge, before it costs your server anything. Add rule creates ONE rate-limiting rule on your zone, named ' . 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.

Nothing happens until you press the button. Rules already on the zone are never changed: the rule is added beside them, and Remove rule deletes only this one. The Free plan allows one rate-limiting rule per zone, so if you already have one Cloudflare refuses, and its message is shown here as it was sent.

It uses the zone ID and API token saved for the Cloudflare cache purge (Images tab). That token also needs the Zone WAF: Edit permission; without it Cloudflare answers with an authentication error, shown here.', ), ) ); ?>

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'] ) ); } ?>

100000 ) { wp_send_json_error( __( 'The ceiling must be a whole number from 1 to 100000.', 'cloudscale-cyber-devtools' ) ); return; } $gap = absint( wp_unslash( $_POST['gap'] ?? 0 ) ); if ( $gap > self::MAX_GAP ) { wp_send_json_error( __( 'The gap between comments must be a whole number of seconds from 0 to 3600.', 'cloudscale-cyber-devtools' ) ); return; } update_option( self::OPTION_MIN_GAP, (string) $gap ); update_option( self::OPTION_MODE, $mode ); update_option( self::OPTION_LIMIT, (string) $limit ); wp_send_json_success( array( 'msg' => __( 'Saved.', 'cloudscale-cyber-devtools' ) ) ); } /** Checks whether the Cloudflare rule exists. Changes nothing on Cloudflare. */ public static function ajax_cf_status(): void { self::cf_respond( 'status' ); } /** Adds the Cloudflare rule. */ public static function ajax_cf_add(): void { self::cf_respond( 'add' ); } /** Removes the Cloudflare rule. */ public static function ajax_cf_remove(): void { self::cf_respond( 'remove' ); } /** * One Cloudflare action, answered as JSON. * * @param string $what status|add|remove. */ private static function cf_respond( string $what ): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } if ( 'add' === $what ) { $r = CSDT_CF_Comment_Rule::add(); } elseif ( 'remove' === $what ) { $r = CSDT_CF_Comment_Rule::remove(); } else { $r = CSDT_CF_Comment_Rule::status(); } if ( $r['ok'] ) { wp_send_json_success( array( 'msg' => $r['message'], 'state' => $r['state'] ) ); } wp_send_json_error( $r['message'] ); } }