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

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