PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-comment-security.php

class-comment-security.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.9.8, at includes/class-comment-security.php

411 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Comment Security Class
4 *
5 * Handles comment security settings and spam protection
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Comment_Security
17 *
18 * Manages comment security features
19 */
20 class Vigilante_Comment_Security {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Comment security options
31 *
32 * @var array
33 */
34 private $options;
35
36 /**
37 * Constructor
38 *
39 * @param Vigilante_Settings $settings Settings instance.
40 */
41 public function __construct( $settings ) {
42 $this->settings = $settings;
43 $this->options = $settings->get_section( 'wp_hardening' );
44
45 $this->init_hooks();
46 }
47
48 /**
49 * Initialize hooks
50 */
51 private function init_hooks() {
52 // XML-RPC exposure: one three-way setting. Lives here rather than under
53 // Login because this class already owns the xmlrpc_methods filter, and
54 // because the module gate for this tab is modules.wp_hardening: keeping
55 // the setting and the code it drives under the same gate avoids the trap
56 // of a switch that looks on while the class that reads it never runs.
57 $xmlrpc_mode = self::resolve_xmlrpc_mode( $this->settings );
58
59 if ( 'full' === $xmlrpc_mode ) {
60 add_filter( 'xmlrpc_enabled', '__return_false' );
61 add_filter( 'wp_xmlrpc_server_class', array( $this, 'disable_xmlrpc_server' ) );
62 remove_action( 'wp_head', 'rsd_link' );
63 remove_action( 'wp_head', 'wlwmanifest_link' );
64 } elseif ( 'pingback' === $xmlrpc_mode ) {
65 add_filter( 'xmlrpc_methods', array( $this, 'disable_pingback_methods' ) );
66 }
67
68 // Disable pingbacks/trackbacks
69 if ( ! empty( $this->options['disable_pingbacks'] ) ) {
70 add_filter( 'xmlrpc_methods', array( $this, 'disable_pingback_methods' ) );
71 add_filter( 'wp_headers', array( $this, 'remove_pingback_header' ) );
72 add_filter( 'pings_open', '__return_false', 9999 );
73 }
74
75 if ( ! empty( $this->options['disable_trackbacks'] ) ) {
76 add_filter( 'pings_open', '__return_false', 9999 );
77 }
78
79 // Close old comments (high priority to override WP native if needed)
80 if ( ! empty( $this->options['close_old_comments'] ) ) {
81 add_filter( 'comments_open', array( $this, 'close_old_comments' ), 9999, 2 );
82 }
83
84 // Honeypot
85 if ( ! empty( $this->options['honeypot_enabled'] ) ) {
86 add_action( 'comment_form', array( $this, 'add_honeypot_field' ) );
87 add_filter( 'preprocess_comment', array( $this, 'check_honeypot' ) );
88 }
89
90 // Link limit check
91 if ( ! empty( $this->options['link_limit'] ) ) {
92 add_filter( 'preprocess_comment', array( $this, 'check_link_limit' ) );
93 }
94
95 // Block patterns
96 if ( ! empty( $this->options['block_patterns'] ) ) {
97 add_filter( 'preprocess_comment', array( $this, 'check_blocked_patterns' ) );
98 }
99
100 // Block IPs
101 if ( ! empty( $this->options['block_ips'] ) ) {
102 add_filter( 'preprocess_comment', array( $this, 'check_blocked_ips' ) );
103 }
104 }
105
106 /**
107 * Disable pingback XML-RPC methods
108 *
109 * @param array $methods XML-RPC methods.
110 * @return array
111 */
112 /**
113 * Resolve the XML-RPC mode.
114 *
115 * The setting used to be two independent checkboxes under Login that could
116 * be on at the same time and contradict each other ("disable everything"
117 * plus "disable only pingback"). It is now a single three-way choice stored
118 * in wp_hardening.xmlrpc_mode, next to the pingback settings it relates to.
119 *
120 * Sites upgrading have neither, only the old pair under login_security, so
121 * their choice is read from there and nothing changes for them until they
122 * save the tab. An install with none of the three gets 'full', which is what
123 * the old defaults did (disable_xmlrpc shipped on and took precedence).
124 *
125 * Public and static so this class, the settings screen and the Security
126 * Check all resolve the value the same way and cannot drift apart.
127 *
128 * @param Vigilante_Settings $settings Settings instance.
129 * @return string 'full', 'pingback' or 'none'.
130 */
131 public static function resolve_xmlrpc_mode( $settings ) {
132 $hardening = $settings->get_section( 'wp_hardening' );
133 $mode = isset( $hardening['xmlrpc_mode'] ) ? (string) $hardening['xmlrpc_mode'] : '';
134
135 if ( in_array( $mode, array( 'full', 'pingback', 'none' ), true ) ) {
136 return $mode;
137 }
138
139 // Legacy pair under Login, only meaningful when one of them was stored.
140 $login = $settings->get_section( 'login_security' );
141 if ( array_key_exists( 'disable_xmlrpc', $login )
142 || array_key_exists( 'disable_xmlrpc_pingback', $login ) ) {
143 if ( ! empty( $login['disable_xmlrpc'] ) ) {
144 return 'full';
145 }
146 if ( ! empty( $login['disable_xmlrpc_pingback'] ) ) {
147 return 'pingback';
148 }
149 return 'none';
150 }
151
152 return 'full';
153 }
154
155 /**
156 * Replace the XML-RPC server class with one that answers nothing.
157 *
158 * @return string
159 */
160 public function disable_xmlrpc_server() {
161 return 'wp_xmlrpc_server_disabled';
162 }
163
164 public function disable_pingback_methods( $methods ) {
165 unset( $methods['pingback.ping'] );
166 unset( $methods['pingback.extensions.getPingbacks'] );
167 return $methods;
168 }
169
170 /**
171 * Remove X-Pingback header
172 *
173 * @param array $headers HTTP headers.
174 * @return array
175 */
176 public function remove_pingback_header( $headers ) {
177 unset( $headers['X-Pingback'] );
178 return $headers;
179 }
180
181 /**
182 * Close comments on old posts
183 *
184 * @param bool $open Whether comments are open.
185 * @param int $post_id Post ID.
186 * @return bool
187 */
188 public function close_old_comments( $open, $post_id ) {
189 $post = get_post( $post_id );
190
191 if ( ! $post ) {
192 return $open;
193 }
194
195 // Don't close comments on WooCommerce products (reviews)
196 if ( 'product' === $post->post_type ) {
197 return 'open' === $post->comment_status;
198 }
199
200 if ( ! $open ) {
201 return $open;
202 }
203
204 $days = absint( $this->options['close_after_days'] ?? 30 );
205 $post_date = strtotime( $post->post_date );
206 $cutoff = strtotime( "-{$days} days" );
207
208 if ( $post_date < $cutoff ) {
209 return false;
210 }
211
212 return $open;
213 }
214
215 /**
216 * Add honeypot field to comment form
217 */
218 public function add_honeypot_field() {
219 ?>
220 <p class="vigilante-hp-field" style="display:none !important;">
221 <label for="vigilante_hp_website"><?php esc_html_e( 'Website', 'vigilante' ); ?></label>
222 <input type="text" name="vigilante_hp_website" id="vigilante_hp_website" value="" autocomplete="off" tabindex="-1" />
223 </p>
224 <?php
225 }
226
227 /**
228 * Check honeypot field
229 *
230 * @param array $commentdata Comment data.
231 * @return array
232 */
233 public function check_honeypot( $commentdata ) {
234 // phpcs:ignore WordPress.Security.NonceVerification.Missing
235 if ( ! empty( $_POST['vigilante_hp_website'] ) ) {
236 wp_die(
237 esc_html__( 'Your comment could not be submitted. Please try again.', 'vigilante' ),
238 esc_html__( 'Comment Blocked', 'vigilante' ),
239 array( 'response' => 403, 'back_link' => true )
240 );
241 }
242
243 return $commentdata;
244 }
245
246 /**
247 * Check for excessive links in comment
248 *
249 * @param array $commentdata Comment data.
250 * @return array
251 */
252 public function check_link_limit( $commentdata ) {
253 $limit = absint( $this->options['link_limit'] ?? 2 );
254 $content = $commentdata['comment_content'];
255
256 // Count links
257 $link_count = preg_match_all( '/<a\s/i', $content, $matches );
258 $link_count += preg_match_all( '/https?:\/\//i', $content, $matches );
259
260 // Remove duplicates from the count
261 $link_count = $link_count / 2;
262
263 if ( $link_count > $limit ) {
264 wp_die(
265 sprintf(
266 /* translators: %d: Maximum number of links allowed */
267 esc_html__( 'Your comment contains too many links. Maximum allowed: %d', 'vigilante' ),
268 absint( $limit )
269 ),
270 esc_html__( 'Comment Blocked', 'vigilante' ),
271 array( 'response' => 403, 'back_link' => true )
272 );
273 }
274
275 return $commentdata;
276 }
277
278 /**
279 * Check for blocked patterns in comment
280 *
281 * @param array $commentdata Comment data.
282 * @return array
283 */
284 public function check_blocked_patterns( $commentdata ) {
285 $patterns = $this->options['block_patterns'] ?? array();
286
287 if ( empty( $patterns ) ) {
288 return $commentdata;
289 }
290
291 $content = strtolower( $commentdata['comment_content'] . ' ' . $commentdata['comment_author'] );
292
293 foreach ( $patterns as $pattern ) {
294 $pattern = trim( strtolower( $pattern ) );
295 if ( ! empty( $pattern ) && strpos( $content, $pattern ) !== false ) {
296 wp_die(
297 esc_html__( 'Your comment could not be submitted. It contains blocked content.', 'vigilante' ),
298 esc_html__( 'Comment Blocked', 'vigilante' ),
299 array( 'response' => 403, 'back_link' => true )
300 );
301 }
302 }
303
304 return $commentdata;
305 }
306
307 /**
308 * Check for blocked IPs
309 *
310 * @param array $commentdata Comment data.
311 * @return array
312 */
313 public function check_blocked_ips( $commentdata ) {
314 $blocked_ips = $this->options['block_ips'] ?? array();
315
316 if ( empty( $blocked_ips ) ) {
317 return $commentdata;
318 }
319
320 $commenter_ip = isset( $_SERVER['REMOTE_ADDR'] )
321 ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
322 : '';
323
324 foreach ( $blocked_ips as $blocked_ip ) {
325 $blocked_ip = trim( $blocked_ip );
326 if ( $blocked_ip === $commenter_ip ) {
327 wp_die(
328 esc_html__( 'Your comment could not be submitted.', 'vigilante' ),
329 esc_html__( 'Comment Blocked', 'vigilante' ),
330 array( 'response' => 403, 'back_link' => true )
331 );
332 }
333 }
334
335 return $commentdata;
336 }
337
338 /**
339 * Apply comment security settings to WordPress options
340 */
341 public function apply_settings() {
342 // Disable pingbacks
343 if ( ! empty( $this->options['disable_pingbacks'] ) ) {
344 update_option( 'default_pingback_flag', 0 );
345 }
346
347 // Disable trackbacks
348 if ( ! empty( $this->options['disable_trackbacks'] ) ) {
349 update_option( 'default_ping_status', 'closed' );
350 }
351
352 // Require moderation
353 if ( ! empty( $this->options['require_moderation'] ) ) {
354 update_option( 'comment_moderation', 1 );
355 }
356
357 // Require name and email
358 if ( ! empty( $this->options['require_name_email'] ) ) {
359 update_option( 'require_name_email', 1 );
360 }
361
362 // Require registration
363 if ( ! empty( $this->options['require_registration'] ) ) {
364 update_option( 'comment_registration', 1 );
365 }
366 }
367
368 /**
369 * Get spam statistics
370 *
371 * @return array
372 */
373 public function get_spam_stats() {
374 global $wpdb;
375
376 $stats = array(
377 'total_comments' => 0,
378 'approved' => 0,
379 'pending' => 0,
380 'spam' => 0,
381 'trash' => 0,
382 );
383
384 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
385 $counts = $wpdb->get_results(
386 "SELECT comment_approved, COUNT(*) as count FROM {$wpdb->comments} GROUP BY comment_approved",
387 ARRAY_A
388 );
389
390 foreach ( $counts as $count ) {
391 switch ( $count['comment_approved'] ) {
392 case '1':
393 $stats['approved'] = absint( $count['count'] );
394 break;
395 case '0':
396 $stats['pending'] = absint( $count['count'] );
397 break;
398 case 'spam':
399 $stats['spam'] = absint( $count['count'] );
400 break;
401 case 'trash':
402 $stats['trash'] = absint( $count['count'] );
403 break;
404 }
405 }
406
407 $stats['total_comments'] = $stats['approved'] + $stats['pending'] + $stats['spam'] + $stats['trash'];
408
409 return $stats;
410 }
411 }