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-firewall.php

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

1,044 lines 33.1 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 * Constructor
61 *
62 * @param Vigilante_Settings $settings Settings instance.
63 * @param Vigilante_Activity_Log $activity_log Activity log instance.
64 */
65 public function __construct( $settings, $activity_log ) {
66 $this->settings = $settings;
67 $this->activity_log = $activity_log;
68 $this->options = $settings->get_section( 'firewall' );
69
70 // Run firewall checks - must be after plugin init (priority 1)
71 add_action( 'init', array( $this, 'run_firewall' ), 2 );
72
73 // Rate limiting
74 if ( ! empty( $this->options['rate_limiting']['enabled'] ) ) {
75 add_action( 'init', array( $this, 'check_rate_limit' ), 2 );
76 }
77 }
78
79 /**
80 * Run all firewall checks
81 */
82 public function run_firewall() {
83 // Skip for whitelisted IPs
84 if ( $this->is_ip_whitelisted() ) {
85 return;
86 }
87
88 // Skip for whitelisted User-Agents (ManageWP, MainWP, etc.)
89 if ( $this->is_ua_whitelisted() ) {
90 return;
91 }
92
93 // Check if IP is blacklisted
94 if ( $this->is_ip_blacklisted() ) {
95 $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) );
96 }
97
98 // Gather request data
99 $this->gather_request_data();
100
101 // Check if User-Agent is blacklisted (after gathering request data)
102 if ( $this->is_ua_blacklisted() ) {
103 $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) );
104 }
105
106 // Run security checks
107 // NOTE: These are PHP-based checks that complement htaccess rules
108 // Some protections exist in both layers for defense in depth
109 $checks = array(
110 // PHP request filtering (complements htaccess block_bad_query_strings)
111 'block_bad_query_strings' => 'check_query_strings',
112 'block_sql_injection' => 'check_sql_injection',
113 'block_xss_attacks' => 'check_xss_attacks',
114 'block_file_inclusion' => 'check_file_inclusion',
115 'block_directory_traversal' => 'check_directory_traversal',
116 // Bot protection (complements htaccess block_bad_bots)
117 'block_bad_bots' => 'check_bad_bots',
118 'block_empty_user_agent' => 'check_empty_user_agent',
119 );
120
121 foreach ( $checks as $option => $method ) {
122 if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) {
123 $result = $this->$method();
124 if ( is_string( $result ) ) {
125 $this->block_request( $option, $result );
126 }
127 }
128 }
129
130 // Check HTTP method if limit_http_methods is enabled
131 if ( ! empty( $this->options['limit_http_methods'] ) ) {
132 $this->check_http_method();
133 }
134 }
135
136 /**
137 * Gather current request data
138 */
139 private function gather_request_data() {
140 $this->request_data = array(
141 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
142 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '',
143 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
144 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
145 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET',
146 'ip' => $this->get_client_ip(),
147 );
148 }
149
150 /**
151 * Check for malicious query strings
152 *
153 * @return string|false Error message or false if safe.
154 */
155 private function check_query_strings() {
156 $query = $this->request_data['query_string'];
157
158 if ( empty( $query ) ) {
159 return false;
160 }
161
162 // Dangerous patterns
163 $patterns = array(
164 // Too long query strings
165 '/^.{4000,}$/s' => __( 'Query string too long', 'vigilante' ),
166
167 // Null bytes
168 '/(\x00|%00)/i' => __( 'Null byte detected', 'vigilante' ),
169
170 // PHP wrappers
171 '/php:\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
172 '/data:\/\//i' => __( 'Data wrapper detected', 'vigilante' ),
173
174 // Globals/Request manipulation
175 '/(globals|mosconfig)(\[|\%5b)/i' => __( 'Global manipulation attempt', 'vigilante' ),
176 '/_request(\[|\%5b)/i' => __( 'Request manipulation attempt', 'vigilante' ),
177
178 // Config file access
179 '/wp-config\.php/i' => __( 'Config file access attempt', 'vigilante' ),
180
181 // Common attack patterns
182 '/(\<|%3c).*script.*(\>|%3e)/i' => __( 'Script tag detected', 'vigilante' ),
183 '/document\.(cookie|location|write)/i' => __( 'DOM manipulation attempt', 'vigilante' ),
184 );
185
186 foreach ( $patterns as $pattern => $message ) {
187 if ( preg_match( $pattern, $query ) ) {
188 return $message;
189 }
190 }
191
192 return false;
193 }
194
195 /**
196 * Check for SQL injection attempts
197 *
198 * @return string|false Error message or false if safe.
199 */
200 private function check_sql_injection() {
201 // Skip SQL injection checks for authenticated admin users on admin pages
202 // WordPress handles sanitization for these requests
203 if ( is_admin() && is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
204 return false;
205 }
206
207 $to_check = array(
208 $this->request_data['query_string'],
209 $this->request_data['uri'],
210 );
211
212 // Check POST data, but exclude content fields that may contain legitimate code/text
213 // phpcs:ignore WordPress.Security.NonceVerification.Missing
214 if ( ! empty( $_POST ) ) {
215 // phpcs:ignore WordPress.Security.NonceVerification.Missing
216 $post_data = $_POST;
217
218 // Remove fields that commonly contain user content (posts, comments, etc.)
219 // These are sanitized by WordPress core
220 $excluded_fields = array(
221 'content',
222 'post_content',
223 'comment',
224 'description',
225 'excerpt',
226 'post_excerpt',
227 'message',
228 'bio',
229 'acf', // Advanced Custom Fields
230 'meta', // Post meta
231 'tax_input', // Taxonomy input
232 '_content', // Various content fields
233 );
234
235 foreach ( $excluded_fields as $field ) {
236 unset( $post_data[ $field ] );
237 }
238
239 // Only check remaining POST data if not empty
240 if ( ! empty( $post_data ) ) {
241 $to_check[] = wp_json_encode( $post_data );
242 }
243 }
244
245 $combined = implode( ' ', array_filter( $to_check ) );
246
247 if ( empty( $combined ) ) {
248 return false;
249 }
250
251 // SQL injection patterns - focused on actual attack vectors
252 $patterns = array(
253 // Union based injection - high confidence attack pattern
254 '/union\s+(all\s+)?select/i' => __( 'UNION SELECT detected', 'vigilante' ),
255
256 // SQL commands in URL/query string context (not in POST body)
257 // More specific pattern to reduce false positives
258 '/[\'\"]\s*(;|--|#)\s*(select|insert|update|delete|drop|truncate|alter|create)/i' => __( 'SQL command injection attempt', 'vigilante' ),
259
260 // Hex encoding of SQL - typically used in attacks
261 '/0x[0-9a-f]{16,}/i' => __( 'Hex encoding detected', 'vigilante' ),
262
263 // Benchmark/sleep attacks - time-based SQL injection
264 '/(benchmark|sleep)\s*\(\s*\d/i' => __( 'Time-based injection attempt', 'vigilante' ),
265
266 // Information schema access
267 '/information_schema\.(tables|columns|schemata)/i' => __( 'Schema access attempt', 'vigilante' ),
268
269 // Load file - file read attempt
270 '/load_file\s*\(/i' => __( 'Load file attempt', 'vigilante' ),
271
272 // Into outfile - file write attempt
273 '/into\s+(out|dump)file/i' => __( 'File write attempt', 'vigilante' ),
274
275 // Stacked queries with dangerous commands
276 '/;\s*(drop|truncate|delete\s+from|update\s+\w+\s+set)/i' => __( 'Stacked query injection', 'vigilante' ),
277 );
278
279 foreach ( $patterns as $pattern => $message ) {
280 if ( preg_match( $pattern, $combined ) ) {
281 return $message;
282 }
283 }
284
285 return false;
286 }
287
288 /**
289 * Check for XSS attacks
290 *
291 * @return string|false Error message or false if safe.
292 */
293 private function check_xss_attacks() {
294 $to_check = array(
295 $this->request_data['query_string'],
296 $this->request_data['uri'],
297 );
298
299 $combined = implode( ' ', array_filter( $to_check ) );
300
301 if ( empty( $combined ) ) {
302 return false;
303 }
304
305 // URL decode for checking
306 $decoded = urldecode( $combined );
307
308 // XSS patterns
309 $patterns = array(
310 // Script tags
311 '/<script[^>]*>/i' => __( 'Script tag detected', 'vigilante' ),
312
313 // Event handlers
314 '/\bon\w+\s*=/i' => __( 'Event handler detected', 'vigilante' ),
315
316 // JavaScript protocol
317 '/javascript\s*:/i' => __( 'JavaScript protocol detected', 'vigilante' ),
318
319 // VBScript
320 '/vbscript\s*:/i' => __( 'VBScript detected', 'vigilante' ),
321
322 // Data URL
323 '/data\s*:[^,]*base64/i' => __( 'Base64 data URL detected', 'vigilante' ),
324
325 // Expression (IE)
326 '/expression\s*\(/i' => __( 'CSS expression detected', 'vigilante' ),
327
328 // Iframe injection
329 '/<iframe[^>]*>/i' => __( 'Iframe injection detected', 'vigilante' ),
330
331 // Object/embed
332 '/<(object|embed|applet)[^>]*>/i' => __( 'Object tag detected', 'vigilante' ),
333 );
334
335 foreach ( $patterns as $pattern => $message ) {
336 if ( preg_match( $pattern, $decoded ) ) {
337 return $message;
338 }
339 }
340
341 return false;
342 }
343
344 /**
345 * Check for file inclusion attacks
346 *
347 * @return string|false Error message or false if safe.
348 */
349 private function check_file_inclusion() {
350 $uri = $this->request_data['uri'];
351 $query = $this->request_data['query_string'];
352 $combined = $uri . ' ' . $query;
353
354 if ( empty( $combined ) ) {
355 return false;
356 }
357
358 // File inclusion patterns
359 $patterns = array(
360 // Remote file inclusion
361 '/=\s*(https?|ftp):\/\//i' => __( 'Remote file inclusion attempt', 'vigilante' ),
362
363 // PHP wrappers
364 '/(php|zip|glob|phar|ssh2|rar|ogg|expect):\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
365
366 // System files
367 '/\/etc\/(passwd|shadow|hosts)/i' => __( 'System file access attempt', 'vigilante' ),
368 '/\/proc\/self/i' => __( 'Proc access attempt', 'vigilante' ),
369
370 // Windows paths
371 '/[a-z]:\\\\(windows|winnt)/i' => __( 'Windows path detected', 'vigilante' ),
372 );
373
374 foreach ( $patterns as $pattern => $message ) {
375 if ( preg_match( $pattern, $combined ) ) {
376 return $message;
377 }
378 }
379
380 return false;
381 }
382
383 /**
384 * Check for directory traversal attacks
385 *
386 * @return string|false Error message or false if safe.
387 */
388 private function check_directory_traversal() {
389 $uri = $this->request_data['uri'];
390 $query = $this->request_data['query_string'];
391 $combined = urldecode( $uri . ' ' . $query );
392
393 if ( empty( $combined ) ) {
394 return false;
395 }
396
397 // Directory traversal patterns
398 $patterns = array(
399 '/\.\.\//i' => __( 'Directory traversal detected', 'vigilante' ),
400 '/\.\.%2f/i' => __( 'Encoded traversal detected', 'vigilante' ),
401 '/%2e%2e\//i' => __( 'Double encoded traversal', 'vigilante' ),
402 '/\.\.%5c/i' => __( 'Backslash traversal detected', 'vigilante' ),
403 );
404
405 foreach ( $patterns as $pattern => $message ) {
406 if ( preg_match( $pattern, $combined ) ) {
407 return $message;
408 }
409 }
410
411 return false;
412 }
413
414 /**
415 * Check for PHP execution in uploads
416 *
417 * @return string|false Error message or false if safe.
418 */
419 private function check_php_in_uploads() {
420 $uri = $this->request_data['uri'];
421
422 // Check if accessing PHP in uploads directory
423 if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) {
424 return __( 'PHP execution in uploads blocked', 'vigilante' );
425 }
426
427 return false;
428 }
429
430 /**
431 * Check for access to sensitive files
432 *
433 * @return string|false Error message or false if safe.
434 */
435 private function check_sensitive_files() {
436 $uri = strtolower( $this->request_data['uri'] );
437
438 // Sensitive file patterns
439 $sensitive_patterns = array(
440 '/\.htaccess$/i',
441 '/\.htpasswd$/i',
442 '/wp-config\.php$/i',
443 '/wp-config-sample\.php$/i',
444 '/readme\.html$/i',
445 '/licen(se|cia)\.txt$/i',
446 '/xmlrpc\.php$/i', // If XML-RPC is disabled
447 '/\.git/i',
448 '/\.svn/i',
449 '/\.env$/i',
450 '/composer\.(json|lock)$/i',
451 '/package(-lock)?\.json$/i',
452 '/\.sql$/i',
453 '/\.bak$/i',
454 '/\.old$/i',
455 '/\.log$/i',
456 '/\.ini$/i',
457 '/debug\.log$/i',
458 '/error_log$/i',
459 );
460
461 foreach ( $sensitive_patterns as $pattern ) {
462 if ( preg_match( $pattern, $uri ) ) {
463 return __( 'Access to sensitive file blocked', 'vigilante' );
464 }
465 }
466
467 return false;
468 }
469
470 /**
471 * Check for bad bots
472 *
473 * @return string|false Error message or false if safe.
474 */
475 private function check_bad_bots() {
476 $user_agent = strtolower( $this->request_data['user_agent'] );
477
478 if ( empty( $user_agent ) ) {
479 return false;
480 }
481
482 // Known malicious bots and scanners
483 // NOTE: Matching is done via strpos() on the full User-Agent string,
484 // so entries must be specific enough to avoid false positives with
485 // legitimate services, plugins, or WordPress loopback requests.
486 // Generic short words (e.g. 'scan', 'ninja', 'titan') must stay out
487 // of BOTH this list and the htaccess one: the htaccess regex matches
488 // bare substrings too, and unlike this layer it runs before PHP, so
489 // the ua_whitelist cannot rescue a false positive there.
490 $bad_bots = array(
491 'ahrefsbot',
492 'semrushbot',
493 'dotbot',
494 'mj12bot',
495 'blexbot',
496 'linkdexbot',
497 'aspiegelbot',
498 'alexibot',
499 'backlink',
500 'bandit',
501 'batchftp',
502 'bigfoot',
503 'blackwidow',
504 'blowfish',
505 'botalot',
506 'builtbottough',
507 'bullseye',
508 'cheesebot',
509 'cherrypicker',
510 'chinaclaw',
511 'copyrightcheck',
512 'crescent',
513 'curl/',
514 'dittospyder',
515 'dragonfly',
516 'easydl',
517 'ebingbong',
518 'ecatch',
519 'eirgrabber',
520 'emailcollector',
521 'emailsiphon',
522 'emailwolf',
523 'erocrawler',
524 'exabot',
525 'expressweb',
526 'eyenetie',
527 'flashget',
528 'flunky',
529 'frontpage',
530 'getright',
531 'getweb',
532 'go-ahead-got-it',
533 'gotit',
534 'grabnet',
535 'grafula',
536 'harvest',
537 'hloader',
538 'hmview',
539 'httplib',
540 'httrack',
541 'humanlinks',
542 'ia_archiver',
543 'imagestripper',
544 'imagesucker',
545 'indy library',
546 'infonavirobot',
547 'infotekies',
548 'intelliseek',
549 'interget',
550 'intraformant',
551 'jakarta',
552 'jennybot',
553 'jetcar',
554 'kenjin',
555 'larbin',
556 'leechftp',
557 'lexibot',
558 'libweb',
559 'likse',
560 'linkscan',
561 'linkwalker',
562 'lnspiderguy',
563 'lwp',
564 'magnet',
565 'mag-net',
566 'markwatch',
567 'mass downloader',
568 'masscan',
569 'microsoft.url',
570 'midown',
571 'miixpc',
572 'missigua',
573 'moget',
574 'nameprotect',
575 'navroad',
576 'nearsite',
577 'net vampire',
578 'netants',
579 'netcraft',
580 'netmechanic',
581 'netspider',
582 'nextgensearchbot',
583 'nibbler',
584 'nicerspro',
585 'niki-bot',
586 'npbot',
587 'offline explorer',
588 'offline navigator',
589 'openfind',
590 'outfoxbot',
591 'pagegrabber',
592 'pavuk',
593 'pcbrowser',
594 'php/',
595 'pockey',
596 'prowebwalker',
597 'psycheclone',
598 'python-urllib',
599 'python-requests',
600 'python/',
601 'queryn',
602 'reget',
603 'repomonkey',
604 'siphon',
605 'siteexplorer',
606 'sitesnagger',
607 'slurp',
608 'smartdownload',
609 'snapbot',
610 'snoopy',
611 'sogou',
612 'spacebison',
613 'spankbot',
614 'sqworm',
615 'superbot',
616 'superhttp',
617 'surfbot',
618 'suzuran',
619 'szukacz',
620 'takeout',
621 'teleport',
622 'telesoft',
623 'thenomad',
624 'tighttwatbot',
625 'true_robot',
626 'turingos',
627 'turnitinbot',
628 'voideye',
629 'webalta',
630 'webbandit',
631 'webcollector',
632 'webcopier',
633 'webdup',
634 'webenhancer',
635 'webfetch',
636 'webgo',
637 'webmasterworldforumbot',
638 'webpictures',
639 'webreaper',
640 'websauger',
641 'webspider',
642 'webstripper',
643 'websucker',
644 'webwhacker',
645 'webzip',
646 'widow',
647 'wisenut',
648 'wwwoffle',
649 'xaldon',
650 'xxxyy',
651 'zeus',
652 'zermelo',
653 'zyborg',
654 );
655
656 foreach ( $bad_bots as $bot ) {
657 if ( strpos( $user_agent, $bot ) !== false ) {
658 return sprintf(
659 /* translators: %s: Bot name */
660 __( 'Bad bot blocked: %s', 'vigilante' ),
661 $bot
662 );
663 }
664 }
665
666 return false;
667 }
668
669 /**
670 * Check for empty user agent
671 *
672 * @return string|false Error message or false if safe.
673 */
674 private function check_empty_user_agent() {
675 if ( empty( $this->request_data['user_agent'] ) ) {
676 return __( 'Empty user agent blocked', 'vigilante' );
677 }
678 return false;
679 }
680
681 /**
682 * Check HTTP method
683 *
684 * Logged-in users with edit capabilities are excluded to ensure
685 * Gutenberg, REST API, and page builders work correctly.
686 */
687 private function check_http_method() {
688 // Skip for authenticated users who can edit content
689 // They need OPTIONS, PUT, PATCH, DELETE for Gutenberg, REST API, and page builders
690 if ( is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
691 return;
692 }
693
694 // Skip for WordPress REST API requests
695 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
696 // authentication and authorization layer — no need to filter methods here
697 $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
698 if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
699 return;
700 }
701
702 $method = strtoupper( $this->request_data['method'] );
703 $allowed_methods = isset( $this->options['allowed_http_methods'] )
704 ? $this->options['allowed_http_methods']
705 : array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' );
706 $allowed = array_map( 'strtoupper', $allowed_methods );
707
708 if ( ! in_array( $method, $allowed, true ) ) {
709 $this->block_request(
710 'http_method',
711 sprintf(
712 /* translators: %s: HTTP method */
713 __( 'HTTP method %s not allowed', 'vigilante' ),
714 $method
715 )
716 );
717 }
718 }
719
720 /**
721 * Check rate limiting
722 */
723 public function check_rate_limit() {
724 // Skip rate limiting for whitelisted IPs
725 if ( $this->is_ip_whitelisted() ) {
726 return;
727 }
728
729 // Skip rate limiting for logged-in administrators
730 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
731 return;
732 }
733
734 // Allow other modules to opt out — Under Attack mode uses this so that
735 // visitors who already passed the JS challenge don't burn the
736 // aggressive 30 req/min cap loading a normal page's assets.
737 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
738 return;
739 }
740
741 $ip = $this->get_client_ip();
742 $rate_limit = $this->options['rate_limiting'];
743
744 // Check if already blocked via queryable option (fast path)
745 $active_blocks = get_option( 'vigilante_firewall_blocks', array() );
746 if ( isset( $active_blocks[ $ip ] ) ) {
747 if ( time() < $active_blocks[ $ip ]['expires'] ) {
748 if ( ! headers_sent() ) {
749 status_header( 429 );
750 nocache_headers();
751 }
752 wp_die(
753 esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
754 esc_html__( 'Too Many Requests', 'vigilante' ),
755 array( 'response' => 429 )
756 );
757 }
758 // Expired — clean up
759 unset( $active_blocks[ $ip ] );
760 update_option( 'vigilante_firewall_blocks', $active_blocks, false );
761 }
762
763 $max_requests = absint( $rate_limit['requests_per_minute'] );
764
765 // Allow Under Attack mode (or other filters) to override threshold
766 $max_requests = absint( apply_filters( 'vigilante_rate_limit_requests', $max_requests ) );
767
768 // Fixed window, anchored to the timestamp of its first request.
769 //
770 // The count used to live in a transient whose TTL was renewed on every
771 // hit, which is a window that never closes: any IP going less than 60 s
772 // between requests kept accumulating, so the effective limit was not
773 // "requests per minute" but "requests since the last full minute of
774 // silence". A logged-in editor publishing several posts in a row could
775 // pile up 150+ requests while never exceeding 60 in any single minute,
776 // and got a 429. Storing the window start makes the reset explicit
777 // instead of relying on the transient expiring.
778 $transient_key = 'vigilante_rate_' . md5( $ip );
779 $window = get_transient( $transient_key );
780 $now = time();
781
782 // Counts stored before 2.9.5 were a bare integer with no window start.
783 // There is no way to tell how old such a count is, so open a new window.
784 if ( ! is_array( $window ) || ! isset( $window['start'], $window['count'] ) ) {
785 $window = array(
786 'start' => $now,
787 'count' => 0,
788 );
789 }
790
791 // Window elapsed: start counting again, even under continuous traffic.
792 if ( ( $now - absint( $window['start'] ) ) >= self::RATE_LIMIT_WINDOW ) {
793 $window = array(
794 'start' => $now,
795 'count' => 0,
796 );
797 }
798
799 // Count this request, then allow up to $max_requests per window.
800 $window['count'] = absint( $window['count'] ) + 1;
801 $request_count = $window['count'];
802
803 if ( $request_count > $max_requests ) {
804 $base_duration = absint( $rate_limit['block_duration'] );
805
806 // Allow Under Attack mode (or other filters) to override duration
807 $base_duration = absint( apply_filters( 'vigilante_rate_limit_duration', $base_duration ) );
808
809 $duration = $base_duration;
810 $strikes = 1;
811
812 // Progressive blocking: double duration on each repeat offense
813 if ( ! empty( $rate_limit['progressive'] ) ) {
814 $strikes_key = 'vigilante_strikes_' . md5( $ip );
815 $strikes = absint( get_transient( $strikes_key ) ) + 1;
816
817 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
818 $duration = min(
819 $base_duration * pow( 2, $strikes - 1 ),
820 $max_duration
821 );
822
823 // Persist strikes for 24h so they accumulate across blocks
824 set_transient( $strikes_key, $strikes, 86400 );
825 }
826
827 // Store block in queryable option for admin UI
828 $active_blocks[ $ip ] = array(
829 'expires' => time() + $duration,
830 'blocked_at' => time(),
831 'duration' => $duration,
832 'reason' => 'rate_limit',
833 'strikes' => $strikes,
834 );
835 update_option( 'vigilante_firewall_blocks', $active_blocks, false );
836
837 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
838 }
839
840 // The TTL only garbage-collects the payload once the IP goes quiet; what
841 // bounds the count is the window reset above, not the expiry.
842 set_transient( $transient_key, $window, self::RATE_LIMIT_WINDOW );
843 }
844
845 /**
846 * Block a request
847 *
848 * @param string $reason Reason code for blocking.
849 * @param string $message Message to log.
850 * @param int $status_code HTTP status code.
851 */
852 private function block_request( $reason, $message, $status_code = 403 ) {
853 // Log the block
854 if ( $this->activity_log ) {
855 $this->activity_log->log(
856 'firewall',
857 'blocked',
858 $message,
859 array(
860 'reason' => $reason,
861 'uri' => $this->request_data['uri'] ?? '',
862 'ip' => $this->get_client_ip(),
863 'user_agent'=> $this->request_data['user_agent'] ?? '',
864 ),
865 'warning'
866 );
867 }
868
869 // Set response headers
870 if ( ! headers_sent() ) {
871 status_header( $status_code );
872 nocache_headers();
873 }
874
875 // Return appropriate response
876 if ( 429 === $status_code ) {
877 wp_die(
878 esc_html( $message ),
879 esc_html__( 'Too Many Requests', 'vigilante' ),
880 array( 'response' => 429 )
881 );
882 }
883
884 wp_die(
885 esc_html( $message ),
886 esc_html__( 'Forbidden', 'vigilante' ),
887 array( 'response' => 403 )
888 );
889 }
890
891 /**
892 * Check if current IP is whitelisted
893 *
894 * @return bool
895 */
896 private function is_ip_whitelisted() {
897 $whitelist = $this->options['ip_whitelist'] ?? array();
898
899 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $whitelist );
900 }
901
902 /**
903 * Check if current IP is blacklisted
904 *
905 * @return bool
906 */
907 private function is_ip_blacklisted() {
908 $blacklist = $this->options['ip_blacklist'] ?? array();
909
910 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $blacklist );
911 }
912
913 /**
914 * Check if current User-Agent is whitelisted
915 *
916 * Partial matching: if the request UA contains any whitelisted string,
917 * it bypasses all firewall checks. Useful for services like ManageWP, MainWP, etc.
918 *
919 * @return bool
920 */
921 private function is_ua_whitelisted() {
922 $whitelist = $this->options['ua_whitelist'] ?? array();
923
924 if ( empty( $whitelist ) ) {
925 return false;
926 }
927
928 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
929
930 if ( empty( $user_agent ) ) {
931 return false;
932 }
933
934 $ua_lower = strtolower( $user_agent );
935
936 foreach ( $whitelist as $allowed ) {
937 $allowed = trim( $allowed );
938 if ( ! empty( $allowed ) && false !== strpos( $ua_lower, strtolower( $allowed ) ) ) {
939 return true;
940 }
941 }
942
943 return false;
944 }
945
946 /**
947 * Check if current User-Agent is blacklisted
948 *
949 * Partial matching: if the request UA contains any blacklisted string, block it.
950 *
951 * @return bool
952 */
953 private function is_ua_blacklisted() {
954 $blacklist = $this->options['ua_blacklist'] ?? array();
955
956 if ( empty( $blacklist ) ) {
957 return false;
958 }
959
960 $user_agent = $this->request_data['user_agent'] ?? '';
961
962 if ( empty( $user_agent ) ) {
963 return false;
964 }
965
966 $ua_lower = strtolower( $user_agent );
967
968 foreach ( $blacklist as $blocked ) {
969 $blocked = trim( $blocked );
970 if ( ! empty( $blocked ) && false !== strpos( $ua_lower, strtolower( $blocked ) ) ) {
971 return true;
972 }
973 }
974
975 return false;
976 }
977
978 /**
979 * Get client IP address
980 *
981 * Delegates to the shared resolver, which only trusts REMOTE_ADDR unless a
982 * proxy header has been explicitly declared in settings.
983 *
984 * @return string
985 */
986 private function get_client_ip() {
987 return Vigilante_IP_Utils::get_client_ip();
988 }
989
990 // =========================================================================
991 // BLOCK MANAGEMENT (static, for admin UI)
992 // =========================================================================
993
994 /**
995 * Get currently active firewall blocks
996 *
997 * Cleans expired entries on each call.
998 *
999 * @return array Active blocks keyed by IP address.
1000 */
1001 public static function get_active_blocks() {
1002 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1003 $now = time();
1004 $dirty = false;
1005
1006 foreach ( $blocks as $ip => $data ) {
1007 if ( $now >= $data['expires'] ) {
1008 unset( $blocks[ $ip ] );
1009 $dirty = true;
1010 }
1011 }
1012
1013 if ( $dirty ) {
1014 update_option( 'vigilante_firewall_blocks', $blocks, false );
1015 }
1016
1017 return $blocks;
1018 }
1019
1020 /**
1021 * Manually unblock an IP from rate limit blocks
1022 *
1023 * @param string $ip IP address to unblock.
1024 * @return bool Whether the IP was found and removed.
1025 */
1026 public static function unblock_ip( $ip ) {
1027 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1028
1029 if ( ! isset( $blocks[ $ip ] ) ) {
1030 return false;
1031 }
1032
1033 unset( $blocks[ $ip ] );
1034 update_option( 'vigilante_firewall_blocks', $blocks, false );
1035
1036 // Clean related transients
1037 $hash = md5( $ip );
1038 delete_transient( 'vigilante_rate_block_' . $hash );
1039 delete_transient( 'vigilante_rate_' . $hash );
1040 delete_transient( 'vigilante_strikes_' . $hash );
1041
1042 return true;
1043 }
1044 }