` block's attributes reached unserialize(). No reader * types a block delimiter: the editor writes them, and comments never go through it. */ const BLOCK_MARKUP_RULE = 'block_markup'; /** How many layers of URL and HTML-entity encoding has_block_markup() peels off. */ const BLOCK_MARKUP_DECODE_ROUNDS = 3; /** * The verdict for the comment being submitted on this request, or null. * * preprocess_comment decides, pre_comment_approved applies and comment_post records; * WordPress gives no single hook that can do all three. * * @var array{status:string,reasons:array,attack:bool}|null */ private static $verdict = null; /** Registers the hooks. Gated at call time, like CSDT_Enum_Guard. */ public static function init(): void { add_action( 'comment_form_after_fields', array( __CLASS__, 'render_fields' ) ); add_filter( 'preprocess_comment', array( __CLASS__, 'inspect' ), 1 ); add_filter( 'pre_comment_approved', array( __CLASS__, 'apply_verdict' ), 99, 2 ); add_filter( 'comment_form_default_fields', array( __CLASS__, 'remove_website_field' ), 99 ); add_filter( 'preprocess_comment', array( __CLASS__, 'drop_profile_website' ), 2 ); // After every check that can mark a comment spam (the ceiling runs at 100), so a // spammer is filed as spam and told nothing. add_filter( 'pre_comment_approved', array( __CLASS__, 'refuse_links' ), 101, 2 ); add_filter( 'pre_comment_approved', array( __CLASS__, 'refuse_duplicate' ), 101, 2 ); add_action( 'comment_post', array( __CLASS__, 'annotate' ), 10, 1 ); add_filter( 'comment_text', array( __CLASS__, 'moderation_note' ), 10, 2 ); } /** Whether the guard is switched on. */ public static function is_enabled(): bool { return '1' === (string) get_option( self::OPTION_ENABLED, '1' ); } /** Whether a comment without the guard's form fields is spam. */ public static function requires_fields(): bool { return '1' === (string) get_option( self::OPTION_REQUIRE_FIELDS, '0' ); } /** Whether block markup in a comment is enforced rather than only reported. */ public static function enforces_block_markup(): bool { return class_exists( 'CSDT_Options' ) && '1' === (string) CSDT_Options::get( self::OPTION_ENFORCE_BLOCK_MARKUP ); } /** Whether attack comments are refused instead of stored as spam. */ public static function refuses_flagged(): bool { return class_exists( 'CSDT_Options' ) && '1' === (string) CSDT_Options::get( self::OPTION_REFUSE_FLAGGED ); } /** Whether a repeat of a comment already on the post is refused. */ public static function refuses_duplicates(): bool { return class_exists( 'CSDT_Options' ) && '1' === (string) CSDT_Options::get( self::OPTION_REFUSE_DUPLICATES ); } /** Whether a visitor's comment carrying a link is refused. */ public static function blocks_links(): bool { return class_exists( 'CSDT_Options' ) && '1' === (string) CSDT_Options::get( self::OPTION_BLOCK_LINKS ); } /** Whether the current commenter is screened. */ public static function is_active(): bool { if ( ! self::is_enabled() ) { return false; } $cap = class_exists( 'CSDT_Firewall' ) ? CSDT_Firewall::skip_capability() : 'edit_posts'; return ! ( function_exists( 'current_user_can' ) && current_user_can( $cap ) ); } /** * Prints the honeypot and the timestamp into the comment form. * * Hooked to comment_form_after_fields, which WordPress fires only for a visitor who is * not signed in, so a signed-in commenter never carries the fields at all. */ public static function render_fields(): void { if ( ! self::is_active() ) { return; } ?> }|null Null to leave it alone. */ public static function judge( array $commentdata, ?string $honeypot, ?string $timer, string $ip, int $now, bool $require = false ): ?array { $reasons = array(); $attack = false; if ( $require && ( null === $honeypot || null === $timer ) ) { // Spam, but not an attack: see OPTION_REQUIRE_FIELDS. $reasons[] = 'no_fields'; } if ( null !== $honeypot && '' !== trim( $honeypot ) ) { $reasons[] = 'honeypot'; $attack = true; } if ( null !== $timer && ctype_digit( $timer ) ) { $served = (int) $timer; // A timestamp in the future was not written by this site; it proves nothing // either way, so it is ignored rather than trusted. if ( $served <= $now && ( $now - $served ) < self::MIN_SECONDS ) { $reasons[] = 'too_fast'; $attack = true; } } if ( '' !== $ip && class_exists( 'CSDT_Storage' ) && CSDT_Storage::is_available() ) { CSDT_Storage::record_event( $ip, self::RATE_SIGNAL, $now ); $count = CSDT_Storage::count_events( $ip, self::RATE_SIGNAL, $now - self::RATE_WINDOW ); // Null is "cannot judge", never zero and never over. if ( null !== $count && $count > self::RATE_LIMIT ) { $reasons[] = 'rate'; $attack = true; } } $matched = array(); if ( class_exists( 'CSDT_Firewall' ) ) { foreach ( array( 'comment_content', 'comment_author', 'comment_author_url' ) as $field ) { $matched = array_merge( $matched, CSDT_Firewall::evaluate_text( (string) ( $commentdata[ $field ] ?? '' ) ) ); } $matched = array_values( array_unique( $matched ) ); } foreach ( $matched as $rule_id ) { $reasons[] = 'rule:' . $rule_id; $attack = true; if ( '' !== $ip && class_exists( 'CSDT_Storage' ) ) { CSDT_Storage::record_match( $ip, 'comment_' . $rule_id, 'blocked', array( 'method' => 'POST', 'path' => '/wp-comments-post.php', ), $now ); } } // Block markup is not a firewall rule (a post body is MADE of it), so it is checked // here. REPORT ONLY unless the admin enforces it (OPTION_ENFORCE_BLOCK_MARKUP): the // match is logged as `logged`, and the comment and its sender are left alone. $block_markup = false; foreach ( array( 'comment_content', 'comment_author', 'comment_author_url', 'comment_author_email' ) as $field ) { if ( self::has_block_markup( (string) ( $commentdata[ $field ] ?? '' ) ) ) { $block_markup = true; break; } } if ( $block_markup ) { $enforce = self::enforces_block_markup(); if ( $enforce ) { $reasons[] = 'rule:' . self::BLOCK_MARKUP_RULE; $attack = true; } if ( '' !== $ip && class_exists( 'CSDT_Storage' ) ) { CSDT_Storage::record_match( $ip, 'comment_' . self::BLOCK_MARKUP_RULE, $enforce ? 'blocked' : 'logged', array( 'method' => 'POST', 'path' => '/wp-comments-post.php', ), $now ); } } if ( empty( $reasons ) ) { return null; } if ( $attack && '' !== $ip && is_callable( array( 'CSDT_Auto_Block', 'record' ) ) ) { CSDT_Auto_Block::record( 'comment_attack', $ip ); } return array( 'status' => 'spam', 'reasons' => $reasons, 'attack' => $attack, ); } /** * Whether text carries a block delimiter, ``), and a * sentence that names a block ("use the wp:paragraph block"). Only the delimiter itself. * * @param string $text Comment field. */ public static function has_block_markup( string $text ): bool { if ( '' === $text ) { return false; } $re = '/<\s*!\s*-\s*-\s*\/?\s*wp\s*:\s*[a-z]/i'; for ( $i = 0; $i <= self::BLOCK_MARKUP_DECODE_ROUNDS; $i++ ) { if ( preg_match( $re, $text ) ) { return true; } $next = $text; if ( false !== stripos( $next, '\\u00' ) ) { $next = (string) preg_replace_callback( '/\\\\u00([0-7][0-9a-f])/i', static function ( array $m ): string { return chr( (int) hexdec( $m[1] ) ); }, $next ); } if ( false !== strpos( $next, '%' ) ) { $next = rawurldecode( $next ); } if ( false !== strpos( $next, '&' ) ) { $next = html_entity_decode( $next, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } if ( $next === $text ) { return false; } $text = $next; } return false; } /** * Applies the verdict. Hooked to pre_comment_approved, late, so it sees every other * plugin's answer and can only ever tighten it. * * @param int|string|WP_Error $approved 1, 0, 'spam', 'trash' or an error. * @param array $commentdata Comment data. * @return int|string|WP_Error */ public static function apply_verdict( $approved, $commentdata = array() ) { if ( null === self::$verdict || ( function_exists( 'is_wp_error' ) && is_wp_error( $approved ) ) ) { return $approved; } if ( 'trash' === $approved ) { return $approved; } if ( ! empty( self::$verdict['attack'] ) && self::refuses_flagged() ) { // Opt-in: refused before insertion, so a flood never fills wp_comments. The // WP_Error is core's own way to refuse here (its flood check does the same), and // wp-comments-post.php turns it into a 403 page with a back link. self::$verdict = null; return self::refusal( 'csdt_comment_refused', __( 'Your comment could not be accepted.', 'cloudscale-cyber-devtools' ), 403 ); } return 'spam'; } /** * Comment text reduced to what a person would call "the same comment": case and runs * of whitespace do not count. * * @param string $text Comment text. */ public static function comparable_text( string $text ): string { $text = trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); return function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); } /** * Whether a stored comment is the same comment from the same sender. * * The same sender is the same email address inside DUPLICATE_EMAIL_WINDOW, or the same * network address inside DUPLICATE_IP_WINDOW. The name is deliberately not compared: * a corrected name is the usual reason a comment is sent twice. * * @param object $stored A stored comment (comment_content, comment_author_email, comment_author_IP, comment_date_gmt). * @param string $text New comment text, unslashed. * @param string $email New comment email, '' when none. * @param string $ip Sender address, '' when unknown. * @param int $now Unix time. */ public static function is_repeat_of( $stored, string $text, string $email, string $ip, int $now ): bool { $wanted = self::comparable_text( $text ); if ( '' === $wanted || self::comparable_text( (string) ( $stored->comment_content ?? '' ) ) !== $wanted ) { return false; } $posted = strtotime( (string) ( $stored->comment_date_gmt ?? '' ) . ' UTC' ); if ( false === $posted ) { return false; } $age = $now - $posted; $their_mail = strtolower( trim( (string) ( $stored->comment_author_email ?? '' ) ) ); if ( '' !== $email && strtolower( trim( $email ) ) === $their_mail && $age <= self::DUPLICATE_EMAIL_WINDOW ) { return true; } return '' !== $ip && (string) ( $stored->comment_author_IP ?? '' ) === $ip && $age <= self::DUPLICATE_IP_WINDOW; } /** * Refuses a comment the same visitor has already left on the same post. Hooked to * pre_comment_approved, opt-in. * * Only a comment that is published or waiting for approval counts as "already left": * one the moderator put in Spam or the Trash can be sent again. A comment already judged * spam or trash is left alone, so nothing here ever loosens a verdict or answers a bot. * * @param int|string|WP_Error $approved 1, 0, 'spam', 'trash' or an error. * @param array $commentdata Comment data, slashed as core passes it. * @return int|string|WP_Error */ public static function refuse_duplicate( $approved, $commentdata = array() ) { try { if ( ( function_exists( 'is_wp_error' ) && is_wp_error( $approved ) ) || 'spam' === $approved || 'trash' === $approved ) { return $approved; } if ( ! is_array( $commentdata ) || ! self::refuses_duplicates() || ! self::is_active() ) { return $approved; } $type = (string) ( $commentdata['comment_type'] ?? '' ); $post_id = (int) ( $commentdata['comment_post_ID'] ?? 0 ); if ( ( '' !== $type && 'comment' !== $type ) || $post_id <= 0 || ! function_exists( 'get_comments' ) ) { return $approved; } $text = (string) wp_unslash( $commentdata['comment_content'] ?? '' ); $email = (string) wp_unslash( $commentdata['comment_author_email'] ?? '' ); $ip = self::client_ip(); $now = time(); $found = get_comments( array( 'post_id' => $post_id, 'status' => array( 'hold', 'approve' ), 'type' => 'comment', 'number' => self::DUPLICATE_SCAN_LIMIT, 'orderby' => 'comment_date_gmt', 'order' => 'DESC', ) ); foreach ( is_array( $found ) ? $found : array() as $stored ) { if ( ! self::is_repeat_of( $stored, $text, $email, $ip, $now ) ) { continue; } $waiting = '0' === (string) ( $stored->comment_approved ?? '' ); return self::refusal( 'csdt_comment_duplicate', $waiting ? __( 'You have already sent this comment. It is waiting for approval, so there is no need to send it again.', 'cloudscale-cyber-devtools' ) : __( 'You have already posted this comment here.', 'cloudscale-cyber-devtools' ), 409 ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } return $approved; } /** * Whether text carries a link. * * A link is an address with a scheme (http, https, ftp), one starting `www.`, anchor or * forum link markup, a host name followed by a path (`example.xyz/page`), or a bare host * name of four letters or more ending in one of LINK_TLDS (`example.com`). * * What is NOT a link, deliberately: an email address, a file name (`wp-config.php`), a * version number, a short product name that happens to end like a host (`ASP.NET`), and a * sentence with a missing space ("It works.Live with it"). * * @param string $text Comment field, unslashed. */ public static function has_link( string $text ): bool { if ( '' === $text ) { return false; } $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( 1 === preg_match( '#\b(?:https?|ftp)\s*:\s*//|\bwww\.[a-z0-9-]|<\s*a\b[^>]*\bhref|\[\s*(?:url|link)\b#i', $text ) ) { return true; } // A host with a path, whatever it ends in. if ( 1 === preg_match( '#(? 0; if ( ! $signed_in && '' !== trim( (string) ( $commentdata['comment_author_url'] ?? '' ) ) ) { return 'website'; } return ''; } /** * Refuses a visitor's comment that carries a link. Hooked to pre_comment_approved, opt-in. * * Refused with a message, not filed as spam, so a reader who pasted a link learns what * to remove. Never counted as an attack and nobody is blocked for it. A comment already * judged spam or trash is left alone, so nothing here loosens a verdict or answers a bot. * * @param int|string|WP_Error $approved 1, 0, 'spam', 'trash' or an error. * @param array $commentdata Comment data, slashed as core passes it. * @return int|string|WP_Error */ public static function refuse_links( $approved, $commentdata = array() ) { try { if ( ( function_exists( 'is_wp_error' ) && is_wp_error( $approved ) ) || 'spam' === $approved || 'trash' === $approved ) { return $approved; } if ( ! is_array( $commentdata ) || ! self::blocks_links() || ! self::is_active() ) { return $approved; } // A pingback or trackback IS a link; whether to accept those is a WordPress setting. $type = (string) ( $commentdata['comment_type'] ?? '' ); if ( '' !== $type && 'comment' !== $type ) { return $approved; } $reason = self::link_reason( (array) wp_unslash( $commentdata ) ); if ( '' === $reason ) { return $approved; } $ip = self::client_ip(); if ( '' !== $ip && class_exists( 'CSDT_Storage' ) ) { CSDT_Storage::record_match( $ip, 'comment_' . self::LINK_RULE, 'blocked', array( 'method' => 'POST', 'path' => '/wp-comments-post.php', ), time() ); } $messages = array( 'text' => __( 'Your comment was not posted because it contains a link or web address. Links are not allowed in comments on this site. Please remove the link and send your comment again.', 'cloudscale-cyber-devtools' ), 'name' => __( 'Your comment was not posted because the Name field contains a web address. Links are not allowed in comments on this site. Please enter only your name and send your comment again.', 'cloudscale-cyber-devtools' ), 'website' => __( 'Your comment was not posted because the Website field is filled in. Links are not allowed in comments on this site. Please leave the Website field empty and send your comment again.', 'cloudscale-cyber-devtools' ), ); return self::refusal( 'csdt_comment_link', $messages[ $reason ], 403 ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } return $approved; } /** * Takes the Website field out of the comment form while links are refused, so a reader * is never offered a field the site will not accept. Hooked to comment_form_default_fields. * * @param array $fields Default comment form fields. * @return array */ public static function remove_website_field( $fields ) { if ( is_array( $fields ) && self::is_enabled() && self::blocks_links() ) { unset( $fields['url'] ); // WordPress's own tick box still offers to remember a website. Say what is left. The // label is replaced by its element, not its wording, so it is found in every language. if ( isset( $fields['cookies'] ) && is_string( $fields['cookies'] ) ) { $label = esc_html__( 'Save my name and email in this browser for the next time I comment.', 'cloudscale-cyber-devtools' ); $fields['cookies'] = (string) preg_replace( '#(]*\bfor=(["\'])wp-comment-cookies-consent\2[^>]*>).*?()#is', '${1}' . str_replace( array( '\\', '$' ), array( '\\\\', '\\$' ), $label ) . '${3}', $fields['cookies'], 1 ); } } return $fields; } /** * Drops a signed-in commenter's profile website while links are refused. Hooked to * preprocess_comment. * * WordPress fills the website of a signed-in commenter from their profile. They did not * type it and cannot clear it in the form, so it is dropped rather than refused. * * @param array $commentdata Comment data. * @return array */ public static function drop_profile_website( $commentdata ) { if ( ! is_array( $commentdata ) || ! self::blocks_links() || ! self::is_active() ) { return $commentdata; } if ( (int) ( $commentdata['user_id'] ?? ( $commentdata['user_ID'] ?? 0 ) ) > 0 ) { $commentdata['comment_author_url'] = ''; } return $commentdata; } /** * A refusal in the shape the submitting path expects. * * wp-comments-post.php reads the error data as an integer HTTP status; the REST comments * endpoint reads it as array( 'status' => N ). Either one given the other's shape answers * with the wrong status, so the shape follows the request. * * @param string $code Error code. * @param string $message Message shown to the commenter. * @param int $status HTTP status. * @return WP_Error */ public static function refusal( string $code, string $message, int $status ) { $data = ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ? array( 'status' => $status ) : $status; return new WP_Error( $code, $message, $data ); } /** * Records why the guard acted, on the stored comment. Hooked to comment_post. * * @param int $comment_id New comment ID. */ public static function annotate( $comment_id ): void { try { if ( null === self::$verdict ) { return; } $reasons = self::$verdict['reasons']; self::$verdict = null; if ( function_exists( 'add_comment_meta' ) ) { add_comment_meta( (int) $comment_id, self::META_KEY, implode( ',', $reasons ), true ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Tells a moderator why a comment was marked spam, on the Comments screen only. * * @param string $text Comment text, already filtered. * @param WP_Comment|null $comment The comment. * @return string */ public static function moderation_note( $text, $comment = null ) { if ( ! function_exists( 'is_admin' ) || ! is_admin() || ! is_object( $comment ) || empty( $comment->comment_ID ) ) { return $text; } if ( ! current_user_can( 'moderate_comments' ) ) { return $text; } $raw = (string) get_comment_meta( (int) $comment->comment_ID, self::META_KEY, true ); if ( '' === $raw ) { return $text; } $labels = array(); foreach ( explode( ',', $raw ) as $reason ) { $labels[] = self::reason_label( $reason ); } return '

' . esc_html__( 'CloudScale Comment Guard:', 'cloudscale-cyber-devtools' ) . ' ' . esc_html( implode( '; ', $labels ) ) . '

' . $text; } /** * A reason code as a moderator reads it. * * @param string $reason Reason code. */ public static function reason_label( string $reason ): string { if ( 0 === strpos( $reason, 'rule:' ) ) { /* translators: %s: firewall rule id, e.g. xss_reflected */ return sprintf( __( 'text matched the firewall rule %s, treated as an attack', 'cloudscale-cyber-devtools' ), substr( $reason, 5 ) ); } $map = array( 'honeypot' => __( 'filled in a field that is invisible to people', 'cloudscale-cyber-devtools' ), 'too_fast' => __( 'posted within seconds of the page loading', 'cloudscale-cyber-devtools' ), 'rate' => __( 'too many comments from one address in ten minutes', 'cloudscale-cyber-devtools' ), 'no_fields' => __( 'posted without the comment form (the form fields are required on this site)', 'cloudscale-cyber-devtools' ), ); return $map[ $reason ] ?? $reason; } /** * One posted field, or null when it was not posted. The only place this class reads * the request. * * No nonce is verified, because none can be: core puts no nonce on the comment form * for a visitor who is not signed in (wp-comments-post.php checks one only for a * signed-in user's unfiltered HTML), and one added here would be baked into cached * pages and expire there, so real commenters on a cached post would fail it. Nor would * it help: these two fields are a bot trap, and a bot that drops or replays a nonce * would walk past the trap. A nonce was added and verified here once, and the * end-to-end comment tests showed exactly that: filled honeypots and too-fast posts * were approved. The fields are sanitised, only compared, never stored or printed, and * the only outcome is that a comment is held as spam. * * @param string $name Field name. */ private static function posted( string $name ): ?string { // A field posted as an array was not posted by the form. // wporg-risk-accepted: CS_REJECT_23, WordPress.Security.NonceVerification.Missing -- Comment Guard's bot trap. Core's comment form carries no nonce for a visitor who is not signed in, and one added here is baked into cached pages and expires there, so real commenters would fail it while bots replay it. The value is sanitised, only compared, never stored or printed; the only outcome is that a comment is held as spam. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the comment form carries no nonce for an anonymous visitor, see the docblock; the value is sanitised and only compared return isset( $_POST[ $name ] ) && ! is_array( $_POST[ $name ] ) ? sanitize_text_field( wp_unslash( $_POST[ $name ] ) ) : null; } /** The client address, '' when it cannot be resolved. */ private static function client_ip(): string { return class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; } /** * Comments the guard acted on in the last 7 days, by outcome, for the panel. * * @return array{spam:int,payload:int} */ public static function recent_counts(): array { $out = array( 'spam' => 0, 'payload' => 0, ); if ( ! function_exists( 'get_comments' ) ) { return $out; } $base = array( 'count' => true, 'meta_key' => self::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin panel only, one indexed key over 7 days of comments 'date_query' => array( array( 'after' => '7 days ago' ) ), ); $out['spam'] = (int) get_comments( array_merge( $base, array( 'status' => 'spam' ) ) ); $out['payload'] = (int) get_comments( array_merge( $base, array( 'status' => 'spam', 'meta_value' => 'rule:', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- admin panel only, 7 days of the guard's own rows 'meta_compare' => 'LIKE', ) ) ); return $out; } // ── Admin UI ───────────────────────────────────────────────────────── /** Renders the Comment Guard panel. */ public static function render_panel(): void { $on = self::is_enabled(); $require = self::requires_fields(); $markup = self::enforces_block_markup(); $refuse = self::refuses_flagged(); $repeats = self::refuses_duplicates(); $links = self::blocks_links(); $counts = self::recent_counts(); ?>
💬 'Why comments need their own check', 'rec' => 'Read this first', 'html' => 'The firewall inspects every comment submission, the name, email and website fields included, but it deliberately does not run its injection rules over the comment text. This is a security blog: a reader who pastes a script element or a SQL payload into a comment to ask about it is a reader, and refusing them would be wrong. Before this panel existed that meant a payload in a comment was never recorded anywhere.

WordPress core already strips script elements, event handlers and javascript: links from comments by visitors, before saving them. That stays the main defence. This guard adds the two things core does not do: telling a bot from a person, and treating a comment that carries a payload as the attack it is.', ), array( 'name' => 'Bots: marked spam', 'rec' => 'How it works', 'html' => 'Three checks, none of which a person can fail:

1. A hidden field. The comment form carries a field that is off-screen, hidden from screen readers and out of the tab order. A person never fills it; a bot filling every field it finds does.
2. Speed. The form records when it was served. A comment posted less than ' . (int) self::MIN_SECONDS . ' seconds later was not typed.
3. Rate. More than ' . (int) self::RATE_LIMIT . ' comments from one address in ' . (int) ( self::RATE_WINDOW / 60 ) . ' minutes.

A comment failing any of them goes to Spam and counts as an attack: see the next section.', ), array( 'name' => 'Attacks: spam, and the sender is blocked', 'rec' => 'How it works', 'html' => 'The comment text, name and website are run through the firewall\'s parameter-content rules with the prose exemption lifted, so a script element, an event handler, a javascript: link, SQL injection, code execution or a serialized PHP object is caught wherever it appears.

So is a block delimiter, <!-- wp:name or <!-- /wp:name, in the text, name, email or website, however it is encoded. Readers never type one; the editor writes them. The Events Calendar (CVE-2026-78159, CVE-2026-78006) was exploited with a wp:legacy-widget block in a comment. An ordinary HTML comment, or a sentence naming a block such as wp:paragraph, is not a match. It is logged as comment_block_markup, and by default that is ALL it does: see the next section.

A firewall rule match, or any of the bot checks above, sends the comment to Spam and counts as an attack, comments flagged as attacks in Auto-Block. When Automatic Blocking is switched on (it is off on a new install) the threshold is ' . (int) CSDT_Auto_Block::DEFAULT_COMMENT_ATTACK_THRESHOLD . ': the first bad comment blocks the address on the usual escalating ladder (1 hour, then 24 hours, then 7 days) and sends the usual Telegram block alert. A payload match also adds a blocked row to the firewall\'s Recent Matches table, under the rule name prefixed with comment_, and the comment on the Comments screen says which rule matched.

The trade, stated plainly: a reader who pastes a live payload into a comment to ask about it is blocked like an attacker. Auto-Block\'s never-block rules still apply, so your own address while signed in, live administrator sessions and your allowlist are never blocked.', ), array( 'name' => 'Block markup: report only unless enforced', 'rec' => 'Optional, off by default', 'html' => 'By default a block delimiter in a comment is only recorded: a logged row under comment_block_markup in the firewall\'s Recent Matches table. The comment is handled as if the check did not exist, and the sender is not counted towards a block.

Tick Enforce block-markup detection and a match is treated like any other attack: the comment goes to Spam, the row is blocked, and the address is counted as a comment_attack. Read the Recent Matches table for a while first: the check is new, and the log tells you whether it ever fires on your readers.', ), array( 'name' => 'Refusing attack comments instead of storing them', 'rec' => 'Optional, off by default', 'html' => 'By default a comment the guard treats as an attack is still stored, in Spam, where you can read it and rescue a false positive. Under a flood that means every one of them is a row in your comments table.

Tick Refuse attack comments and they are refused before they are saved: the sender sees a short "could not be accepted" page with HTTP status 403, and nothing is written. The trade: there is nothing left to rescue, so a reader caught by mistake loses their comment. Comments that are spam without being an attack (the "require the comment form" check) are still stored as Spam.', ), array( 'name' => 'Refusing a comment sent twice', 'rec' => 'Optional, off by default', 'html' => 'WordPress already refuses a comment that repeats one on the same post, but only when the name and the email address are both unchanged. A visitor who notices a typo in their name, corrects it and sends the same text again gets through, and you receive two comments and two moderation emails.

Tick Refuse a comment the same visitor already sent and the second one is refused, whatever name it carries. The same visitor means the same email address in the last ' . (int) ( self::DUPLICATE_EMAIL_WINDOW / DAY_IN_SECONDS ) . ' days, or the same network address in the last ' . (int) ( self::DUPLICATE_IP_WINDOW / MINUTE_IN_SECONDS ) . ' minutes. Capital letters and spacing are ignored when the text is compared. The visitor sees a short page saying the comment was already sent, and whether it is waiting for approval, with a link back.

The trade: the first copy is the one that is kept, so a corrected name is not picked up. Edit the name on the Comments screen if it matters. A comment you moved to Spam or the Trash does not count, so that visitor can send it again. Nobody is blocked or counted as an attacker for repeating a comment.', ), array( 'name' => 'Refusing comments with links', 'rec' => 'Optional, off by default', 'html' => 'The comment nothing else here stops is the polite one: on topic, typed at a human pace, and written only to carry a link, in its text and in the Website field.

Tick Refuse comments that contain a link and a visitor\'s comment is refused when its text or name holds a web address, or its Website field is filled in. The Website field is also taken out of your comment form, so readers are not offered a field the site will not accept.

What counts as a link: an address starting http://, https:// or www., link markup, a host name followed by a path such as example.xyz/page, and a bare host name such as example.com. An email address, a file name such as wp-config.php and a product name such as ASP.NET are not links.

The visitor sees a short page saying links are not accepted and what to remove, with a link back, so a reader can take the address out and send the comment again. Nothing is stored. Each refusal adds a blocked row under comment_link to the firewall\'s Recent Matches table.

The trade: a reader cannot share a useful link either. Nobody is blocked or counted as an attacker for posting one. Signed-in users who can write posts are never screened; any other signed-in commenter has their profile website left off the comment. Pingbacks and trackbacks are not affected, those are switched off under Settings, Discussion. If your pages are cached, the Website field stays on cached copies until they refresh, and a reader who fills it in is told to leave it empty.', ), array( 'name' => 'Requiring the comment form', 'rec' => 'Optional, off by default', 'html' => 'The second checkbox closes the one gap the default leaves: a bot that posts straight to wp-comments-post.php without loading the page never carries the hidden field or the timestamp. With it ticked, a comment missing either is marked spam.

Only tick it if your comments come through WordPress\'s own comment form. A theme that builds its own form, or an AMP comment template, never carries the fields, so every comment from it would go to Spam. Post a test comment as a logged-out visitor after turning it on.

These are never counted as attacks, so a misconfigured form cannot get your readers blocked.', ), array( 'name' => 'What it does not do', 'rec' => 'Honest limits', 'html' => 'Signed-in users who can write posts are never screened, the same scope as the firewall.

A comment form your theme builds by hand, rather than with WordPress\'s comment_form(), carries neither the hidden field nor the timestamp, and by default their absence is never held against a comment. So a bot posting straight to wp-comments-post.php skips the first two bot checks; the rate cap and the payload check still apply to it.

This is not a spam-content filter: a polite comment advertising something gets through unless it carries a link and you have ticked Refuse comments that contain a link. Pair it with Akismet or manual moderation for the rest.

Page caching is safe: a cached form only ever makes a comment look slower, never faster.', ), ) ); ?>

$on ? __( 'Saved. Visitor comments are screened.', 'cloudscale-cyber-devtools' ) : __( 'Saved. Visitor comments are no longer screened.', 'cloudscale-cyber-devtools' ), ) ); } }