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

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

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