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

1,555 lines 55.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Firewall Class
4 *
5 * WordPress-optimized firewall protection
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Firewall
17 *
18 * Provides firewall protection against common attacks
19 */
20 class Vigilante_Firewall {
21
22 /**
23 * 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 * Whether the request is addressed to the REST API itself
1020 *
1021 * The method filter lets the REST API through, and until 2.11.8 it asked
1022 * whether "/wp-json/" appeared anywhere in the address, query string
1023 * included: TRACE /?x=/wp-json/ skipped the filter and reached a page that
1024 * is not the REST API at all. Found by the audit of the firewall for
1025 * 2.11.8. Core routes the pretty REST URLs from the start of the home
1026 * path, directly or through index.php, so the path has to start there.
1027 * The ?rest_route= form never matched the old test and still does not:
1028 * widening the exemption was not the point.
1029 *
1030 * @since 2.11.8
1031 *
1032 * @return bool
1033 */
1034 private function is_rest_api_request() {
1035 /*
1036 * The path is cut by hand, not with wp_parse_url(): with two leading
1037 * slashes that reads "//wp-json/..." as a host, and WordPress still routes
1038 * it to the REST API. And both the home path and the root are accepted,
1039 * for the language folders some multilingual plugins add to home_url().
1040 * Both from the cross review of 2.11.8.
1041 */
1042 $path = preg_replace( '#/{2,}#', '/', (string) preg_replace( '/[?#].*$/s', '', (string) ( $this->request_data['uri_raw'] ?? '' ) ) );
1043 $home = trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) );
1044 $prefix = trim( rest_get_url_prefix(), '/' );
1045
1046 foreach ( array_unique( array( $home, '/' ) ) as $root ) {
1047 foreach ( array( $root . $prefix, $root . 'index.php/' . $prefix ) as $base ) {
1048 if ( $path === $base || 0 === strpos( (string) $path, $base . '/' ) ) {
1049 return true;
1050 }
1051 }
1052 }
1053
1054 return false;
1055 }
1056
1057 /**
1058 * Check HTTP method
1059 *
1060 * Logged-in users with edit capabilities are excluded to ensure
1061 * Gutenberg, REST API, and page builders work correctly.
1062 */
1063 private function check_http_method() {
1064 // Skip for authenticated users who can edit content
1065 // They need OPTIONS, PUT, PATCH, DELETE for Gutenberg, REST API, and page builders
1066 if ( is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
1067 return;
1068 }
1069
1070 // Skip for WordPress REST API requests
1071 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
1072 // authentication and authorization layer — no need to filter methods here
1073 if ( $this->is_rest_api_request() ) {
1074 return;
1075 }
1076
1077 $method = strtoupper( $this->request_data['method'] );
1078 $allowed_methods = isset( $this->options['allowed_http_methods'] )
1079 ? $this->options['allowed_http_methods']
1080 : array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' );
1081 $allowed = array_map( 'strtoupper', $allowed_methods );
1082
1083 if ( ! in_array( $method, $allowed, true ) ) {
1084 $this->block_request(
1085 'http_method',
1086 sprintf(
1087 /* translators: %s: HTTP method */
1088 __( 'HTTP method %s not allowed', 'vigilante' ),
1089 $method
1090 )
1091 );
1092 }
1093 }
1094
1095 /**
1096 * Upper bound of the vigilante_firewall_blocks index
1097 *
1098 * The index only feeds the admin screen; enforcement reads a transient per
1099 * IP. Under a distributed attack the oldest entries are dropped first, so
1100 * the option cannot grow without limit (S6).
1101 *
1102 * @since 2.11.0
1103 */
1104 const MAX_TRACKED_BLOCKS = 500;
1105
1106 /**
1107 * Add a block to the bounded admin index
1108 *
1109 * Prunes expired entries on every write, not only when an administrator
1110 * opens the Firewall tab, and keeps at most MAX_TRACKED_BLOCKS entries,
1111 * dropping the oldest by blocked_at.
1112 *
1113 * @since 2.11.0
1114 *
1115 * @param string $ip Blocked address.
1116 * @param array $block Block data (expires, blocked_at, duration, reason, strikes).
1117 */
1118 private static function index_block( $ip, $block ) {
1119 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1120 $now = time();
1121
1122 if ( ! is_array( $blocks ) ) {
1123 $blocks = array();
1124 }
1125
1126 foreach ( $blocks as $blocked_ip => $data ) {
1127 if ( ! is_array( $data ) || ! isset( $data['expires'] ) || $now >= (int) $data['expires'] ) {
1128 unset( $blocks[ $blocked_ip ] );
1129 }
1130 }
1131
1132 $blocks[ $ip ] = $block;
1133
1134 if ( count( $blocks ) > self::MAX_TRACKED_BLOCKS ) {
1135 uasort(
1136 $blocks,
1137 static function ( $a, $b ) {
1138 return (int) ( $a['blocked_at'] ?? 0 ) <=> (int) ( $b['blocked_at'] ?? 0 );
1139 }
1140 );
1141 $blocks = array_slice( $blocks, count( $blocks ) - self::MAX_TRACKED_BLOCKS, null, true );
1142 }
1143
1144 update_option( 'vigilante_firewall_blocks', $blocks, false );
1145 }
1146
1147 /**
1148 * Check rate limiting
1149 */
1150 public function check_rate_limit() {
1151 // Skip rate limiting for whitelisted IPs
1152 if ( $this->is_ip_whitelisted() ) {
1153 return;
1154 }
1155
1156 // Skip rate limiting for logged-in administrators
1157 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
1158 return;
1159 }
1160
1161 // Allow other code to opt out. Under Attack mode used this until 2.11.8
1162 // to exempt visitors who had passed the JS challenge, which exempted a
1163 // bot that solved it once, too; it now raises their limit instead,
1164 // through vigilante_rate_limit_requests below.
1165 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
1166 return;
1167 }
1168
1169 $ip = $this->get_client_ip();
1170 $rate_limit = $this->options['rate_limiting'];
1171
1172 /*
1173 * What the count and the block are kept under: the address, unless a
1174 * filter narrows it. Under Attack mode gives visitors who passed its
1175 * challenge a count of their own, because counting them with everybody
1176 * else at their address let one unverified client behind the same NAT
1177 * lock them out for fifteen minutes with its own block. Found by the
1178 * cross review of 2.11.8, the same shape as the challenge nonce the
1179 * automated review reported on 2.11.7.
1180 */
1181 $key = (string) apply_filters( 'vigilante_rate_limit_key', $ip );
1182 $key = '' !== $key ? $key : $ip;
1183 $hash = md5( $key );
1184
1185 // Check if already blocked (fast path). The active block lives in a
1186 // transient keyed by IP, so this path, which runs on every
1187 // unauthenticated request, reads one row and not the whole index of
1188 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
1189 // entire, an array with no upper bound that a distributed attack grew
1190 // by one entry per new address, so the firewall amplified the attack it
1191 // was blocking (S6). The transient expires with the block itself.
1192 $block = get_transient( 'vigilante_rate_block_' . $hash );
1193 if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) {
1194 if ( ! headers_sent() ) {
1195 status_header( 429 );
1196 nocache_headers();
1197 }
1198 wp_die(
1199 esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
1200 esc_html__( 'Too Many Requests', 'vigilante' ),
1201 array( 'response' => 429 )
1202 );
1203 }
1204
1205 $max_requests = absint( $rate_limit['requests_per_minute'] );
1206
1207 // Allow Under Attack mode (or other filters) to override threshold
1208 $max_requests = absint( apply_filters( 'vigilante_rate_limit_requests', $max_requests ) );
1209
1210 // Fixed window, anchored to the timestamp of its first request.
1211 //
1212 // The count used to live in a transient whose TTL was renewed on every
1213 // hit, which is a window that never closes: any IP going less than 60 s
1214 // between requests kept accumulating, so the effective limit was not
1215 // "requests per minute" but "requests since the last full minute of
1216 // silence". A logged-in editor publishing several posts in a row could
1217 // pile up 150+ requests while never exceeding 60 in any single minute,
1218 // and got a 429. Storing the window start makes the reset explicit
1219 // instead of relying on the transient expiring.
1220 $transient_key = 'vigilante_rate_' . $hash;
1221 $window = get_transient( $transient_key );
1222 $now = time();
1223
1224 // Counts stored before 2.9.5 were a bare integer with no window start.
1225 // There is no way to tell how old such a count is, so open a new window.
1226 if ( ! is_array( $window ) || ! isset( $window['start'], $window['count'] ) ) {
1227 $window = array(
1228 'start' => $now,
1229 'count' => 0,
1230 );
1231 }
1232
1233 // Window elapsed: start counting again, even under continuous traffic.
1234 if ( ( $now - absint( $window['start'] ) ) >= self::RATE_LIMIT_WINDOW ) {
1235 $window = array(
1236 'start' => $now,
1237 'count' => 0,
1238 );
1239 }
1240
1241 // Count this request, then allow up to $max_requests per window.
1242 $window['count'] = absint( $window['count'] ) + 1;
1243 $request_count = $window['count'];
1244
1245 if ( $request_count > $max_requests ) {
1246 $base_duration = absint( $rate_limit['block_duration'] );
1247
1248 // Allow Under Attack mode (or other filters) to override duration
1249 $base_duration = absint( apply_filters( 'vigilante_rate_limit_duration', $base_duration ) );
1250
1251 $duration = $base_duration;
1252 $strikes = 1;
1253
1254 // Progressive blocking: double duration on each repeat offense
1255 if ( ! empty( $rate_limit['progressive'] ) ) {
1256 $strikes_key = 'vigilante_strikes_' . $hash;
1257 $strikes = absint( get_transient( $strikes_key ) ) + 1;
1258
1259 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
1260 $duration = min(
1261 $base_duration * pow( 2, $strikes - 1 ),
1262 $max_duration
1263 );
1264
1265 // Persist strikes for 24h so they accumulate across blocks
1266 set_transient( $strikes_key, $strikes, 86400 );
1267 }
1268
1269 $block = array(
1270 'expires' => time() + $duration,
1271 'blocked_at' => time(),
1272 'duration' => $duration,
1273 'reason' => 'rate_limit',
1274 'strikes' => $strikes,
1275 'key' => $key,
1276 );
1277
1278 // The block itself, read by the fast path above on every request.
1279 set_transient( 'vigilante_rate_block_' . $hash, $block, $duration );
1280
1281 // The bounded index the admin screen lists.
1282 self::index_block( $ip, $block );
1283
1284 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
1285 }
1286
1287 // The TTL only garbage-collects the payload once the IP goes quiet; what
1288 // bounds the count is the window reset above, not the expiry.
1289 set_transient( $transient_key, $window, self::RATE_LIMIT_WINDOW );
1290 }
1291
1292 /**
1293 * Block a request
1294 *
1295 * @param string $reason Reason code for blocking.
1296 * @param string $message Message to log.
1297 * @param int $status_code HTTP status code.
1298 */
1299 private function block_request( $reason, $message, $status_code = 403 ) {
1300 // Log the block
1301 if ( $this->activity_log ) {
1302 $this->activity_log->log(
1303 'firewall',
1304 'blocked',
1305 $message,
1306 array(
1307 'reason' => $reason,
1308 'request_uri' => $this->loggable_uri(),
1309 'ip' => $this->get_client_ip(),
1310 'user_agent' => $this->request_data['user_agent'] ?? '',
1311 ),
1312 'warning'
1313 );
1314 }
1315
1316 // Set response headers
1317 if ( ! headers_sent() ) {
1318 status_header( $status_code );
1319 nocache_headers();
1320 }
1321
1322 // A REST client gets the refusal in the shape it can read. Until
1323 // 2.11.0 every block answered with the HTML "Forbidden" page, so the
1324 // block editor could only show its own generic message and the reason
1325 // was reachable only by opening the activity log. Same status code,
1326 // same message, the envelope core uses for an error.
1327 if ( '' !== $this->current_rest_route() ) {
1328 wp_send_json(
1329 array(
1330 'code' => 'vigilante_firewall_blocked',
1331 'message' => $message,
1332 'data' => array( 'status' => $status_code ),
1333 ),
1334 $status_code
1335 );
1336 }
1337
1338 // Return appropriate response
1339 if ( 429 === $status_code ) {
1340 wp_die(
1341 esc_html( $message ),
1342 esc_html__( 'Too Many Requests', 'vigilante' ),
1343 array( 'response' => 429 )
1344 );
1345 }
1346
1347 wp_die(
1348 esc_html( $message ),
1349 esc_html__( 'Forbidden', 'vigilante' ),
1350 array( 'response' => 403 )
1351 );
1352 }
1353
1354 /**
1355 * Upper bound of the address stored with a logged block
1356 *
1357 * @since 2.11.1
1358 */
1359 const MAX_LOGGED_URI = 512;
1360
1361 /**
1362 * The blocked address, in a form that still says what was blocked
1363 *
1364 * The copy kept for logging goes through sanitize_text_field(), which
1365 * deletes every %XX sequence instead of decoding it. A browser percent
1366 * encodes the URL it puts in a parameter, so a blocked embed was recorded
1367 * as "/wp-json/oembed/1.0/proxy?url=httpswww.youtube.comwatchv..." and the
1368 * owner could not tell what the request had been. Reported on 9 sep 2026.
1369 *
1370 * Nothing is sanitized away here beyond control characters, and that is
1371 * the point. The value is stored, never executed: it is escaped where it
1372 * is shown, by escapeHtml() in the log detail and by csvCell() in the
1373 * export. Invalid UTF-8 is stripped because wp_json_encode() returns false
1374 * on it, which would have thrown away the whole entry's context.
1375 *
1376 * @since 2.11.1
1377 *
1378 * @return string
1379 */
1380 private function loggable_uri() {
1381 $uri = (string) ( $this->request_data['uri_raw'] ?? '' );
1382 $uri = (string) preg_replace( '/[\x00-\x1F\x7F]/', '', $uri );
1383 $uri = wp_check_invalid_utf8( $uri, true );
1384
1385 if ( strlen( $uri ) > self::MAX_LOGGED_URI ) {
1386 $uri = substr( $uri, 0, self::MAX_LOGGED_URI ) . '...';
1387 }
1388
1389 return $uri;
1390 }
1391
1392 /**
1393 * Check if current IP is whitelisted
1394 *
1395 * @return bool
1396 */
1397 private function is_ip_whitelisted() {
1398 $whitelist = $this->options['ip_whitelist'] ?? array();
1399
1400 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $whitelist );
1401 }
1402
1403 /**
1404 * Check if current IP is blacklisted
1405 *
1406 * @return bool
1407 */
1408 private function is_ip_blacklisted() {
1409 $blacklist = $this->options['ip_blacklist'] ?? array();
1410
1411 return Vigilante_IP_Utils::in_list( $this->get_client_ip(), $blacklist );
1412 }
1413
1414 /**
1415 * Check if current User-Agent is whitelisted
1416 *
1417 * Partial matching: if the request UA contains any whitelisted string,
1418 * it bypasses all firewall checks. Useful for services like ManageWP, MainWP, etc.
1419 *
1420 * @return bool
1421 */
1422 private function is_ua_whitelisted() {
1423 $whitelist = $this->options['ua_whitelist'] ?? array();
1424
1425 if ( empty( $whitelist ) ) {
1426 return false;
1427 }
1428
1429 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1430
1431 if ( empty( $user_agent ) ) {
1432 return false;
1433 }
1434
1435 $ua_lower = strtolower( $user_agent );
1436
1437 foreach ( $whitelist as $allowed ) {
1438 $allowed = trim( $allowed );
1439 if ( ! empty( $allowed ) && false !== strpos( $ua_lower, strtolower( $allowed ) ) ) {
1440 return true;
1441 }
1442 }
1443
1444 return false;
1445 }
1446
1447 /**
1448 * Check if current User-Agent is blacklisted
1449 *
1450 * Partial matching: if the request UA contains any blacklisted string, block it.
1451 *
1452 * @return bool
1453 */
1454 private function is_ua_blacklisted() {
1455 $blacklist = $this->options['ua_blacklist'] ?? array();
1456
1457 if ( empty( $blacklist ) ) {
1458 return false;
1459 }
1460
1461 $user_agent = $this->request_data['user_agent'] ?? '';
1462
1463 if ( empty( $user_agent ) ) {
1464 return false;
1465 }
1466
1467 $ua_lower = strtolower( $user_agent );
1468
1469 foreach ( $blacklist as $blocked ) {
1470 $blocked = trim( $blocked );
1471 if ( ! empty( $blocked ) && false !== strpos( $ua_lower, strtolower( $blocked ) ) ) {
1472 return true;
1473 }
1474 }
1475
1476 return false;
1477 }
1478
1479 /**
1480 * Get client IP address
1481 *
1482 * Delegates to the shared resolver, which only trusts REMOTE_ADDR unless a
1483 * proxy header has been explicitly declared in settings.
1484 *
1485 * @return string
1486 */
1487 private function get_client_ip() {
1488 return Vigilante_IP_Utils::get_client_ip();
1489 }
1490
1491 // =========================================================================
1492 // BLOCK MANAGEMENT (static, for admin UI)
1493 // =========================================================================
1494
1495 /**
1496 * Get currently active firewall blocks
1497 *
1498 * Cleans expired entries on each call.
1499 *
1500 * @return array Active blocks keyed by IP address.
1501 */
1502 public static function get_active_blocks() {
1503 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1504 $now = time();
1505 $dirty = false;
1506
1507 foreach ( $blocks as $ip => $data ) {
1508 if ( $now >= $data['expires'] ) {
1509 unset( $blocks[ $ip ] );
1510 $dirty = true;
1511 }
1512 }
1513
1514 if ( $dirty ) {
1515 update_option( 'vigilante_firewall_blocks', $blocks, false );
1516 }
1517
1518 return $blocks;
1519 }
1520
1521 /**
1522 * Manually unblock an IP from rate limit blocks
1523 *
1524 * @param string $ip IP address to unblock.
1525 * @return bool Whether the IP was found and removed.
1526 */
1527 public static function unblock_ip( $ip ) {
1528 $blocks = get_option( 'vigilante_firewall_blocks', array() );
1529
1530 if ( ! isset( $blocks[ $ip ] ) ) {
1531 return false;
1532 }
1533
1534 // The address, and the narrower key the block was kept under, if any
1535 // (a verified visitor of Under Attack mode, since 2.11.8).
1536 $keys = array( $ip );
1537
1538 if ( is_array( $blocks[ $ip ] ) && ! empty( $blocks[ $ip ]['key'] ) && is_string( $blocks[ $ip ]['key'] ) ) {
1539 $keys[] = $blocks[ $ip ]['key'];
1540 }
1541
1542 unset( $blocks[ $ip ] );
1543 update_option( 'vigilante_firewall_blocks', $blocks, false );
1544
1545 // Clean related transients
1546 foreach ( array_unique( $keys ) as $key ) {
1547 $hash = md5( $key );
1548 delete_transient( 'vigilante_rate_block_' . $hash );
1549 delete_transient( 'vigilante_rate_' . $hash );
1550 delete_transient( 'vigilante_strikes_' . $hash );
1551 }
1552
1553 return true;
1554 }
1555 }