PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.4
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-comment-security.php

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

438 lines 14.2 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 // Answer the XML-RPC endpoint here, before core gets as far as
61 // building the server object. Until 2.9.9 this pointed the
62 // wp_xmlrpc_server_class filter at a class that does not exist,
63 // which is not a block but an uncaught Error: xmlrpc.php answered
64 // 500 with an empty body and wrote a PHP fatal to the log on every
65 // hit, and that endpoint is one of the most hammered by bots.
66 $this->block_xmlrpc_request();
67
68 add_filter( 'xmlrpc_enabled', '__return_false' );
69 remove_action( 'wp_head', 'rsd_link' );
70 remove_action( 'wp_head', 'wlwmanifest_link' );
71 } elseif ( 'pingback' === $xmlrpc_mode ) {
72 add_filter( 'xmlrpc_methods', array( $this, 'disable_pingback_methods' ) );
73 }
74
75 // Disable pingbacks/trackbacks
76 if ( ! empty( $this->options['disable_pingbacks'] ) ) {
77 add_filter( 'xmlrpc_methods', array( $this, 'disable_pingback_methods' ) );
78 add_filter( 'wp_headers', array( $this, 'remove_pingback_header' ) );
79 add_filter( 'pings_open', '__return_false', 9999 );
80 }
81
82 if ( ! empty( $this->options['disable_trackbacks'] ) ) {
83 add_filter( 'pings_open', '__return_false', 9999 );
84 }
85
86 // Close old comments (high priority to override WP native if needed)
87 if ( ! empty( $this->options['close_old_comments'] ) ) {
88 add_filter( 'comments_open', array( $this, 'close_old_comments' ), 9999, 2 );
89 }
90
91 // Honeypot
92 if ( ! empty( $this->options['honeypot_enabled'] ) ) {
93 add_action( 'comment_form', array( $this, 'add_honeypot_field' ) );
94 add_filter( 'preprocess_comment', array( $this, 'check_honeypot' ) );
95 }
96
97 // Link limit check
98 if ( ! empty( $this->options['link_limit'] ) ) {
99 add_filter( 'preprocess_comment', array( $this, 'check_link_limit' ) );
100 }
101
102 // Block patterns
103 if ( ! empty( $this->options['block_patterns'] ) ) {
104 add_filter( 'preprocess_comment', array( $this, 'check_blocked_patterns' ) );
105 }
106
107 // Block IPs
108 if ( ! empty( $this->options['block_ips'] ) ) {
109 add_filter( 'preprocess_comment', array( $this, 'check_blocked_ips' ) );
110 }
111 }
112
113 /**
114 * Disable pingback XML-RPC methods
115 *
116 * @param array $methods XML-RPC methods.
117 * @return array
118 */
119 /**
120 * Resolve the XML-RPC mode.
121 *
122 * The setting used to be two independent checkboxes under Login that could
123 * be on at the same time and contradict each other ("disable everything"
124 * plus "disable only pingback"). It is now a single three-way choice stored
125 * in wp_hardening.xmlrpc_mode, next to the pingback settings it relates to.
126 *
127 * Sites upgrading have neither, only the old pair under login_security, so
128 * their choice is read from there and nothing changes for them until they
129 * save the tab. An install with none of the three gets 'full', which is what
130 * the old defaults did (disable_xmlrpc shipped on and took precedence).
131 *
132 * Public and static so this class, the settings screen and the Security
133 * Check all resolve the value the same way and cannot drift apart.
134 *
135 * @param Vigilante_Settings $settings Settings instance.
136 * @return string 'full', 'pingback' or 'none'.
137 */
138 public static function resolve_xmlrpc_mode( $settings ) {
139 $hardening = $settings->get_section( 'wp_hardening' );
140 $mode = isset( $hardening['xmlrpc_mode'] ) ? (string) $hardening['xmlrpc_mode'] : '';
141
142 if ( in_array( $mode, array( 'full', 'pingback', 'none' ), true ) ) {
143 return $mode;
144 }
145
146 // Legacy pair under Login, only meaningful when one of them was stored.
147 $login = $settings->get_section( 'login_security' );
148 if ( array_key_exists( 'disable_xmlrpc', $login )
149 || array_key_exists( 'disable_xmlrpc_pingback', $login ) ) {
150 if ( ! empty( $login['disable_xmlrpc'] ) ) {
151 return 'full';
152 }
153 if ( ! empty( $login['disable_xmlrpc_pingback'] ) ) {
154 return 'pingback';
155 }
156 return 'none';
157 }
158
159 return 'full';
160 }
161
162 /**
163 * Answer an XML-RPC request with a plain 403 and stop
164 *
165 * Does nothing outside an XML-RPC request, so it is safe to call while the
166 * module is wiring its hooks. XMLRPC_REQUEST is defined at the top of
167 * xmlrpc.php, before wp-load.php, so it is already there by the time
168 * plugins load.
169 *
170 * The modules are built on init priority 1, which is inside init, so the
171 * translation functions are safe to use here.
172 *
173 * @since 2.9.9
174 */
175 private function block_xmlrpc_request() {
176 if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
177 return;
178 }
179
180 if ( ! headers_sent() ) {
181 status_header( 403 );
182 nocache_headers();
183 $charset = sanitize_text_field( (string) get_option( 'blog_charset', 'UTF-8' ) );
184 header( 'Content-Type: text/plain; charset=' . ( '' !== $charset ? $charset : 'UTF-8' ) );
185 }
186
187 echo esc_html__( 'XML-RPC services are disabled on this site.', 'vigilante' );
188 exit;
189 }
190
191 public function disable_pingback_methods( $methods ) {
192 unset( $methods['pingback.ping'] );
193 unset( $methods['pingback.extensions.getPingbacks'] );
194 return $methods;
195 }
196
197 /**
198 * Remove X-Pingback header
199 *
200 * @param array $headers HTTP headers.
201 * @return array
202 */
203 public function remove_pingback_header( $headers ) {
204 unset( $headers['X-Pingback'] );
205 return $headers;
206 }
207
208 /**
209 * Close comments on old posts
210 *
211 * @param bool $open Whether comments are open.
212 * @param int $post_id Post ID.
213 * @return bool
214 */
215 public function close_old_comments( $open, $post_id ) {
216 $post = get_post( $post_id );
217
218 if ( ! $post ) {
219 return $open;
220 }
221
222 // Don't close comments on WooCommerce products (reviews)
223 if ( 'product' === $post->post_type ) {
224 return 'open' === $post->comment_status;
225 }
226
227 if ( ! $open ) {
228 return $open;
229 }
230
231 $days = absint( $this->options['close_after_days'] ?? 30 );
232 $post_date = strtotime( $post->post_date );
233 $cutoff = strtotime( "-{$days} days" );
234
235 if ( $post_date < $cutoff ) {
236 return false;
237 }
238
239 return $open;
240 }
241
242 /**
243 * Add honeypot field to comment form
244 */
245 public function add_honeypot_field() {
246 ?>
247 <p class="vigilante-hp-field" style="display:none !important;">
248 <label for="vigilante_hp_website"><?php esc_html_e( 'Website', 'vigilante' ); ?></label>
249 <input type="text" name="vigilante_hp_website" id="vigilante_hp_website" value="" autocomplete="off" tabindex="-1" />
250 </p>
251 <?php
252 }
253
254 /**
255 * Check honeypot field
256 *
257 * @param array $commentdata Comment data.
258 * @return array
259 */
260 public function check_honeypot( $commentdata ) {
261 // phpcs:ignore WordPress.Security.NonceVerification.Missing
262 if ( ! empty( $_POST['vigilante_hp_website'] ) ) {
263 wp_die(
264 esc_html__( 'Your comment could not be submitted. Please try again.', 'vigilante' ),
265 esc_html__( 'Comment Blocked', 'vigilante' ),
266 array( 'response' => 403, 'back_link' => true )
267 );
268 }
269
270 return $commentdata;
271 }
272
273 /**
274 * Check for excessive links in comment
275 *
276 * @param array $commentdata Comment data.
277 * @return array
278 */
279 public function check_link_limit( $commentdata ) {
280 $limit = absint( $this->options['link_limit'] ?? 2 );
281 $content = $commentdata['comment_content'];
282
283 // Count links
284 $link_count = preg_match_all( '/<a\s/i', $content, $matches );
285 $link_count += preg_match_all( '/https?:\/\//i', $content, $matches );
286
287 // Remove duplicates from the count
288 $link_count = $link_count / 2;
289
290 if ( $link_count > $limit ) {
291 wp_die(
292 sprintf(
293 /* translators: %d: Maximum number of links allowed */
294 esc_html__( 'Your comment contains too many links. Maximum allowed: %d', 'vigilante' ),
295 absint( $limit )
296 ),
297 esc_html__( 'Comment Blocked', 'vigilante' ),
298 array( 'response' => 403, 'back_link' => true )
299 );
300 }
301
302 return $commentdata;
303 }
304
305 /**
306 * Check for blocked patterns in comment
307 *
308 * @param array $commentdata Comment data.
309 * @return array
310 */
311 public function check_blocked_patterns( $commentdata ) {
312 $patterns = $this->options['block_patterns'] ?? array();
313
314 if ( empty( $patterns ) ) {
315 return $commentdata;
316 }
317
318 $content = strtolower( $commentdata['comment_content'] . ' ' . $commentdata['comment_author'] );
319
320 foreach ( $patterns as $pattern ) {
321 $pattern = trim( strtolower( $pattern ) );
322 if ( ! empty( $pattern ) && strpos( $content, $pattern ) !== false ) {
323 wp_die(
324 esc_html__( 'Your comment could not be submitted. It contains blocked content.', 'vigilante' ),
325 esc_html__( 'Comment Blocked', 'vigilante' ),
326 array( 'response' => 403, 'back_link' => true )
327 );
328 }
329 }
330
331 return $commentdata;
332 }
333
334 /**
335 * Check for blocked IPs
336 *
337 * @param array $commentdata Comment data.
338 * @return array
339 */
340 public function check_blocked_ips( $commentdata ) {
341 $blocked_ips = $this->options['block_ips'] ?? array();
342
343 if ( empty( $blocked_ips ) ) {
344 return $commentdata;
345 }
346
347 $commenter_ip = isset( $_SERVER['REMOTE_ADDR'] )
348 ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
349 : '';
350
351 foreach ( $blocked_ips as $blocked_ip ) {
352 $blocked_ip = trim( $blocked_ip );
353 if ( $blocked_ip === $commenter_ip ) {
354 wp_die(
355 esc_html__( 'Your comment could not be submitted.', 'vigilante' ),
356 esc_html__( 'Comment Blocked', 'vigilante' ),
357 array( 'response' => 403, 'back_link' => true )
358 );
359 }
360 }
361
362 return $commentdata;
363 }
364
365 /**
366 * Apply comment security settings to WordPress options
367 */
368 public function apply_settings() {
369 // Disable pingbacks
370 if ( ! empty( $this->options['disable_pingbacks'] ) ) {
371 update_option( 'default_pingback_flag', 0 );
372 }
373
374 // Disable trackbacks
375 if ( ! empty( $this->options['disable_trackbacks'] ) ) {
376 update_option( 'default_ping_status', 'closed' );
377 }
378
379 // Require moderation
380 if ( ! empty( $this->options['require_moderation'] ) ) {
381 update_option( 'comment_moderation', 1 );
382 }
383
384 // Require name and email
385 if ( ! empty( $this->options['require_name_email'] ) ) {
386 update_option( 'require_name_email', 1 );
387 }
388
389 // Require registration
390 if ( ! empty( $this->options['require_registration'] ) ) {
391 update_option( 'comment_registration', 1 );
392 }
393 }
394
395 /**
396 * Get spam statistics
397 *
398 * @return array
399 */
400 public function get_spam_stats() {
401 global $wpdb;
402
403 $stats = array(
404 'total_comments' => 0,
405 'approved' => 0,
406 'pending' => 0,
407 'spam' => 0,
408 'trash' => 0,
409 );
410
411 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
412 $counts = $wpdb->get_results(
413 "SELECT comment_approved, COUNT(*) as count FROM {$wpdb->comments} GROUP BY comment_approved",
414 ARRAY_A
415 );
416
417 foreach ( $counts as $count ) {
418 switch ( $count['comment_approved'] ) {
419 case '1':
420 $stats['approved'] = absint( $count['count'] );
421 break;
422 case '0':
423 $stats['pending'] = absint( $count['count'] );
424 break;
425 case 'spam':
426 $stats['spam'] = absint( $count['count'] );
427 break;
428 case 'trash':
429 $stats['trash'] = absint( $count['count'] );
430 break;
431 }
432 }
433
434 $stats['total_comments'] = $stats['approved'] + $stats['pending'] + $stats['spam'] + $stats['trash'];
435
436 return $stats;
437 }
438 }