PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.4
2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-firewall.php

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

1,492 lines 52.6 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 /*
98 * The User-Agent whitelist no longer skips the firewall.
99 *
100 * Until 2.11.1 a matching User-Agent returned here, before the IP
101 * blacklist and every request check, so anyone who guessed a
102 * configured substring ("ManageWP", "MainWP") walked past the SQL
103 * injection, script injection, file inclusion, traversal, bot and
104 * HTTP method rules by setting a header they control. A header a
105 * client chooses cannot stand in for an identity. Reported by the
106 * automated security review of wp.org on 9 sep 2026 and fixed in
107 * 2.11.2.
108 *
109 * What the option is actually for is keeping a remote manager from
110 * being turned away as a bot, so that is all it does now: it exempts
111 * the User-Agent rules, resolved further down, and nothing else. The
112 * list is empty by default, so only sites that had configured one were
113 * ever exposed.
114 */
115 $ua_whitelisted = $this->is_ua_whitelisted();
116
117 // Gather request data first: a block is logged with the address it
118 // turned away, and until 2.11.1 the blacklist ran before this, so the
119 // entry for a blacklisted IP recorded no address at all.
120 $this->gather_request_data();
121
122 // Check if IP is blacklisted
123 if ( $this->is_ip_blacklisted() ) {
124 $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) );
125 }
126
127 // Check if User-Agent is blacklisted (after gathering request data).
128 // An explicitly whitelisted agent still wins over the blacklist, which
129 // is what an administrator who wrote it there expects.
130 if ( ! $ua_whitelisted && $this->is_ua_blacklisted() ) {
131 $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) );
132 }
133
134 // Run security checks
135 // NOTE: These are PHP-based checks that complement htaccess rules
136 // Some protections exist in both layers for defense in depth
137 $checks = array(
138 // PHP request filtering (complements htaccess block_bad_query_strings)
139 'block_bad_query_strings' => 'check_query_strings',
140 'block_sql_injection' => 'check_sql_injection',
141 'block_xss_attacks' => 'check_xss_attacks',
142 'block_file_inclusion' => 'check_file_inclusion',
143 'block_directory_traversal' => 'check_directory_traversal',
144 // Bot protection (complements htaccess block_bad_bots)
145 'block_bad_bots' => 'check_bad_bots',
146 'block_empty_user_agent' => 'check_empty_user_agent',
147 );
148
149 // The rules a whitelisted User-Agent is exempt from, and only these.
150 $ua_rules = array( 'block_bad_bots', 'block_empty_user_agent' );
151
152 foreach ( $checks as $option => $method ) {
153 if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) {
154 continue;
155 }
156
157 if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) {
158 $result = $this->$method();
159 if ( is_string( $result ) ) {
160 $this->block_request( $option, $result );
161 }
162 }
163 }
164
165 // Check HTTP method if limit_http_methods is enabled
166 if ( ! empty( $this->options['limit_http_methods'] ) ) {
167 $this->check_http_method();
168 }
169 }
170
171 /**
172 * Gather current request data
173 */
174 private function gather_request_data() {
175 $this->haystack = null;
176
177 $this->request_data = array(
178 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
179 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '',
180 /*
181 * Copies that keep the percent encoding, used only as the haystack
182 * of the pattern checks and never logged, printed or stored.
183 *
184 * They exist because sanitize_text_field() deletes every %XX
185 * sequence instead of decoding it: the copies above are the payload
186 * with the evidence removed, so an encoded attack was invisible to
187 * every rule that reads them. Measured on 22 aug 2026 against 2.9.8,
188 * ?x=%3Cscript%3E, javascript%3A, php%3A%2F%2F and GLOBALS%5B all
189 * reached the checks as harmless text and went straight through.
190 *
191 * No sanitizer is applied, and that is the point: every one of them
192 * destroys exactly what has to be matched. sanitize_text_field()
193 * deletes the %XX sequences and strips tags. esc_url_raw() is worse
194 * here: measured on 22 aug 2026, it returns an empty string for a
195 * query that carries an unencoded :// , which is precisely the
196 * remote inclusion shape, so it would blind the firewall instead of
197 * arming it. These two values are never echoed, never stored and
198 * never reach a query; they are the haystack of preg_match() and
199 * nothing else.
200 */
201 // 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.
202 'uri_raw' => isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '',
203 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as uri_raw.
204 'query_raw' => isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : '',
205 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
206 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
207 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET',
208 'ip' => $this->get_client_ip(),
209 );
210 }
211
212 /**
213 * What the pattern checks run against: the request as it arrived, plus its decoded form
214 *
215 * Both forms on purpose. Some patterns look for the encoded shape, such as
216 * the null byte %00 or the %5b of GLOBALS[, and others for the decoded one,
217 * such as <script or ../. Feeding only one of the two leaves half the rules
218 * looking at something that cannot match.
219 *
220 * Decoded once, not twice: a second pass catches a bit more evasion and
221 * brings in false positives that are not worth it.
222 *
223 * @since 2.9.9
224 *
225 * @return string
226 */
227 private function inspection_haystack() {
228 if ( null !== $this->haystack ) {
229 return $this->haystack;
230 }
231
232 $raw = trim( (string) $this->request_data['uri_raw'] . ' ' . (string) $this->request_data['query_raw'] );
233 $decoded = rawurldecode( $raw );
234
235 $this->haystack = ( $raw === $decoded ) ? $raw : $raw . ' ' . $decoded;
236
237 return $this->haystack;
238 }
239
240 /**
241 * Check for malicious query strings
242 *
243 * @return string|false Error message or false if safe.
244 */
245 private function check_query_strings() {
246 $query = $this->request_data['query_raw'];
247
248 if ( empty( $query ) ) {
249 return false;
250 }
251
252 // Length is measured on the query alone, the rest of the patterns run
253 // against the whole request in both its raw and decoded forms.
254 $haystack = $this->inspection_haystack();
255
256 // Dangerous patterns
257 $patterns = array(
258 // Too long query strings
259 '/^.{4000,}$/s' => __( 'Query string too long', 'vigilante' ),
260
261 // Null bytes
262 '/(\x00|%00)/i' => __( 'Null byte detected', 'vigilante' ),
263
264 // PHP wrappers
265 '/php:\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
266 '/data:\/\//i' => __( 'Data wrapper detected', 'vigilante' ),
267
268 // Globals/Request manipulation
269 '/(globals|mosconfig)(\[|\%5b)/i' => __( 'Global manipulation attempt', 'vigilante' ),
270 '/_request(\[|\%5b)/i' => __( 'Request manipulation attempt', 'vigilante' ),
271
272 // Config file access
273 '/wp-config\.php/i' => __( 'Config file access attempt', 'vigilante' ),
274
275 // Common attack patterns
276 '/(\<|%3c).*script.*(\>|%3e)/i' => __( 'Script tag detected', 'vigilante' ),
277 '/document\.(cookie|location|write)/i' => __( 'DOM manipulation attempt', 'vigilante' ),
278 );
279
280 foreach ( $patterns as $pattern => $message ) {
281 // The length rule is anchored, so it has to see the query on its
282 // own; every other pattern gets the whole request.
283 $subject = ( '/^.{4000,}$/s' === $pattern ) ? $query : $haystack;
284
285 if ( preg_match( $pattern, $subject ) ) {
286 return $message;
287 }
288 }
289
290 return false;
291 }
292
293 /**
294 * Check for SQL injection attempts
295 *
296 * @return string|false Error message or false if safe.
297 */
298 private function check_sql_injection() {
299 // Skip SQL injection checks for authenticated admin users on admin pages
300 // WordPress handles sanitization for these requests
301 if ( is_admin() && is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
302 return false;
303 }
304
305 $to_check = array(
306 $this->inspection_haystack(),
307 );
308
309 // Check POST data, but exclude content fields that may contain legitimate code/text
310 // phpcs:ignore WordPress.Security.NonceVerification.Missing
311 if ( ! empty( $_POST ) ) {
312 // phpcs:ignore WordPress.Security.NonceVerification.Missing
313 $post_data = $_POST;
314
315 // Remove fields that commonly contain user content (posts, comments, etc.)
316 // These are sanitized by WordPress core
317 $excluded_fields = array(
318 'content',
319 'post_content',
320 'comment',
321 'description',
322 'excerpt',
323 'post_excerpt',
324 'message',
325 'bio',
326 'acf', // Advanced Custom Fields
327 'meta', // Post meta
328 'tax_input', // Taxonomy input
329 '_content', // Various content fields
330 );
331
332 foreach ( $excluded_fields as $field ) {
333 unset( $post_data[ $field ] );
334 }
335
336 // Only check remaining POST data if not empty
337 if ( ! empty( $post_data ) ) {
338 $to_check[] = wp_json_encode( $post_data );
339 }
340 }
341
342 $combined = implode( ' ', array_filter( $to_check ) );
343
344 if ( empty( $combined ) ) {
345 return false;
346 }
347
348 // SQL injection patterns - focused on actual attack vectors
349 $patterns = array(
350 // Union based injection - high confidence attack pattern
351 '/union\s+(all\s+)?select/i' => __( 'UNION SELECT detected', 'vigilante' ),
352
353 // SQL commands in URL/query string context (not in POST body)
354 // More specific pattern to reduce false positives
355 '/[\'\"]\s*(;|--|#)\s*(select|insert|update|delete|drop|truncate|alter|create)/i' => __( 'SQL command injection attempt', 'vigilante' ),
356
357 // Hex encoding of SQL - typically used in attacks
358 '/0x[0-9a-f]{16,}/i' => __( 'Hex encoding detected', 'vigilante' ),
359
360 // Benchmark/sleep attacks - time-based SQL injection
361 '/(benchmark|sleep)\s*\(\s*\d/i' => __( 'Time-based injection attempt', 'vigilante' ),
362
363 // Information schema access
364 '/information_schema\.(tables|columns|schemata)/i' => __( 'Schema access attempt', 'vigilante' ),
365
366 // Load file - file read attempt
367 '/load_file\s*\(/i' => __( 'Load file attempt', 'vigilante' ),
368
369 // Into outfile - file write attempt
370 '/into\s+(out|dump)file/i' => __( 'File write attempt', 'vigilante' ),
371
372 // Stacked queries with dangerous commands
373 '/;\s*(drop|truncate|delete\s+from|update\s+\w+\s+set)/i' => __( 'Stacked query injection', 'vigilante' ),
374 );
375
376 foreach ( $patterns as $pattern => $message ) {
377 if ( preg_match( $pattern, $combined ) ) {
378 return $message;
379 }
380 }
381
382 return false;
383 }
384
385 /**
386 * Check for XSS attacks
387 *
388 * @return string|false Error message or false if safe.
389 */
390 private function check_xss_attacks() {
391 $combined = $this->inspection_haystack();
392
393 if ( empty( $combined ) ) {
394 return false;
395 }
396
397 // Already carries the decoded form, see inspection_haystack().
398 $decoded = $combined;
399
400 // XSS patterns
401 $patterns = array(
402 // Script tags
403 '/<script[^>]*>/i' => __( 'Script tag detected', 'vigilante' ),
404
405 /*
406 * Event handlers. Two shapes, because the rule used to be a bare
407 * \bon\w+\s*= and that matches any parameter whose name starts
408 * with "on": only=, once=, online= and onboarding= were all
409 * answered with a 403 on every site with the firewall on, and the
410 * owner never saw it because it only hits visitors.
411 */
412 '/<[^>]*\bon\w+\s*=/i' => __( 'Event handler detected', 'vigilante' ),
413 '/\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' ),
414
415 // JavaScript protocol
416 '/javascript\s*:/i' => __( 'JavaScript protocol detected', 'vigilante' ),
417
418 // VBScript
419 '/vbscript\s*:/i' => __( 'VBScript detected', 'vigilante' ),
420
421 // Data URL
422 '/data\s*:[^,]*base64/i' => __( 'Base64 data URL detected', 'vigilante' ),
423
424 // Expression (IE)
425 '/expression\s*\(/i' => __( 'CSS expression detected', 'vigilante' ),
426
427 // Iframe injection
428 '/<iframe[^>]*>/i' => __( 'Iframe injection detected', 'vigilante' ),
429
430 // Object/embed
431 '/<(object|embed|applet)[^>]*>/i' => __( 'Object tag detected', 'vigilante' ),
432 );
433
434 foreach ( $patterns as $pattern => $message ) {
435 if ( preg_match( $pattern, $decoded ) ) {
436 return $message;
437 }
438 }
439
440 return false;
441 }
442
443 /**
444 * Check for file inclusion attacks
445 *
446 * @return string|false Error message or false if safe.
447 */
448 private function check_file_inclusion() {
449 $combined = $this->inspection_haystack();
450
451 if ( empty( $combined ) ) {
452 return false;
453 }
454
455 // Remote inclusion is decided on the parsed values, not on the raw
456 // string. Until 2.9.9 any '=' followed by an absolute URL tripped this
457 // rule, and legitimate links carry those all the time: a redirect_to
458 // back to the site itself, a return_url, a payment gateway callback.
459 // What makes it an inclusion attempt is the target being somewhere
460 // else, so a URL pointing at this very site is left alone.
461 //
462 // The core endpoint that resolves an embed takes an external URL as
463 // its whole job, so it is exempted rather than made to look innocent.
464 // Only this check is skipped: the PHP wrappers and the system paths
465 // below still run on that route, for everyone.
466 if ( ! $this->is_core_embed_proxy_request() && $this->has_remote_inclusion() ) {
467 return __( 'Remote file inclusion attempt', 'vigilante' );
468 }
469
470 // File inclusion patterns
471 $patterns = array(
472 // PHP wrappers
473 '/(php|zip|glob|phar|ssh2|rar|ogg|expect):\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
474
475 // System files
476 '/\/etc\/(passwd|shadow|hosts)/i' => __( 'System file access attempt', 'vigilante' ),
477 '/\/proc\/self/i' => __( 'Proc access attempt', 'vigilante' ),
478
479 // Windows paths
480 '/[a-z]:\\\\(windows|winnt)/i' => __( 'Windows path detected', 'vigilante' ),
481 );
482
483 foreach ( $patterns as $pattern => $message ) {
484 if ( preg_match( $pattern, $combined ) ) {
485 return $message;
486 }
487 }
488
489 return false;
490 }
491
492 /**
493 * Parameter names a remote inclusion payload travels in
494 *
495 * An inclusion needs its value to reach an include() or a require(), so it
496 * arrives in the parameter a vulnerable script treats as a path. These are
497 * the names those scripts use, and the ones every RFI scanner probes.
498 *
499 * Deliberately absent: url, redirect, redirect_to, return, return_url,
500 * callback and the rest of the link-carrying names. Carrying a URL is what
501 * those are for. Matching them is what turned WordPress core's own oembed
502 * proxy into a 403 for anyone using the block editor, reported on 9 sep
503 * 2026 and fixed in 2.11.1.
504 *
505 * @since 2.11.1
506 *
507 * @var string[]
508 */
509 private static $inclusion_param_names = array(
510 // The value is read as a file
511 'file', 'files', 'filename', 'file_name', 'filepath', 'file_path',
512 'archivo', 'arquivo', 'fichier', 'datei',
513 // ...or as the place to read it from
514 'path', 'paths', 'dir', 'directory', 'folder', 'root', 'base',
515 'basepath', 'base_path', 'abs_path', 'absolute_path',
516 'mosconfig_absolute_path',
517 // ...or as the page a front controller includes
518 'page', 'pag', 'pagina', 'pageweb', 'pg', 'seite',
519 'include', 'includes', 'inc', 'incl', 'require', 'load', 'loadfile',
520 'open', 'read', 'readfile', 'show', 'display', 'view', 'content',
521 // ...or as a template, which is a file by another name
522 'template', 'templates', 'tpl', 'tmpl', 'theme', 'skin', 'style',
523 'layout', 'doc', 'document', 'module', 'mod', 'plugin', 'controller',
524 'class', 'func', 'function', 'lang', 'language',
525 // ...or as configuration, or straight into a shell
526 'conf', 'config', 'cfg', 'src', 'source', 'download',
527 'cmd', 'exec', 'shell', 'system',
528 );
529
530 /**
531 * Extensions a remote inclusion payload is served with
532 *
533 * The point of the target is that it carries code, or text the vulnerable
534 * script will treat as code. Media extensions are not here on purpose: an
535 * embedded .mp4 or .jpg from a CDN is what the editor sends all day.
536 *
537 * @since 2.11.1
538 *
539 * @var string[]
540 */
541 private static $inclusion_extensions = array(
542 'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8',
543 'phps', 'phtml', 'pht', 'phar', 'inc', 'txt', 'log', 'ini', 'cfg',
544 'conf', 'asp', 'aspx', 'jsp', 'jspx', 'cgi', 'pl', 'py', 'rb', 'sh',
545 'bash', 'exe', 'dll', 'so', 'bak', 'old', 'env',
546 );
547
548 /**
549 * Whether the request carries a remote file inclusion attempt
550 *
551 * Works on the parsed parameters rather than on a pattern match over the
552 * whole string, for two reasons: a link back to the site itself is not
553 * mistaken for an attack, and an encoded payload is seen for what it is.
554 * The copy of the query string kept for logging goes through
555 * sanitize_text_field(), which strips every %XX sequence instead of
556 * decoding it, so the encoded form never looked like a URL there.
557 *
558 * Until 2.11.0 an external URL in any parameter was the whole signature,
559 * and that is not what an inclusion looks like, it is what a link looks
560 * like. WordPress core's own /wp-json/oembed/1.0/proxy?url=... is the
561 * clearest case: the block editor asks the site to resolve a YouTube URL,
562 * the rule read it as RFI, and embedding was dead on every site with the
563 * rule on while the classic editor kept working, because it posts the same
564 * URL to admin-ajax instead of putting it in a query string. Since 2.11.1
565 * an external URL is an inclusion attempt when it travels in a parameter
566 * that is read as a path, or when it points at something includable.
567 *
568 * @since 2.9.9
569 *
570 * @return bool
571 */
572 private function has_remote_inclusion() {
573 $query = $this->request_data['query_raw'];
574
575 if ( '' === $query ) {
576 return false;
577 }
578
579 // parse_str() decodes as it splits, so this sees the same values PHP
580 // would have put in $_GET, without reading the superglobal.
581 $params = array();
582 parse_str( $query, $params );
583
584 $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) );
585
586 foreach ( $this->flatten_query_params( $params ) as $pair ) {
587 list( $names, $value ) = $pair;
588
589 if ( ! preg_match_all( '/(?:https?|ftp):\/\/[^\s\'"<>]+/i', $value, $matches ) ) {
590 continue;
591 }
592
593 foreach ( $matches[0] as $url ) {
594 $host = $this->normalize_host( wp_parse_url( $url, PHP_URL_HOST ) );
595
596 // A URL pointing at this very site is not an inclusion.
597 if ( '' !== $host && $host === $home_host ) {
598 continue;
599 }
600
601 if ( $this->looks_like_inclusion( $names, $url ) ) {
602 return true;
603 }
604 }
605 }
606
607 return false;
608 }
609
610 /**
611 * Query parameters as (name segments, value) pairs
612 *
613 * Keeps every segment of a nested name, so opts[file]=http://... is seen
614 * as the inclusion parameter it is and not as an anonymous value.
615 *
616 * @since 2.11.1
617 *
618 * @param array $params Parsed parameters.
619 * @param string[] $inherited Name segments of the parent levels.
620 * @return array List of array( string[] $names, string $value ).
621 */
622 private function flatten_query_params( $params, $inherited = array() ) {
623 $pairs = array();
624
625 foreach ( $params as $key => $value ) {
626 $names = array_merge( $inherited, array( strtolower( (string) $key ) ) );
627
628 if ( is_array( $value ) ) {
629 $pairs = array_merge( $pairs, $this->flatten_query_params( $value, $names ) );
630 continue;
631 }
632
633 if ( is_scalar( $value ) ) {
634 $pairs[] = array( $names, (string) $value );
635 }
636 }
637
638 return $pairs;
639 }
640
641 /**
642 * Whether an external URL in this parameter is an inclusion attempt
643 *
644 * Two independent signals, either is enough: the parameter is one a
645 * vulnerable script reads as a path, or the target is something that gets
646 * included rather than linked. The trailing '?' and the null byte are the
647 * two ways a payload truncates whatever the script appends to it.
648 *
649 * What this deliberately no longer catches, so nobody reads its silence as
650 * coverage: an external URL with no includable extension travelling in a
651 * parameter whose name is not on the list. That shape is a link, which is
652 * why the site's own search box and every return_url tripped the rule
653 * before. A payload in it still has to reach an include() in some other
654 * plugin's code to do anything, and the wrappers, the system paths and the
655 * traversal rules below are untouched.
656 *
657 * @since 2.11.1
658 *
659 * @param string[] $names Name segments of the parameter.
660 * @param string $url The external URL found in its value.
661 * @return bool
662 */
663 private function looks_like_inclusion( $names, $url ) {
664 foreach ( $names as $name ) {
665 if ( in_array( $name, self::$inclusion_param_names, true ) ) {
666 return true;
667 }
668 }
669
670 // ftp:// is never how a page links to something; it is how a payload
671 // is fetched.
672 if ( 0 === stripos( $url, 'ftp://' ) ) {
673 return true;
674 }
675
676 // Truncation of the suffix the vulnerable script appends.
677 if ( '?' === substr( $url, -1 ) || false !== stripos( $url, '%00' ) || false !== strpos( $url, "\0" ) ) {
678 return true;
679 }
680
681 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
682
683 if ( '' === $path ) {
684 return false;
685 }
686
687 $extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
688
689 return ( '' !== $extension && in_array( $extension, self::$inclusion_extensions, true ) );
690 }
691
692 /**
693 * The REST route of the current request, or '' when it is not a REST call
694 *
695 * Read from the request itself because REST_REQUEST is not defined yet:
696 * the firewall runs on init, and rest_api_loaded() defines it later, on
697 * parse_request. Both shapes are covered, the pretty /wp-json/<route> and
698 * the plain ?rest_route=<route>, and the prefix is asked for rather than
699 * assumed, since rest_url_prefix filters it.
700 *
701 * @since 2.11.1
702 *
703 * @return string Route with a leading slash, or '' when there is none.
704 */
705 private function current_rest_route() {
706 $params = array();
707 parse_str( (string) ( $this->request_data['query_raw'] ?? '' ), $params );
708
709 if ( isset( $params['rest_route'] ) && is_string( $params['rest_route'] ) ) {
710 return '/' . ltrim( $params['rest_route'], '/' );
711 }
712
713 $uri = (string) ( $this->request_data['uri_raw'] ?? '' );
714 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
715 $needle = '/' . trim( rest_get_url_prefix(), '/' ) . '/';
716 $at = strpos( $path, $needle );
717
718 if ( false === $at ) {
719 return '';
720 }
721
722 return '/' . ltrim( substr( $path, $at + strlen( $needle ) ), '/' );
723 }
724
725 /**
726 * Whether this is a logged-in editor asking core to resolve an embed
727 *
728 * /wp-json/oembed/1.0/proxy is where the block editor sends the URL the
729 * author pasted, so an external URL there is the request, not an attack.
730 * The exemption is not the route on its own: it asks for the capability
731 * that route's own permission_callback asks for, so an anonymous scanner
732 * probing it is still blocked and still logged. The other core embed
733 * route, /oembed/1.0/embed, only ever answers for this site's own URLs,
734 * which the check already leaves alone.
735 *
736 * @since 2.11.1
737 *
738 * @return bool
739 */
740 private function is_core_embed_proxy_request() {
741 if ( 0 !== strpos( $this->current_rest_route(), '/oembed/1.0/proxy' ) ) {
742 return false;
743 }
744
745 return ( is_user_logged_in() && current_user_can( 'edit_posts' ) );
746 }
747
748 /**
749 * Host in a comparable form: lowercase and without a leading www.
750 *
751 * @since 2.9.9
752 *
753 * @param string|null $host Host to normalize.
754 * @return string
755 */
756 private function normalize_host( $host ) {
757 $host = strtolower( trim( (string) $host ) );
758
759 return ( 0 === strpos( $host, 'www.' ) ) ? substr( $host, 4 ) : $host;
760 }
761
762 /**
763 * Check for directory traversal attacks
764 *
765 * @return string|false Error message or false if safe.
766 */
767 private function check_directory_traversal() {
768 $combined = $this->inspection_haystack();
769
770 if ( empty( $combined ) ) {
771 return false;
772 }
773
774 // Directory traversal patterns
775 $patterns = array(
776 '/\.\.\//i' => __( 'Directory traversal detected', 'vigilante' ),
777 '/\.\.%2f/i' => __( 'Encoded traversal detected', 'vigilante' ),
778 '/%2e%2e\//i' => __( 'Double encoded traversal', 'vigilante' ),
779 '/\.\.%5c/i' => __( 'Backslash traversal detected', 'vigilante' ),
780 );
781
782 foreach ( $patterns as $pattern => $message ) {
783 if ( preg_match( $pattern, $combined ) ) {
784 return $message;
785 }
786 }
787
788 return false;
789 }
790
791 /**
792 * Check for PHP execution in uploads
793 *
794 * @return string|false Error message or false if safe.
795 */
796 private function check_php_in_uploads() {
797 $uri = $this->inspection_haystack();
798
799 // Check if accessing PHP in uploads directory
800 if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) {
801 return __( 'PHP execution in uploads blocked', 'vigilante' );
802 }
803
804 return false;
805 }
806
807 /**
808 * Check for bad bots
809 *
810 * @return string|false Error message or false if safe.
811 */
812 private function check_bad_bots() {
813 $user_agent = strtolower( $this->request_data['user_agent'] );
814
815 if ( empty( $user_agent ) ) {
816 return false;
817 }
818
819 // Known malicious bots and scanners
820 // NOTE: Matching is done via strpos() on the full User-Agent string,
821 // so entries must be specific enough to avoid false positives with
822 // legitimate services, plugins, or WordPress loopback requests.
823 // Generic short words (e.g. 'scan', 'ninja', 'titan') must stay out
824 // of BOTH this list and the htaccess one: the htaccess regex matches
825 // bare substrings too, and unlike this layer it runs before PHP, so
826 // the ua_whitelist cannot rescue a false positive there.
827 $bad_bots = array(
828 'ahrefsbot',
829 'semrushbot',
830 'dotbot',
831 'mj12bot',
832 'blexbot',
833 'linkdexbot',
834 'aspiegelbot',
835 'alexibot',
836 'backlink',
837 'bandit',
838 'batchftp',
839 'bigfoot',
840 'blackwidow',
841 'blowfish',
842 'botalot',
843 'builtbottough',
844 'bullseye',
845 'cheesebot',
846 'cherrypicker',
847 'chinaclaw',
848 'copyrightcheck',
849 'crescent',
850 'curl/',
851 'dittospyder',
852 'dragonfly',
853 'easydl',
854 'ebingbong',
855 'ecatch',
856 'eirgrabber',
857 'emailcollector',
858 'emailsiphon',
859 'emailwolf',
860 'erocrawler',
861 'exabot',
862 'expressweb',
863 'eyenetie',
864 'flashget',
865 'flunky',
866 'frontpage',
867 'getright',
868 'getweb',
869 'go-ahead-got-it',
870 'gotit',
871 'grabnet',
872 'grafula',
873 'harvest',
874 'hloader',
875 'hmview',
876 'httplib',
877 'httrack',
878 'humanlinks',
879 'ia_archiver',
880 'imagestripper',
881 'imagesucker',
882 'indy library',
883 'infonavirobot',
884 'infotekies',
885 'intelliseek',
886 'interget',
887 'intraformant',
888 'jakarta',
889 'jennybot',
890 'jetcar',
891 'kenjin',
892 'larbin',
893 'leechftp',
894 'lexibot',
895 'libweb',
896 'likse',
897 'linkscan',
898 'linkwalker',
899 'lnspiderguy',
900 'lwp',
901 'magnet',
902 'mag-net',
903 'markwatch',
904 'mass downloader',
905 'masscan',
906 'microsoft.url',
907 'midown',
908 'miixpc',
909 'missigua',
910 'moget',
911 'nameprotect',
912 'navroad',
913 'nearsite',
914 'net vampire',
915 'netants',
916 'netcraft',
917 'netmechanic',
918 'netspider',
919 'nextgensearchbot',
920 'nibbler',
921 'nicerspro',
922 'niki-bot',
923 'npbot',
924 'offline explorer',
925 'offline navigator',
926 'openfind',
927 'outfoxbot',
928 'pagegrabber',
929 'pavuk',
930 'pcbrowser',
931 'php/',
932 'pockey',
933 'prowebwalker',
934 'psycheclone',
935 'python-urllib',
936 'python-requests',
937 'python/',
938 'queryn',
939 'reget',
940 'repomonkey',
941 'siphon',
942 'siteexplorer',
943 'sitesnagger',
944 'slurp',
945 'smartdownload',
946 'snapbot',
947 'snoopy',
948 'sogou',
949 'spacebison',
950 'spankbot',
951 'sqworm',
952 'superbot',
953 'superhttp',
954 'surfbot',
955 'suzuran',
956 'szukacz',
957 'takeout',
958 'teleport',
959 'telesoft',
960 'thenomad',
961 'tighttwatbot',
962 'true_robot',
963 'turingos',
964 'turnitinbot',
965 'voideye',
966 'webalta',
967 'webbandit',
968 'webcollector',
969 'webcopier',
970 'webdup',
971 'webenhancer',
972 'webfetch',
973 'webgo',
974 'webmasterworldforumbot',
975 'webpictures',
976 'webreaper',
977 'websauger',
978 'webspider',
979 'webstripper',
980 'websucker',
981 'webwhacker',
982 'webzip',
983 'widow',
984 'wisenut',
985 'wwwoffle',
986 'xaldon',
987 'xxxyy',
988 'zeus',
989 'zermelo',
990 'zyborg',
991 );
992
993 foreach ( $bad_bots as $bot ) {
994 if ( strpos( $user_agent, $bot ) !== false ) {
995 return sprintf(
996 /* translators: %s: Bot name */
997 __( 'Bad bot blocked: %s', 'vigilante' ),
998 $bot
999 );
1000 }
1001 }
1002
1003 return false;
1004 }
1005
1006 /**
1007 * Check for empty user agent
1008 *
1009 * @return string|false Error message or false if safe.
1010 */
1011 private function check_empty_user_agent() {
1012 if ( empty( $this->request_data['user_agent'] ) ) {
1013 return __( 'Empty user agent blocked', 'vigilante' );
1014 }
1015 return false;
1016 }
1017
1018 /**
1019 * Check HTTP method
1020 *
1021 * Logged-in users with edit capabilities are excluded to ensure
1022 * Gutenberg, REST API, and page builders work correctly.
1023 */
1024 private function check_http_method() {
1025 // Skip for authenticated users who can edit content
1026 // They need OPTIONS, PUT, PATCH, DELETE for Gutenberg, REST API, and page builders
1027 if ( is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
1028 return;
1029 }
1030
1031 // Skip for WordPress REST API requests
1032 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
1033 // authentication and authorization layer — no need to filter methods here
1034 $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
1035 if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
1036 return;
1037 }
1038
1039 $method = strtoupper( $this->request_data['method'] );
1040 $allowed_methods = isset( $this->options['allowed_http_methods'] )
1041 ? $this->options['allowed_http_methods']
1042 : array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' );
1043 $allowed = array_map( 'strtoupper', $allowed_methods );
1044
1045 if ( ! in_array( $method, $allowed, true ) ) {
1046 $this->block_request(
1047 'http_method',
1048 sprintf(
1049 /* translators: %s: HTTP method */
1050 __( 'HTTP method %s not allowed', 'vigilante' ),
1051 $method
1052 )
1053 );
1054 }
1055 }
1056
1057 /**
1058 * Upper bound of the vigilante_firewall_blocks index
1059 *
1060 * The index only feeds the admin screen; enforcement reads a transient per
1061 * IP. Under a distributed attack the oldest entries are dropped first, so
1062 * the option cannot grow without limit (S6).
1063 *
1064 * @since 2.11.0
1065 */
1066 const MAX_TRACKED_BLOCKS = 500;
1067
1068 /**
1069 * Add a block to the bounded admin index
1070 *
1071 * Prunes expired entries on every write, not only when an administrator
1072 * opens the Firewall tab, and keeps at most MAX_TRACKED_BLOCKS entries,
1073 * dropping the oldest by blocked_at.
1074 *
1075 * @since 2.11.0
1076 *
1077 * @param string $ip Blocked address.
1078 * @param array $block Block data (expires, blocked_at, duration, reason, strikes).
1079 */
1080 private static function index_block( $ip, $block ) {
1081 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1082 $now = time();
1083
1084 if ( ! is_array( $blocks ) ) {
1085 $blocks = array();
1086 }
1087
1088 foreach ( $blocks as $blocked_ip => $data ) {
1089 if ( ! is_array( $data ) || ! isset( $data['expires'] ) || $now >= (int) $data['expires'] ) {
1090 unset( $blocks[ $blocked_ip ] );
1091 }
1092 }
1093
1094 $blocks[ $ip ] = $block;
1095
1096 if ( count( $blocks ) > self::MAX_TRACKED_BLOCKS ) {
1097 uasort(
1098 $blocks,
1099 static function ( $a, $b ) {
1100 return (int) ( $a['blocked_at'] ?? 0 ) <=> (int) ( $b['blocked_at'] ?? 0 );
1101 }
1102 );
1103 $blocks = array_slice( $blocks, count( $blocks ) - self::MAX_TRACKED_BLOCKS, null, true );
1104 }
1105
1106 update_option( 'vigilante_firewall_blocks', $blocks, false );
1107 }
1108
1109 /**
1110 * Check rate limiting
1111 */
1112 public function check_rate_limit() {
1113 // Skip rate limiting for whitelisted IPs
1114 if ( $this->is_ip_whitelisted() ) {
1115 return;
1116 }
1117
1118 // Skip rate limiting for logged-in administrators
1119 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
1120 return;
1121 }
1122
1123 // Allow other modules to opt out — Under Attack mode uses this so that
1124 // visitors who already passed the JS challenge don't burn the
1125 // aggressive 30 req/min cap loading a normal page's assets.
1126 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
1127 return;
1128 }
1129
1130 $ip = $this->get_client_ip();
1131 $rate_limit = $this->options['rate_limiting'];
1132
1133 // Check if already blocked (fast path). The active block lives in a
1134 // transient keyed by IP, so this path, which runs on every
1135 // unauthenticated request, reads one row and not the whole index of
1136 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
1137 // entire, an array with no upper bound that a distributed attack grew
1138 // by one entry per new address, so the firewall amplified the attack it
1139 // was blocking (S6). The transient expires with the block itself.
1140 $block = get_transient( 'vigilante_rate_block_' . md5( $ip ) );
1141 if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) {
1142 if ( ! headers_sent() ) {
1143 status_header( 429 );
1144 nocache_headers();
1145 }
1146 wp_die(
1147 esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
1148 esc_html__( 'Too Many Requests', 'vigilante' ),
1149 array( 'response' => 429 )
1150 );
1151 }
1152
1153 $max_requests = absint( $rate_limit['requests_per_minute'] );
1154
1155 // Allow Under Attack mode (or other filters) to override threshold
1156 $max_requests = absint( apply_filters( 'vigilante_rate_limit_requests', $max_requests ) );
1157
1158 // Fixed window, anchored to the timestamp of its first request.
1159 //
1160 // The count used to live in a transient whose TTL was renewed on every
1161 // hit, which is a window that never closes: any IP going less than 60 s
1162 // between requests kept accumulating, so the effective limit was not
1163 // "requests per minute" but "requests since the last full minute of
1164 // silence". A logged-in editor publishing several posts in a row could
1165 // pile up 150+ requests while never exceeding 60 in any single minute,
1166 // and got a 429. Storing the window start makes the reset explicit
1167 // instead of relying on the transient expiring.
1168 $transient_key = 'vigilante_rate_' . md5( $ip );
1169 $window = get_transient( $transient_key );
1170 $now = time();
1171
1172 // Counts stored before 2.9.5 were a bare integer with no window start.
1173 // There is no way to tell how old such a count is, so open a new window.
1174 if ( ! is_array( $window ) || ! isset( $window['start'], $window['count'] ) ) {
1175 $window = array(
1176 'start' => $now,
1177 'count' => 0,
1178 );
1179 }
1180
1181 // Window elapsed: start counting again, even under continuous traffic.
1182 if ( ( $now - absint( $window['start'] ) ) >= self::RATE_LIMIT_WINDOW ) {
1183 $window = array(
1184 'start' => $now,
1185 'count' => 0,
1186 );
1187 }
1188
1189 // Count this request, then allow up to $max_requests per window.
1190 $window['count'] = absint( $window['count'] ) + 1;
1191 $request_count = $window['count'];
1192
1193 if ( $request_count > $max_requests ) {
1194 $base_duration = absint( $rate_limit['block_duration'] );
1195
1196 // Allow Under Attack mode (or other filters) to override duration
1197 $base_duration = absint( apply_filters( 'vigilante_rate_limit_duration', $base_duration ) );
1198
1199 $duration = $base_duration;
1200 $strikes = 1;
1201
1202 // Progressive blocking: double duration on each repeat offense
1203 if ( ! empty( $rate_limit['progressive'] ) ) {
1204 $strikes_key = 'vigilante_strikes_' . md5( $ip );
1205 $strikes = absint( get_transient( $strikes_key ) ) + 1;
1206
1207 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
1208 $duration = min(
1209 $base_duration * pow( 2, $strikes - 1 ),
1210 $max_duration
1211 );
1212
1213 // Persist strikes for 24h so they accumulate across blocks
1214 set_transient( $strikes_key, $strikes, 86400 );
1215 }
1216
1217 $block = array(
1218 'expires' => time() + $duration,
1219 'blocked_at' => time(),
1220 'duration' => $duration,
1221 'reason' => 'rate_limit',
1222 'strikes' => $strikes,
1223 );
1224
1225 // The block itself, read by the fast path above on every request.
1226 set_transient( 'vigilante_rate_block_' . md5( $ip ), $block, $duration );
1227
1228 // The bounded index the admin screen lists.
1229 self::index_block( $ip, $block );
1230
1231 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
1232 }
1233
1234 // The TTL only garbage-collects the payload once the IP goes quiet; what
1235 // bounds the count is the window reset above, not the expiry.
1236 set_transient( $transient_key, $window, self::RATE_LIMIT_WINDOW );
1237 }
1238
1239 /**
1240 * Block a request
1241 *
1242 * @param string $reason Reason code for blocking.
1243 * @param string $message Message to log.
1244 * @param int $status_code HTTP status code.
1245 */
1246 private function block_request( $reason, $message, $status_code = 403 ) {
1247 // Log the block
1248 if ( $this->activity_log ) {
1249 $this->activity_log->log(
1250 'firewall',
1251 'blocked',
1252 $message,
1253 array(
1254 'reason' => $reason,
1255 'request_uri' => $this->loggable_uri(),
1256 'ip' => $this->get_client_ip(),
1257 'user_agent' => $this->request_data['user_agent'] ?? '',
1258 ),
1259 'warning'
1260 );
1261 }
1262
1263 // Set response headers
1264 if ( ! headers_sent() ) {
1265 status_header( $status_code );
1266 nocache_headers();
1267 }
1268
1269 // A REST client gets the refusal in the shape it can read. Until
1270 // 2.11.0 every block answered with the HTML "Forbidden" page, so the
1271 // block editor could only show its own generic message and the reason
1272 // was reachable only by opening the activity log. Same status code,
1273 // same message, the envelope core uses for an error.
1274 if ( '' !== $this->current_rest_route() ) {
1275 wp_send_json(
1276 array(
1277 'code' => 'vigilante_firewall_blocked',
1278 'message' => $message,
1279 'data' => array( 'status' => $status_code ),
1280 ),
1281 $status_code
1282 );
1283 }
1284
1285 // Return appropriate response
1286 if ( 429 === $status_code ) {
1287 wp_die(
1288 esc_html( $message ),
1289 esc_html__( 'Too Many Requests', 'vigilante' ),
1290 array( 'response' => 429 )
1291 );
1292 }
1293
1294 wp_die(
1295 esc_html( $message ),
1296 esc_html__( 'Forbidden', 'vigilante' ),
1297 array( 'response' => 403 )
1298 );
1299 }
1300
1301 /**
1302 * Upper bound of the address stored with a logged block
1303 *
1304 * @since 2.11.1
1305 */
1306 const MAX_LOGGED_URI = 512;
1307
1308 /**
1309 * The blocked address, in a form that still says what was blocked
1310 *
1311 * The copy kept for logging goes through sanitize_text_field(), which
1312 * deletes every %XX sequence instead of decoding it. A browser percent
1313 * encodes the URL it puts in a parameter, so a blocked embed was recorded
1314 * as "/wp-json/oembed/1.0/proxy?url=httpswww.youtube.comwatchv..." and the
1315 * owner could not tell what the request had been. Reported on 9 sep 2026.
1316 *
1317 * Nothing is sanitized away here beyond control characters, and that is
1318 * the point. The value is stored, never executed: it is escaped where it
1319 * is shown, by escapeHtml() in the log detail and by csvCell() in the
1320 * export. Invalid UTF-8 is stripped because wp_json_encode() returns false
1321 * on it, which would have thrown away the whole entry's context.
1322 *
1323 * @since 2.11.1
1324 *
1325 * @return string
1326 */
1327 private function loggable_uri() {
1328 $uri = (string) ( $this->request_data['uri_raw'] ?? '' );
1329 $uri = (string) preg_replace( '/[\x00-\x1F\x7F]/', '', $uri );
1330 $uri = wp_check_invalid_utf8( $uri, true );
1331
1332 if ( strlen( $uri ) > self::MAX_LOGGED_URI ) {
1333 $uri = substr( $uri, 0, self::MAX_LOGGED_URI ) . '...';
1334 }
1335
1336 return $uri;
1337 }
1338
1339 /**
1340 * Check if current IP is whitelisted
1341 *
1342 * @return bool
1343 */
1344 private function is_ip_whitelisted() {
1345 $whitelist = $this->options['ip_whitelist'] ?? array();
1346
1347 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $whitelist );
1348 }
1349
1350 /**
1351 * Check if current IP is blacklisted
1352 *
1353 * @return bool
1354 */
1355 private function is_ip_blacklisted() {
1356 $blacklist = $this->options['ip_blacklist'] ?? array();
1357
1358 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $blacklist );
1359 }
1360
1361 /**
1362 * Check if current User-Agent is whitelisted
1363 *
1364 * Partial matching: if the request UA contains any whitelisted string,
1365 * it bypasses all firewall checks. Useful for services like ManageWP, MainWP, etc.
1366 *
1367 * @return bool
1368 */
1369 private function is_ua_whitelisted() {
1370 $whitelist = $this->options['ua_whitelist'] ?? array();
1371
1372 if ( empty( $whitelist ) ) {
1373 return false;
1374 }
1375
1376 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1377
1378 if ( empty( $user_agent ) ) {
1379 return false;
1380 }
1381
1382 $ua_lower = strtolower( $user_agent );
1383
1384 foreach ( $whitelist as $allowed ) {
1385 $allowed = trim( $allowed );
1386 if ( ! empty( $allowed ) && false !== strpos( $ua_lower, strtolower( $allowed ) ) ) {
1387 return true;
1388 }
1389 }
1390
1391 return false;
1392 }
1393
1394 /**
1395 * Check if current User-Agent is blacklisted
1396 *
1397 * Partial matching: if the request UA contains any blacklisted string, block it.
1398 *
1399 * @return bool
1400 */
1401 private function is_ua_blacklisted() {
1402 $blacklist = $this->options['ua_blacklist'] ?? array();
1403
1404 if ( empty( $blacklist ) ) {
1405 return false;
1406 }
1407
1408 $user_agent = $this->request_data['user_agent'] ?? '';
1409
1410 if ( empty( $user_agent ) ) {
1411 return false;
1412 }
1413
1414 $ua_lower = strtolower( $user_agent );
1415
1416 foreach ( $blacklist as $blocked ) {
1417 $blocked = trim( $blocked );
1418 if ( ! empty( $blocked ) && false !== strpos( $ua_lower, strtolower( $blocked ) ) ) {
1419 return true;
1420 }
1421 }
1422
1423 return false;
1424 }
1425
1426 /**
1427 * Get client IP address
1428 *
1429 * Delegates to the shared resolver, which only trusts REMOTE_ADDR unless a
1430 * proxy header has been explicitly declared in settings.
1431 *
1432 * @return string
1433 */
1434 private function get_client_ip() {
1435 return Vigilante_IP_Utils::get_client_ip();
1436 }
1437
1438 // =========================================================================
1439 // BLOCK MANAGEMENT (static, for admin UI)
1440 // =========================================================================
1441
1442 /**
1443 * Get currently active firewall blocks
1444 *
1445 * Cleans expired entries on each call.
1446 *
1447 * @return array Active blocks keyed by IP address.
1448 */
1449 public static function get_active_blocks() {
1450 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1451 $now = time();
1452 $dirty = false;
1453
1454 foreach ( $blocks as $ip => $data ) {
1455 if ( $now >= $data['expires'] ) {
1456 unset( $blocks[ $ip ] );
1457 $dirty = true;
1458 }
1459 }
1460
1461 if ( $dirty ) {
1462 update_option( 'vigilante_firewall_blocks', $blocks, false );
1463 }
1464
1465 return $blocks;
1466 }
1467
1468 /**
1469 * Manually unblock an IP from rate limit blocks
1470 *
1471 * @param string $ip IP address to unblock.
1472 * @return bool Whether the IP was found and removed.
1473 */
1474 public static function unblock_ip( $ip ) {
1475 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1476
1477 if ( ! isset( $blocks[ $ip ] ) ) {
1478 return false;
1479 }
1480
1481 unset( $blocks[ $ip ] );
1482 update_option( 'vigilante_firewall_blocks', $blocks, false );
1483
1484 // Clean related transients
1485 $hash = md5( $ip );
1486 delete_transient( 'vigilante_rate_block_' . $hash );
1487 delete_transient( 'vigilante_rate_' . $hash );
1488 delete_transient( 'vigilante_strikes_' . $hash );
1489
1490 return true;
1491 }
1492 }