PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
3.0.5 3.0.4 3.0.3 3.0.2 3.0.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 All 93 releases
vigilante / includes / class-firewall.php

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

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