PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.0
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-firewall.php

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

1,206 lines 40.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Firewall Class
4 *
5 * WordPress-optimized firewall protection
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Firewall
17 *
18 * Provides firewall protection against common attacks
19 */
20 class Vigilante_Firewall {
21
22 /**
23 * Rate limiting window, in seconds.
24 *
25 * The "Requests per Minute" setting is measured over this window.
26 *
27 * @var int
28 */
29 const RATE_LIMIT_WINDOW = 60;
30
31 /**
32 * Settings instance
33 *
34 * @var Vigilante_Settings
35 */
36 private $settings;
37
38 /**
39 * Activity log instance
40 *
41 * @var Vigilante_Activity_Log
42 */
43 private $activity_log;
44
45 /**
46 * Firewall options
47 *
48 * @var array
49 */
50 private $options;
51
52 /**
53 * Current request data
54 *
55 * @var array
56 */
57 private $request_data = array();
58
59 /**
60 * Memoized haystack the pattern checks run against
61 *
62 * @since 2.9.9
63 *
64 * @var string|null
65 */
66 private $haystack = null;
67
68 /**
69 * Constructor
70 *
71 * @param Vigilante_Settings $settings Settings instance.
72 * @param Vigilante_Activity_Log $activity_log Activity log instance.
73 */
74 public function __construct( $settings, $activity_log ) {
75 $this->settings = $settings;
76 $this->activity_log = $activity_log;
77 $this->options = $settings->get_section( 'firewall' );
78
79 // Run firewall checks - must be after plugin init (priority 1)
80 add_action( 'init', array( $this, 'run_firewall' ), 2 );
81
82 // Rate limiting
83 if ( ! empty( $this->options['rate_limiting']['enabled'] ) ) {
84 add_action( 'init', array( $this, 'check_rate_limit' ), 2 );
85 }
86 }
87
88 /**
89 * Run all firewall checks
90 */
91 public function run_firewall() {
92 // Skip for whitelisted IPs
93 if ( $this->is_ip_whitelisted() ) {
94 return;
95 }
96
97 // Skip for whitelisted User-Agents (ManageWP, MainWP, etc.)
98 if ( $this->is_ua_whitelisted() ) {
99 return;
100 }
101
102 // Check if IP is blacklisted
103 if ( $this->is_ip_blacklisted() ) {
104 $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) );
105 }
106
107 // Gather request data
108 $this->gather_request_data();
109
110 // Check if User-Agent is blacklisted (after gathering request data)
111 if ( $this->is_ua_blacklisted() ) {
112 $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) );
113 }
114
115 // Run security checks
116 // NOTE: These are PHP-based checks that complement htaccess rules
117 // Some protections exist in both layers for defense in depth
118 $checks = array(
119 // PHP request filtering (complements htaccess block_bad_query_strings)
120 'block_bad_query_strings' => 'check_query_strings',
121 'block_sql_injection' => 'check_sql_injection',
122 'block_xss_attacks' => 'check_xss_attacks',
123 'block_file_inclusion' => 'check_file_inclusion',
124 'block_directory_traversal' => 'check_directory_traversal',
125 // Bot protection (complements htaccess block_bad_bots)
126 'block_bad_bots' => 'check_bad_bots',
127 'block_empty_user_agent' => 'check_empty_user_agent',
128 );
129
130 foreach ( $checks as $option => $method ) {
131 if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) {
132 $result = $this->$method();
133 if ( is_string( $result ) ) {
134 $this->block_request( $option, $result );
135 }
136 }
137 }
138
139 // Check HTTP method if limit_http_methods is enabled
140 if ( ! empty( $this->options['limit_http_methods'] ) ) {
141 $this->check_http_method();
142 }
143 }
144
145 /**
146 * Gather current request data
147 */
148 private function gather_request_data() {
149 $this->haystack = null;
150
151 $this->request_data = array(
152 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
153 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '',
154 /*
155 * Copies that keep the percent encoding, used only as the haystack
156 * of the pattern checks and never logged, printed or stored.
157 *
158 * They exist because sanitize_text_field() deletes every %XX
159 * sequence instead of decoding it: the copies above are the payload
160 * with the evidence removed, so an encoded attack was invisible to
161 * every rule that reads them. Measured on 22 aug 2026 against 2.9.8,
162 * ?x=%3Cscript%3E, javascript%3A, php%3A%2F%2F and GLOBALS%5B all
163 * reached the checks as harmless text and went straight through.
164 *
165 * No sanitizer is applied, and that is the point: every one of them
166 * destroys exactly what has to be matched. sanitize_text_field()
167 * deletes the %XX sequences and strips tags. esc_url_raw() is worse
168 * here: measured on 22 aug 2026, it returns an empty string for a
169 * query that carries an unencoded :// , which is precisely the
170 * remote inclusion shape, so it would blind the firewall instead of
171 * arming it. These two values are never echoed, never stored and
172 * never reach a query; they are the haystack of preg_match() and
173 * nothing else.
174 */
175 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- inspection buffer for the pattern checks, see the note above. Sanitizing it is what hid the attacks. Never output, stored nor queried.
176 'uri_raw' => isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '',
177 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as uri_raw.
178 'query_raw' => isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : '',
179 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
180 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
181 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET',
182 'ip' => $this->get_client_ip(),
183 );
184 }
185
186 /**
187 * What the pattern checks run against: the request as it arrived, plus its decoded form
188 *
189 * Both forms on purpose. Some patterns look for the encoded shape, such as
190 * the null byte %00 or the %5b of GLOBALS[, and others for the decoded one,
191 * such as <script or ../. Feeding only one of the two leaves half the rules
192 * looking at something that cannot match.
193 *
194 * Decoded once, not twice: a second pass catches a bit more evasion and
195 * brings in false positives that are not worth it.
196 *
197 * @since 2.9.9
198 *
199 * @return string
200 */
201 private function inspection_haystack() {
202 if ( null !== $this->haystack ) {
203 return $this->haystack;
204 }
205
206 $raw = trim( (string) $this->request_data['uri_raw'] . ' ' . (string) $this->request_data['query_raw'] );
207 $decoded = rawurldecode( $raw );
208
209 $this->haystack = ( $raw === $decoded ) ? $raw : $raw . ' ' . $decoded;
210
211 return $this->haystack;
212 }
213
214 /**
215 * Check for malicious query strings
216 *
217 * @return string|false Error message or false if safe.
218 */
219 private function check_query_strings() {
220 $query = $this->request_data['query_raw'];
221
222 if ( empty( $query ) ) {
223 return false;
224 }
225
226 // Length is measured on the query alone, the rest of the patterns run
227 // against the whole request in both its raw and decoded forms.
228 $haystack = $this->inspection_haystack();
229
230 // Dangerous patterns
231 $patterns = array(
232 // Too long query strings
233 '/^.{4000,}$/s' => __( 'Query string too long', 'vigilante' ),
234
235 // Null bytes
236 '/(\x00|%00)/i' => __( 'Null byte detected', 'vigilante' ),
237
238 // PHP wrappers
239 '/php:\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
240 '/data:\/\//i' => __( 'Data wrapper detected', 'vigilante' ),
241
242 // Globals/Request manipulation
243 '/(globals|mosconfig)(\[|\%5b)/i' => __( 'Global manipulation attempt', 'vigilante' ),
244 '/_request(\[|\%5b)/i' => __( 'Request manipulation attempt', 'vigilante' ),
245
246 // Config file access
247 '/wp-config\.php/i' => __( 'Config file access attempt', 'vigilante' ),
248
249 // Common attack patterns
250 '/(\<|%3c).*script.*(\>|%3e)/i' => __( 'Script tag detected', 'vigilante' ),
251 '/document\.(cookie|location|write)/i' => __( 'DOM manipulation attempt', 'vigilante' ),
252 );
253
254 foreach ( $patterns as $pattern => $message ) {
255 // The length rule is anchored, so it has to see the query on its
256 // own; every other pattern gets the whole request.
257 $subject = ( '/^.{4000,}$/s' === $pattern ) ? $query : $haystack;
258
259 if ( preg_match( $pattern, $subject ) ) {
260 return $message;
261 }
262 }
263
264 return false;
265 }
266
267 /**
268 * Check for SQL injection attempts
269 *
270 * @return string|false Error message or false if safe.
271 */
272 private function check_sql_injection() {
273 // Skip SQL injection checks for authenticated admin users on admin pages
274 // WordPress handles sanitization for these requests
275 if ( is_admin() && is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
276 return false;
277 }
278
279 $to_check = array(
280 $this->inspection_haystack(),
281 );
282
283 // Check POST data, but exclude content fields that may contain legitimate code/text
284 // phpcs:ignore WordPress.Security.NonceVerification.Missing
285 if ( ! empty( $_POST ) ) {
286 // phpcs:ignore WordPress.Security.NonceVerification.Missing
287 $post_data = $_POST;
288
289 // Remove fields that commonly contain user content (posts, comments, etc.)
290 // These are sanitized by WordPress core
291 $excluded_fields = array(
292 'content',
293 'post_content',
294 'comment',
295 'description',
296 'excerpt',
297 'post_excerpt',
298 'message',
299 'bio',
300 'acf', // Advanced Custom Fields
301 'meta', // Post meta
302 'tax_input', // Taxonomy input
303 '_content', // Various content fields
304 );
305
306 foreach ( $excluded_fields as $field ) {
307 unset( $post_data[ $field ] );
308 }
309
310 // Only check remaining POST data if not empty
311 if ( ! empty( $post_data ) ) {
312 $to_check[] = wp_json_encode( $post_data );
313 }
314 }
315
316 $combined = implode( ' ', array_filter( $to_check ) );
317
318 if ( empty( $combined ) ) {
319 return false;
320 }
321
322 // SQL injection patterns - focused on actual attack vectors
323 $patterns = array(
324 // Union based injection - high confidence attack pattern
325 '/union\s+(all\s+)?select/i' => __( 'UNION SELECT detected', 'vigilante' ),
326
327 // SQL commands in URL/query string context (not in POST body)
328 // More specific pattern to reduce false positives
329 '/[\'\"]\s*(;|--|#)\s*(select|insert|update|delete|drop|truncate|alter|create)/i' => __( 'SQL command injection attempt', 'vigilante' ),
330
331 // Hex encoding of SQL - typically used in attacks
332 '/0x[0-9a-f]{16,}/i' => __( 'Hex encoding detected', 'vigilante' ),
333
334 // Benchmark/sleep attacks - time-based SQL injection
335 '/(benchmark|sleep)\s*\(\s*\d/i' => __( 'Time-based injection attempt', 'vigilante' ),
336
337 // Information schema access
338 '/information_schema\.(tables|columns|schemata)/i' => __( 'Schema access attempt', 'vigilante' ),
339
340 // Load file - file read attempt
341 '/load_file\s*\(/i' => __( 'Load file attempt', 'vigilante' ),
342
343 // Into outfile - file write attempt
344 '/into\s+(out|dump)file/i' => __( 'File write attempt', 'vigilante' ),
345
346 // Stacked queries with dangerous commands
347 '/;\s*(drop|truncate|delete\s+from|update\s+\w+\s+set)/i' => __( 'Stacked query injection', 'vigilante' ),
348 );
349
350 foreach ( $patterns as $pattern => $message ) {
351 if ( preg_match( $pattern, $combined ) ) {
352 return $message;
353 }
354 }
355
356 return false;
357 }
358
359 /**
360 * Check for XSS attacks
361 *
362 * @return string|false Error message or false if safe.
363 */
364 private function check_xss_attacks() {
365 $combined = $this->inspection_haystack();
366
367 if ( empty( $combined ) ) {
368 return false;
369 }
370
371 // Already carries the decoded form, see inspection_haystack().
372 $decoded = $combined;
373
374 // XSS patterns
375 $patterns = array(
376 // Script tags
377 '/<script[^>]*>/i' => __( 'Script tag detected', 'vigilante' ),
378
379 /*
380 * Event handlers. Two shapes, because the rule used to be a bare
381 * \bon\w+\s*= and that matches any parameter whose name starts
382 * with "on": only=, once=, online= and onboarding= were all
383 * answered with a 403 on every site with the firewall on, and the
384 * owner never saw it because it only hits visitors.
385 */
386 '/<[^>]*\bon\w+\s*=/i' => __( 'Event handler detected', 'vigilante' ),
387 '/\bon(abort|blur|change|click|contextmenu|copy|cut|dblclick|drag\w*|drop|error|focus\w*|input|invalid|key\w+|load\w*|mouse\w+|paste|pointer\w+|reset|resize|scroll|select|submit|toggle|touch\w+|transitionend|animation\w+|wheel)\s*=\s*["\']?\s*[\w.$]+\s*\(/i' => __( 'Event handler detected', 'vigilante' ),
388
389 // JavaScript protocol
390 '/javascript\s*:/i' => __( 'JavaScript protocol detected', 'vigilante' ),
391
392 // VBScript
393 '/vbscript\s*:/i' => __( 'VBScript detected', 'vigilante' ),
394
395 // Data URL
396 '/data\s*:[^,]*base64/i' => __( 'Base64 data URL detected', 'vigilante' ),
397
398 // Expression (IE)
399 '/expression\s*\(/i' => __( 'CSS expression detected', 'vigilante' ),
400
401 // Iframe injection
402 '/<iframe[^>]*>/i' => __( 'Iframe injection detected', 'vigilante' ),
403
404 // Object/embed
405 '/<(object|embed|applet)[^>]*>/i' => __( 'Object tag detected', 'vigilante' ),
406 );
407
408 foreach ( $patterns as $pattern => $message ) {
409 if ( preg_match( $pattern, $decoded ) ) {
410 return $message;
411 }
412 }
413
414 return false;
415 }
416
417 /**
418 * Check for file inclusion attacks
419 *
420 * @return string|false Error message or false if safe.
421 */
422 private function check_file_inclusion() {
423 $combined = $this->inspection_haystack();
424
425 if ( empty( $combined ) ) {
426 return false;
427 }
428
429 // Remote inclusion is decided on the parsed values, not on the raw
430 // string. Until 2.9.9 any '=' followed by an absolute URL tripped this
431 // rule, and legitimate links carry those all the time: a redirect_to
432 // back to the site itself, a return_url, a payment gateway callback.
433 // What makes it an inclusion attempt is the target being somewhere
434 // else, so a URL pointing at this very site is left alone.
435 if ( $this->has_remote_inclusion() ) {
436 return __( 'Remote file inclusion attempt', 'vigilante' );
437 }
438
439 // File inclusion patterns
440 $patterns = array(
441 // PHP wrappers
442 '/(php|zip|glob|phar|ssh2|rar|ogg|expect):\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
443
444 // System files
445 '/\/etc\/(passwd|shadow|hosts)/i' => __( 'System file access attempt', 'vigilante' ),
446 '/\/proc\/self/i' => __( 'Proc access attempt', 'vigilante' ),
447
448 // Windows paths
449 '/[a-z]:\\\\(windows|winnt)/i' => __( 'Windows path detected', 'vigilante' ),
450 );
451
452 foreach ( $patterns as $pattern => $message ) {
453 if ( preg_match( $pattern, $combined ) ) {
454 return $message;
455 }
456 }
457
458 return false;
459 }
460
461 /**
462 * Whether the request carries a URL that points outside this site
463 *
464 * Works on the parsed parameters rather than on a pattern match over the
465 * whole string, for two reasons: a link back to the site itself is not
466 * mistaken for an attack, and an encoded payload is seen for what it is.
467 * The copy of the query string kept for logging goes through
468 * sanitize_text_field(), which strips every %XX sequence instead of
469 * decoding it, so the encoded form never looked like a URL there.
470 *
471 * @since 2.9.9
472 *
473 * @return bool
474 */
475 private function has_remote_inclusion() {
476 $query = $this->request_data['query_raw'];
477
478 if ( '' === $query ) {
479 return false;
480 }
481
482 // parse_str() decodes as it splits, so this sees the same values PHP
483 // would have put in $_GET, without reading the superglobal.
484 $params = array();
485 parse_str( $query, $params );
486
487 $values = array();
488 array_walk_recursive(
489 $params,
490 function ( $value ) use ( &$values ) {
491 if ( is_scalar( $value ) ) {
492 $values[] = (string) $value;
493 }
494 }
495 );
496
497 $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) );
498
499 foreach ( $values as $value ) {
500 if ( ! preg_match_all( '/(?:https?|ftp):\/\/[^\s\'"<>]+/i', $value, $matches ) ) {
501 continue;
502 }
503
504 foreach ( $matches[0] as $url ) {
505 $host = $this->normalize_host( wp_parse_url( $url, PHP_URL_HOST ) );
506
507 if ( '' === $host || $host !== $home_host ) {
508 return true;
509 }
510 }
511 }
512
513 return false;
514 }
515
516 /**
517 * Host in a comparable form: lowercase and without a leading www.
518 *
519 * @since 2.9.9
520 *
521 * @param string|null $host Host to normalize.
522 * @return string
523 */
524 private function normalize_host( $host ) {
525 $host = strtolower( trim( (string) $host ) );
526
527 return ( 0 === strpos( $host, 'www.' ) ) ? substr( $host, 4 ) : $host;
528 }
529
530 /**
531 * Check for directory traversal attacks
532 *
533 * @return string|false Error message or false if safe.
534 */
535 private function check_directory_traversal() {
536 $combined = $this->inspection_haystack();
537
538 if ( empty( $combined ) ) {
539 return false;
540 }
541
542 // Directory traversal patterns
543 $patterns = array(
544 '/\.\.\//i' => __( 'Directory traversal detected', 'vigilante' ),
545 '/\.\.%2f/i' => __( 'Encoded traversal detected', 'vigilante' ),
546 '/%2e%2e\//i' => __( 'Double encoded traversal', 'vigilante' ),
547 '/\.\.%5c/i' => __( 'Backslash traversal detected', 'vigilante' ),
548 );
549
550 foreach ( $patterns as $pattern => $message ) {
551 if ( preg_match( $pattern, $combined ) ) {
552 return $message;
553 }
554 }
555
556 return false;
557 }
558
559 /**
560 * Check for PHP execution in uploads
561 *
562 * @return string|false Error message or false if safe.
563 */
564 private function check_php_in_uploads() {
565 $uri = $this->inspection_haystack();
566
567 // Check if accessing PHP in uploads directory
568 if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) {
569 return __( 'PHP execution in uploads blocked', 'vigilante' );
570 }
571
572 return false;
573 }
574
575 /**
576 * Check for bad bots
577 *
578 * @return string|false Error message or false if safe.
579 */
580 private function check_bad_bots() {
581 $user_agent = strtolower( $this->request_data['user_agent'] );
582
583 if ( empty( $user_agent ) ) {
584 return false;
585 }
586
587 // Known malicious bots and scanners
588 // NOTE: Matching is done via strpos() on the full User-Agent string,
589 // so entries must be specific enough to avoid false positives with
590 // legitimate services, plugins, or WordPress loopback requests.
591 // Generic short words (e.g. 'scan', 'ninja', 'titan') must stay out
592 // of BOTH this list and the htaccess one: the htaccess regex matches
593 // bare substrings too, and unlike this layer it runs before PHP, so
594 // the ua_whitelist cannot rescue a false positive there.
595 $bad_bots = array(
596 'ahrefsbot',
597 'semrushbot',
598 'dotbot',
599 'mj12bot',
600 'blexbot',
601 'linkdexbot',
602 'aspiegelbot',
603 'alexibot',
604 'backlink',
605 'bandit',
606 'batchftp',
607 'bigfoot',
608 'blackwidow',
609 'blowfish',
610 'botalot',
611 'builtbottough',
612 'bullseye',
613 'cheesebot',
614 'cherrypicker',
615 'chinaclaw',
616 'copyrightcheck',
617 'crescent',
618 'curl/',
619 'dittospyder',
620 'dragonfly',
621 'easydl',
622 'ebingbong',
623 'ecatch',
624 'eirgrabber',
625 'emailcollector',
626 'emailsiphon',
627 'emailwolf',
628 'erocrawler',
629 'exabot',
630 'expressweb',
631 'eyenetie',
632 'flashget',
633 'flunky',
634 'frontpage',
635 'getright',
636 'getweb',
637 'go-ahead-got-it',
638 'gotit',
639 'grabnet',
640 'grafula',
641 'harvest',
642 'hloader',
643 'hmview',
644 'httplib',
645 'httrack',
646 'humanlinks',
647 'ia_archiver',
648 'imagestripper',
649 'imagesucker',
650 'indy library',
651 'infonavirobot',
652 'infotekies',
653 'intelliseek',
654 'interget',
655 'intraformant',
656 'jakarta',
657 'jennybot',
658 'jetcar',
659 'kenjin',
660 'larbin',
661 'leechftp',
662 'lexibot',
663 'libweb',
664 'likse',
665 'linkscan',
666 'linkwalker',
667 'lnspiderguy',
668 'lwp',
669 'magnet',
670 'mag-net',
671 'markwatch',
672 'mass downloader',
673 'masscan',
674 'microsoft.url',
675 'midown',
676 'miixpc',
677 'missigua',
678 'moget',
679 'nameprotect',
680 'navroad',
681 'nearsite',
682 'net vampire',
683 'netants',
684 'netcraft',
685 'netmechanic',
686 'netspider',
687 'nextgensearchbot',
688 'nibbler',
689 'nicerspro',
690 'niki-bot',
691 'npbot',
692 'offline explorer',
693 'offline navigator',
694 'openfind',
695 'outfoxbot',
696 'pagegrabber',
697 'pavuk',
698 'pcbrowser',
699 'php/',
700 'pockey',
701 'prowebwalker',
702 'psycheclone',
703 'python-urllib',
704 'python-requests',
705 'python/',
706 'queryn',
707 'reget',
708 'repomonkey',
709 'siphon',
710 'siteexplorer',
711 'sitesnagger',
712 'slurp',
713 'smartdownload',
714 'snapbot',
715 'snoopy',
716 'sogou',
717 'spacebison',
718 'spankbot',
719 'sqworm',
720 'superbot',
721 'superhttp',
722 'surfbot',
723 'suzuran',
724 'szukacz',
725 'takeout',
726 'teleport',
727 'telesoft',
728 'thenomad',
729 'tighttwatbot',
730 'true_robot',
731 'turingos',
732 'turnitinbot',
733 'voideye',
734 'webalta',
735 'webbandit',
736 'webcollector',
737 'webcopier',
738 'webdup',
739 'webenhancer',
740 'webfetch',
741 'webgo',
742 'webmasterworldforumbot',
743 'webpictures',
744 'webreaper',
745 'websauger',
746 'webspider',
747 'webstripper',
748 'websucker',
749 'webwhacker',
750 'webzip',
751 'widow',
752 'wisenut',
753 'wwwoffle',
754 'xaldon',
755 'xxxyy',
756 'zeus',
757 'zermelo',
758 'zyborg',
759 );
760
761 foreach ( $bad_bots as $bot ) {
762 if ( strpos( $user_agent, $bot ) !== false ) {
763 return sprintf(
764 /* translators: %s: Bot name */
765 __( 'Bad bot blocked: %s', 'vigilante' ),
766 $bot
767 );
768 }
769 }
770
771 return false;
772 }
773
774 /**
775 * Check for empty user agent
776 *
777 * @return string|false Error message or false if safe.
778 */
779 private function check_empty_user_agent() {
780 if ( empty( $this->request_data['user_agent'] ) ) {
781 return __( 'Empty user agent blocked', 'vigilante' );
782 }
783 return false;
784 }
785
786 /**
787 * Check HTTP method
788 *
789 * Logged-in users with edit capabilities are excluded to ensure
790 * Gutenberg, REST API, and page builders work correctly.
791 */
792 private function check_http_method() {
793 // Skip for authenticated users who can edit content
794 // They need OPTIONS, PUT, PATCH, DELETE for Gutenberg, REST API, and page builders
795 if ( is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
796 return;
797 }
798
799 // Skip for WordPress REST API requests
800 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
801 // authentication and authorization layer — no need to filter methods here
802 $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
803 if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
804 return;
805 }
806
807 $method = strtoupper( $this->request_data['method'] );
808 $allowed_methods = isset( $this->options['allowed_http_methods'] )
809 ? $this->options['allowed_http_methods']
810 : array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' );
811 $allowed = array_map( 'strtoupper', $allowed_methods );
812
813 if ( ! in_array( $method, $allowed, true ) ) {
814 $this->block_request(
815 'http_method',
816 sprintf(
817 /* translators: %s: HTTP method */
818 __( 'HTTP method %s not allowed', 'vigilante' ),
819 $method
820 )
821 );
822 }
823 }
824
825 /**
826 * Upper bound of the vigilante_firewall_blocks index
827 *
828 * The index only feeds the admin screen; enforcement reads a transient per
829 * IP. Under a distributed attack the oldest entries are dropped first, so
830 * the option cannot grow without limit (S6).
831 *
832 * @since 2.11.0
833 */
834 const MAX_TRACKED_BLOCKS = 500;
835
836 /**
837 * Add a block to the bounded admin index
838 *
839 * Prunes expired entries on every write, not only when an administrator
840 * opens the Firewall tab, and keeps at most MAX_TRACKED_BLOCKS entries,
841 * dropping the oldest by blocked_at.
842 *
843 * @since 2.11.0
844 *
845 * @param string $ip Blocked address.
846 * @param array $block Block data (expires, blocked_at, duration, reason, strikes).
847 */
848 private static function index_block( $ip, $block ) {
849 $blocks = get_option( 'vigilante_firewall_blocks', array() );
850 $now = time();
851
852 if ( ! is_array( $blocks ) ) {
853 $blocks = array();
854 }
855
856 foreach ( $blocks as $blocked_ip => $data ) {
857 if ( ! is_array( $data ) || ! isset( $data['expires'] ) || $now >= (int) $data['expires'] ) {
858 unset( $blocks[ $blocked_ip ] );
859 }
860 }
861
862 $blocks[ $ip ] = $block;
863
864 if ( count( $blocks ) > self::MAX_TRACKED_BLOCKS ) {
865 uasort(
866 $blocks,
867 static function ( $a, $b ) {
868 return (int) ( $a['blocked_at'] ?? 0 ) <=> (int) ( $b['blocked_at'] ?? 0 );
869 }
870 );
871 $blocks = array_slice( $blocks, count( $blocks ) - self::MAX_TRACKED_BLOCKS, null, true );
872 }
873
874 update_option( 'vigilante_firewall_blocks', $blocks, false );
875 }
876
877 /**
878 * Check rate limiting
879 */
880 public function check_rate_limit() {
881 // Skip rate limiting for whitelisted IPs
882 if ( $this->is_ip_whitelisted() ) {
883 return;
884 }
885
886 // Skip rate limiting for logged-in administrators
887 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
888 return;
889 }
890
891 // Allow other modules to opt out — Under Attack mode uses this so that
892 // visitors who already passed the JS challenge don't burn the
893 // aggressive 30 req/min cap loading a normal page's assets.
894 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
895 return;
896 }
897
898 $ip = $this->get_client_ip();
899 $rate_limit = $this->options['rate_limiting'];
900
901 // Check if already blocked (fast path). The active block lives in a
902 // transient keyed by IP, so this path, which runs on every
903 // unauthenticated request, reads one row and not the whole index of
904 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
905 // entire, an array with no upper bound that a distributed attack grew
906 // by one entry per new address, so the firewall amplified the attack it
907 // was blocking (S6). The transient expires with the block itself.
908 $block = get_transient( 'vigilante_rate_block_' . md5( $ip ) );
909 if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) {
910 if ( ! headers_sent() ) {
911 status_header( 429 );
912 nocache_headers();
913 }
914 wp_die(
915 esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
916 esc_html__( 'Too Many Requests', 'vigilante' ),
917 array( 'response' => 429 )
918 );
919 }
920
921 $max_requests = absint( $rate_limit['requests_per_minute'] );
922
923 // Allow Under Attack mode (or other filters) to override threshold
924 $max_requests = absint( apply_filters( 'vigilante_rate_limit_requests', $max_requests ) );
925
926 // Fixed window, anchored to the timestamp of its first request.
927 //
928 // The count used to live in a transient whose TTL was renewed on every
929 // hit, which is a window that never closes: any IP going less than 60 s
930 // between requests kept accumulating, so the effective limit was not
931 // "requests per minute" but "requests since the last full minute of
932 // silence". A logged-in editor publishing several posts in a row could
933 // pile up 150+ requests while never exceeding 60 in any single minute,
934 // and got a 429. Storing the window start makes the reset explicit
935 // instead of relying on the transient expiring.
936 $transient_key = 'vigilante_rate_' . md5( $ip );
937 $window = get_transient( $transient_key );
938 $now = time();
939
940 // Counts stored before 2.9.5 were a bare integer with no window start.
941 // There is no way to tell how old such a count is, so open a new window.
942 if ( ! is_array( $window ) || ! isset( $window['start'], $window['count'] ) ) {
943 $window = array(
944 'start' => $now,
945 'count' => 0,
946 );
947 }
948
949 // Window elapsed: start counting again, even under continuous traffic.
950 if ( ( $now - absint( $window['start'] ) ) >= self::RATE_LIMIT_WINDOW ) {
951 $window = array(
952 'start' => $now,
953 'count' => 0,
954 );
955 }
956
957 // Count this request, then allow up to $max_requests per window.
958 $window['count'] = absint( $window['count'] ) + 1;
959 $request_count = $window['count'];
960
961 if ( $request_count > $max_requests ) {
962 $base_duration = absint( $rate_limit['block_duration'] );
963
964 // Allow Under Attack mode (or other filters) to override duration
965 $base_duration = absint( apply_filters( 'vigilante_rate_limit_duration', $base_duration ) );
966
967 $duration = $base_duration;
968 $strikes = 1;
969
970 // Progressive blocking: double duration on each repeat offense
971 if ( ! empty( $rate_limit['progressive'] ) ) {
972 $strikes_key = 'vigilante_strikes_' . md5( $ip );
973 $strikes = absint( get_transient( $strikes_key ) ) + 1;
974
975 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
976 $duration = min(
977 $base_duration * pow( 2, $strikes - 1 ),
978 $max_duration
979 );
980
981 // Persist strikes for 24h so they accumulate across blocks
982 set_transient( $strikes_key, $strikes, 86400 );
983 }
984
985 $block = array(
986 'expires' => time() + $duration,
987 'blocked_at' => time(),
988 'duration' => $duration,
989 'reason' => 'rate_limit',
990 'strikes' => $strikes,
991 );
992
993 // The block itself, read by the fast path above on every request.
994 set_transient( 'vigilante_rate_block_' . md5( $ip ), $block, $duration );
995
996 // The bounded index the admin screen lists.
997 self::index_block( $ip, $block );
998
999 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
1000 }
1001
1002 // The TTL only garbage-collects the payload once the IP goes quiet; what
1003 // bounds the count is the window reset above, not the expiry.
1004 set_transient( $transient_key, $window, self::RATE_LIMIT_WINDOW );
1005 }
1006
1007 /**
1008 * Block a request
1009 *
1010 * @param string $reason Reason code for blocking.
1011 * @param string $message Message to log.
1012 * @param int $status_code HTTP status code.
1013 */
1014 private function block_request( $reason, $message, $status_code = 403 ) {
1015 // Log the block
1016 if ( $this->activity_log ) {
1017 $this->activity_log->log(
1018 'firewall',
1019 'blocked',
1020 $message,
1021 array(
1022 'reason' => $reason,
1023 'uri' => $this->request_data['uri'] ?? '',
1024 'ip' => $this->get_client_ip(),
1025 'user_agent'=> $this->request_data['user_agent'] ?? '',
1026 ),
1027 'warning'
1028 );
1029 }
1030
1031 // Set response headers
1032 if ( ! headers_sent() ) {
1033 status_header( $status_code );
1034 nocache_headers();
1035 }
1036
1037 // Return appropriate response
1038 if ( 429 === $status_code ) {
1039 wp_die(
1040 esc_html( $message ),
1041 esc_html__( 'Too Many Requests', 'vigilante' ),
1042 array( 'response' => 429 )
1043 );
1044 }
1045
1046 wp_die(
1047 esc_html( $message ),
1048 esc_html__( 'Forbidden', 'vigilante' ),
1049 array( 'response' => 403 )
1050 );
1051 }
1052
1053 /**
1054 * Check if current IP is whitelisted
1055 *
1056 * @return bool
1057 */
1058 private function is_ip_whitelisted() {
1059 $whitelist = $this->options['ip_whitelist'] ?? array();
1060
1061 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $whitelist );
1062 }
1063
1064 /**
1065 * Check if current IP is blacklisted
1066 *
1067 * @return bool
1068 */
1069 private function is_ip_blacklisted() {
1070 $blacklist = $this->options['ip_blacklist'] ?? array();
1071
1072 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $blacklist );
1073 }
1074
1075 /**
1076 * Check if current User-Agent is whitelisted
1077 *
1078 * Partial matching: if the request UA contains any whitelisted string,
1079 * it bypasses all firewall checks. Useful for services like ManageWP, MainWP, etc.
1080 *
1081 * @return bool
1082 */
1083 private function is_ua_whitelisted() {
1084 $whitelist = $this->options['ua_whitelist'] ?? array();
1085
1086 if ( empty( $whitelist ) ) {
1087 return false;
1088 }
1089
1090 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1091
1092 if ( empty( $user_agent ) ) {
1093 return false;
1094 }
1095
1096 $ua_lower = strtolower( $user_agent );
1097
1098 foreach ( $whitelist as $allowed ) {
1099 $allowed = trim( $allowed );
1100 if ( ! empty( $allowed ) && false !== strpos( $ua_lower, strtolower( $allowed ) ) ) {
1101 return true;
1102 }
1103 }
1104
1105 return false;
1106 }
1107
1108 /**
1109 * Check if current User-Agent is blacklisted
1110 *
1111 * Partial matching: if the request UA contains any blacklisted string, block it.
1112 *
1113 * @return bool
1114 */
1115 private function is_ua_blacklisted() {
1116 $blacklist = $this->options['ua_blacklist'] ?? array();
1117
1118 if ( empty( $blacklist ) ) {
1119 return false;
1120 }
1121
1122 $user_agent = $this->request_data['user_agent'] ?? '';
1123
1124 if ( empty( $user_agent ) ) {
1125 return false;
1126 }
1127
1128 $ua_lower = strtolower( $user_agent );
1129
1130 foreach ( $blacklist as $blocked ) {
1131 $blocked = trim( $blocked );
1132 if ( ! empty( $blocked ) && false !== strpos( $ua_lower, strtolower( $blocked ) ) ) {
1133 return true;
1134 }
1135 }
1136
1137 return false;
1138 }
1139
1140 /**
1141 * Get client IP address
1142 *
1143 * Delegates to the shared resolver, which only trusts REMOTE_ADDR unless a
1144 * proxy header has been explicitly declared in settings.
1145 *
1146 * @return string
1147 */
1148 private function get_client_ip() {
1149 return Vigilante_IP_Utils::get_client_ip();
1150 }
1151
1152 // =========================================================================
1153 // BLOCK MANAGEMENT (static, for admin UI)
1154 // =========================================================================
1155
1156 /**
1157 * Get currently active firewall blocks
1158 *
1159 * Cleans expired entries on each call.
1160 *
1161 * @return array Active blocks keyed by IP address.
1162 */
1163 public static function get_active_blocks() {
1164 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1165 $now = time();
1166 $dirty = false;
1167
1168 foreach ( $blocks as $ip => $data ) {
1169 if ( $now >= $data['expires'] ) {
1170 unset( $blocks[ $ip ] );
1171 $dirty = true;
1172 }
1173 }
1174
1175 if ( $dirty ) {
1176 update_option( 'vigilante_firewall_blocks', $blocks, false );
1177 }
1178
1179 return $blocks;
1180 }
1181
1182 /**
1183 * Manually unblock an IP from rate limit blocks
1184 *
1185 * @param string $ip IP address to unblock.
1186 * @return bool Whether the IP was found and removed.
1187 */
1188 public static function unblock_ip( $ip ) {
1189 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1190
1191 if ( ! isset( $blocks[ $ip ] ) ) {
1192 return false;
1193 }
1194
1195 unset( $blocks[ $ip ] );
1196 update_option( 'vigilante_firewall_blocks', $blocks, false );
1197
1198 // Clean related transients
1199 $hash = md5( $ip );
1200 delete_transient( 'vigilante_rate_block_' . $hash );
1201 delete_transient( 'vigilante_rate_' . $hash );
1202 delete_transient( 'vigilante_strikes_' . $hash );
1203
1204 return true;
1205 }
1206 }