PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.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.10.0, at includes/class-firewall.php

1,149 lines 38.3 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 * Check rate limiting
827 */
828 public function check_rate_limit() {
829 // Skip rate limiting for whitelisted IPs
830 if ( $this->is_ip_whitelisted() ) {
831 return;
832 }
833
834 // Skip rate limiting for logged-in administrators
835 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
836 return;
837 }
838
839 // Allow other modules to opt out — Under Attack mode uses this so that
840 // visitors who already passed the JS challenge don't burn the
841 // aggressive 30 req/min cap loading a normal page's assets.
842 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
843 return;
844 }
845
846 $ip = $this->get_client_ip();
847 $rate_limit = $this->options['rate_limiting'];
848
849 // Check if already blocked via queryable option (fast path)
850 $active_blocks = get_option( 'vigilante_firewall_blocks', array() );
851 if ( isset( $active_blocks[ $ip ] ) ) {
852 if ( time() < $active_blocks[ $ip ]['expires'] ) {
853 if ( ! headers_sent() ) {
854 status_header( 429 );
855 nocache_headers();
856 }
857 wp_die(
858 esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
859 esc_html__( 'Too Many Requests', 'vigilante' ),
860 array( 'response' => 429 )
861 );
862 }
863 // Expired — clean up
864 unset( $active_blocks[ $ip ] );
865 update_option( 'vigilante_firewall_blocks', $active_blocks, false );
866 }
867
868 $max_requests = absint( $rate_limit['requests_per_minute'] );
869
870 // Allow Under Attack mode (or other filters) to override threshold
871 $max_requests = absint( apply_filters( 'vigilante_rate_limit_requests', $max_requests ) );
872
873 // Fixed window, anchored to the timestamp of its first request.
874 //
875 // The count used to live in a transient whose TTL was renewed on every
876 // hit, which is a window that never closes: any IP going less than 60 s
877 // between requests kept accumulating, so the effective limit was not
878 // "requests per minute" but "requests since the last full minute of
879 // silence". A logged-in editor publishing several posts in a row could
880 // pile up 150+ requests while never exceeding 60 in any single minute,
881 // and got a 429. Storing the window start makes the reset explicit
882 // instead of relying on the transient expiring.
883 $transient_key = 'vigilante_rate_' . md5( $ip );
884 $window = get_transient( $transient_key );
885 $now = time();
886
887 // Counts stored before 2.9.5 were a bare integer with no window start.
888 // There is no way to tell how old such a count is, so open a new window.
889 if ( ! is_array( $window ) || ! isset( $window['start'], $window['count'] ) ) {
890 $window = array(
891 'start' => $now,
892 'count' => 0,
893 );
894 }
895
896 // Window elapsed: start counting again, even under continuous traffic.
897 if ( ( $now - absint( $window['start'] ) ) >= self::RATE_LIMIT_WINDOW ) {
898 $window = array(
899 'start' => $now,
900 'count' => 0,
901 );
902 }
903
904 // Count this request, then allow up to $max_requests per window.
905 $window['count'] = absint( $window['count'] ) + 1;
906 $request_count = $window['count'];
907
908 if ( $request_count > $max_requests ) {
909 $base_duration = absint( $rate_limit['block_duration'] );
910
911 // Allow Under Attack mode (or other filters) to override duration
912 $base_duration = absint( apply_filters( 'vigilante_rate_limit_duration', $base_duration ) );
913
914 $duration = $base_duration;
915 $strikes = 1;
916
917 // Progressive blocking: double duration on each repeat offense
918 if ( ! empty( $rate_limit['progressive'] ) ) {
919 $strikes_key = 'vigilante_strikes_' . md5( $ip );
920 $strikes = absint( get_transient( $strikes_key ) ) + 1;
921
922 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
923 $duration = min(
924 $base_duration * pow( 2, $strikes - 1 ),
925 $max_duration
926 );
927
928 // Persist strikes for 24h so they accumulate across blocks
929 set_transient( $strikes_key, $strikes, 86400 );
930 }
931
932 // Store block in queryable option for admin UI
933 $active_blocks[ $ip ] = array(
934 'expires' => time() + $duration,
935 'blocked_at' => time(),
936 'duration' => $duration,
937 'reason' => 'rate_limit',
938 'strikes' => $strikes,
939 );
940 update_option( 'vigilante_firewall_blocks', $active_blocks, false );
941
942 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
943 }
944
945 // The TTL only garbage-collects the payload once the IP goes quiet; what
946 // bounds the count is the window reset above, not the expiry.
947 set_transient( $transient_key, $window, self::RATE_LIMIT_WINDOW );
948 }
949
950 /**
951 * Block a request
952 *
953 * @param string $reason Reason code for blocking.
954 * @param string $message Message to log.
955 * @param int $status_code HTTP status code.
956 */
957 private function block_request( $reason, $message, $status_code = 403 ) {
958 // Log the block
959 if ( $this->activity_log ) {
960 $this->activity_log->log(
961 'firewall',
962 'blocked',
963 $message,
964 array(
965 'reason' => $reason,
966 'uri' => $this->request_data['uri'] ?? '',
967 'ip' => $this->get_client_ip(),
968 'user_agent'=> $this->request_data['user_agent'] ?? '',
969 ),
970 'warning'
971 );
972 }
973
974 // Set response headers
975 if ( ! headers_sent() ) {
976 status_header( $status_code );
977 nocache_headers();
978 }
979
980 // Return appropriate response
981 if ( 429 === $status_code ) {
982 wp_die(
983 esc_html( $message ),
984 esc_html__( 'Too Many Requests', 'vigilante' ),
985 array( 'response' => 429 )
986 );
987 }
988
989 wp_die(
990 esc_html( $message ),
991 esc_html__( 'Forbidden', 'vigilante' ),
992 array( 'response' => 403 )
993 );
994 }
995
996 /**
997 * Check if current IP is whitelisted
998 *
999 * @return bool
1000 */
1001 private function is_ip_whitelisted() {
1002 $whitelist = $this->options['ip_whitelist'] ?? array();
1003
1004 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $whitelist );
1005 }
1006
1007 /**
1008 * Check if current IP is blacklisted
1009 *
1010 * @return bool
1011 */
1012 private function is_ip_blacklisted() {
1013 $blacklist = $this->options['ip_blacklist'] ?? array();
1014
1015 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $blacklist );
1016 }
1017
1018 /**
1019 * Check if current User-Agent is whitelisted
1020 *
1021 * Partial matching: if the request UA contains any whitelisted string,
1022 * it bypasses all firewall checks. Useful for services like ManageWP, MainWP, etc.
1023 *
1024 * @return bool
1025 */
1026 private function is_ua_whitelisted() {
1027 $whitelist = $this->options['ua_whitelist'] ?? array();
1028
1029 if ( empty( $whitelist ) ) {
1030 return false;
1031 }
1032
1033 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1034
1035 if ( empty( $user_agent ) ) {
1036 return false;
1037 }
1038
1039 $ua_lower = strtolower( $user_agent );
1040
1041 foreach ( $whitelist as $allowed ) {
1042 $allowed = trim( $allowed );
1043 if ( ! empty( $allowed ) && false !== strpos( $ua_lower, strtolower( $allowed ) ) ) {
1044 return true;
1045 }
1046 }
1047
1048 return false;
1049 }
1050
1051 /**
1052 * Check if current User-Agent is blacklisted
1053 *
1054 * Partial matching: if the request UA contains any blacklisted string, block it.
1055 *
1056 * @return bool
1057 */
1058 private function is_ua_blacklisted() {
1059 $blacklist = $this->options['ua_blacklist'] ?? array();
1060
1061 if ( empty( $blacklist ) ) {
1062 return false;
1063 }
1064
1065 $user_agent = $this->request_data['user_agent'] ?? '';
1066
1067 if ( empty( $user_agent ) ) {
1068 return false;
1069 }
1070
1071 $ua_lower = strtolower( $user_agent );
1072
1073 foreach ( $blacklist as $blocked ) {
1074 $blocked = trim( $blocked );
1075 if ( ! empty( $blocked ) && false !== strpos( $ua_lower, strtolower( $blocked ) ) ) {
1076 return true;
1077 }
1078 }
1079
1080 return false;
1081 }
1082
1083 /**
1084 * Get client IP address
1085 *
1086 * Delegates to the shared resolver, which only trusts REMOTE_ADDR unless a
1087 * proxy header has been explicitly declared in settings.
1088 *
1089 * @return string
1090 */
1091 private function get_client_ip() {
1092 return Vigilante_IP_Utils::get_client_ip();
1093 }
1094
1095 // =========================================================================
1096 // BLOCK MANAGEMENT (static, for admin UI)
1097 // =========================================================================
1098
1099 /**
1100 * Get currently active firewall blocks
1101 *
1102 * Cleans expired entries on each call.
1103 *
1104 * @return array Active blocks keyed by IP address.
1105 */
1106 public static function get_active_blocks() {
1107 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1108 $now = time();
1109 $dirty = false;
1110
1111 foreach ( $blocks as $ip => $data ) {
1112 if ( $now >= $data['expires'] ) {
1113 unset( $blocks[ $ip ] );
1114 $dirty = true;
1115 }
1116 }
1117
1118 if ( $dirty ) {
1119 update_option( 'vigilante_firewall_blocks', $blocks, false );
1120 }
1121
1122 return $blocks;
1123 }
1124
1125 /**
1126 * Manually unblock an IP from rate limit blocks
1127 *
1128 * @param string $ip IP address to unblock.
1129 * @return bool Whether the IP was found and removed.
1130 */
1131 public static function unblock_ip( $ip ) {
1132 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1133
1134 if ( ! isset( $blocks[ $ip ] ) ) {
1135 return false;
1136 }
1137
1138 unset( $blocks[ $ip ] );
1139 update_option( 'vigilante_firewall_blocks', $blocks, false );
1140
1141 // Clean related transients
1142 $hash = md5( $ip );
1143 delete_transient( 'vigilante_rate_block_' . $hash );
1144 delete_transient( 'vigilante_rate_' . $hash );
1145 delete_transient( 'vigilante_strikes_' . $hash );
1146
1147 return true;
1148 }
1149 }