PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-firewall.php +619 -108 2.9.72.11.8 View file →
@@ -56,8 +56,17 @@
56 56 */
57 57 private $request_data = array();
58 58
59 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 + /**
60 69 * Constructor
61 70 *
62 71 * @param Vigilante_Settings $settings Settings instance.
63 72 * @param Vigilante_Activity_Log $activity_log Activity log instance.
@@ -84,23 +93,42 @@
84 93 if ( $this->is_ip_whitelisted() ) {
85 94 return;
86 95 }
87 96
88 - // Skip for whitelisted User-Agents (ManageWP, MainWP, etc.)
89 - if ( $this->is_ua_whitelisted() ) {
90 - return;
91 - }
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();
92 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 +
93 122 // Check if IP is blacklisted
94 123 if ( $this->is_ip_blacklisted() ) {
95 124 $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) );
96 125 }
97 126
98 - // Gather request data
99 - $this->gather_request_data();
100 -
101 - // Check if User-Agent is blacklisted (after gathering request data)
102 - if ( $this->is_ua_blacklisted() ) {
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() ) {
103 131 $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) );
104 132 }
105 133
106 134 // Run security checks
@@ -117,9 +145,16 @@
117 145 'block_bad_bots' => 'check_bad_bots',
118 146 'block_empty_user_agent' => 'check_empty_user_agent',
119 147 );
120 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 +
121 152 foreach ( $checks as $option => $method ) {
153 + if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) {
154 + continue;
155 + }
156 +
122 157 if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) {
123 158 $result = $this->$method();
124 159 if ( is_string( $result ) ) {
125 160 $this->block_request( $option, $result );
@@ -136,11 +171,38 @@
136 171 /**
137 172 * Gather current request data
138 173 */
139 174 private function gather_request_data() {
175 + $this->haystack = null;
176 +
140 177 $this->request_data = array(
141 178 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
142 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'] ) : '',
143 205 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
144 206 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
145 207 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET',
146 208 'ip' => $this->get_client_ip(),
@@ -147,19 +209,51 @@
147 209 );
148 210 }
149 211
150 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 + /**
151 241 * Check for malicious query strings
152 242 *
153 243 * @return string|false Error message or false if safe.
154 244 */
155 245 private function check_query_strings() {
156 - $query = $this->request_data['query_string'];
246 + $query = $this->request_data['query_raw'];
157 247
158 248 if ( empty( $query ) ) {
159 249 return false;
160 250 }
161 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 +
162 256 // Dangerous patterns
163 257 $patterns = array(
164 258 // Too long query strings
165 259 '/^.{4000,}$/s' => __( 'Query string too long', 'vigilante' ),
@@ -183,9 +277,13 @@
183 277 '/document\.(cookie|location|write)/i' => __( 'DOM manipulation attempt', 'vigilante' ),
184 278 );
185 279
186 280 foreach ( $patterns as $pattern => $message ) {
187 - if ( preg_match( $pattern, $query ) ) {
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 ) ) {
188 286 return $message;
189 287 }
190 288 }
191 289
@@ -204,10 +302,9 @@
204 302 return false;
205 303 }
206 304
207 305 $to_check = array(
208 - $this->request_data['query_string'],
209 - $this->request_data['uri'],
306 + $this->inspection_haystack(),
210 307 );
211 308
212 309 // Check POST data, but exclude content fields that may contain legitimate code/text
213 310 // phpcs:ignore WordPress.Security.NonceVerification.Missing
@@ -290,21 +387,16 @@
290 387 *
291 388 * @return string|false Error message or false if safe.
292 389 */
293 390 private function check_xss_attacks() {
294 - $to_check = array(
295 - $this->request_data['query_string'],
296 - $this->request_data['uri'],
297 - );
391 + $combined = $this->inspection_haystack();
298 392
299 - $combined = implode( ' ', array_filter( $to_check ) );
300 -
301 393 if ( empty( $combined ) ) {
302 394 return false;
303 395 }
304 396
305 - // URL decode for checking
306 - $decoded = urldecode( $combined );
397 + // Already carries the decoded form, see inspection_haystack().
398 + $decoded = $combined;
307 399
308 400 // XSS patterns
309 401 $patterns = array(
310 402 // Script tags
@@ -309,10 +401,17 @@
309 401 $patterns = array(
310 402 // Script tags
311 403 '/<script[^>]*>/i' => __( 'Script tag detected', 'vigilante' ),
312 404
313 - // Event handlers
314 - '/\bon\w+\s*=/i' => __( 'Event handler detected', 'vigilante' ),
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' ),
315 414
316 415 // JavaScript protocol
317 416 '/javascript\s*:/i' => __( 'JavaScript protocol detected', 'vigilante' ),
318 417
@@ -346,21 +445,31 @@
346 445 *
347 446 * @return string|false Error message or false if safe.
348 447 */
349 448 private function check_file_inclusion() {
350 - $uri = $this->request_data['uri'];
351 - $query = $this->request_data['query_string'];
352 - $combined = $uri . ' ' . $query;
449 + $combined = $this->inspection_haystack();
353 450
354 451 if ( empty( $combined ) ) {
355 452 return false;
356 453 }
357 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 +
358 470 // File inclusion patterns
359 471 $patterns = array(
360 - // Remote file inclusion
361 - '/=\s*(https?|ftp):\/\//i' => __( 'Remote file inclusion attempt', 'vigilante' ),
362 -
363 472 // PHP wrappers
364 473 '/(php|zip|glob|phar|ssh2|rar|ogg|expect):\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
365 474
366 475 // System files
@@ -380,16 +489,284 @@
380 489 return false;
381 490 }
382 491
383 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 + /**
384 763 * Check for directory traversal attacks
385 764 *
386 765 * @return string|false Error message or false if safe.
387 766 */
388 767 private function check_directory_traversal() {
389 - $uri = $this->request_data['uri'];
390 - $query = $this->request_data['query_string'];
391 - $combined = urldecode( $uri . ' ' . $query );
768 + $combined = $this->inspection_haystack();
392 769
393 770 if ( empty( $combined ) ) {
394 771 return false;
395 772 }
@@ -416,9 +793,9 @@
416 793 *
417 794 * @return string|false Error message or false if safe.
418 795 */
419 796 private function check_php_in_uploads() {
420 - $uri = $this->request_data['uri'];
797 + $uri = $this->inspection_haystack();
421 798
422 799 // Check if accessing PHP in uploads directory
423 800 if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) {
424 801 return __( 'PHP execution in uploads blocked', 'vigilante' );
@@ -427,48 +804,8 @@
427 804 return false;
428 805 }
429 806
430 807 /**
431 - * Check for access to sensitive files
432 - *
433 - * @return string|false Error message or false if safe.
434 - */
435 - private function check_sensitive_files() {
436 - $uri = strtolower( $this->request_data['uri'] );
437 -
438 - // Sensitive file patterns
439 - $sensitive_patterns = array(
440 - '/\.htaccess$/i',
441 - '/\.htpasswd$/i',
442 - '/wp-config\.php$/i',
443 - '/wp-config-sample\.php$/i',
444 - '/readme\.html$/i',
445 - '/licen(se|cia)\.txt$/i',
446 - '/xmlrpc\.php$/i', // If XML-RPC is disabled
447 - '/\.git/i',
448 - '/\.svn/i',
449 - '/\.env$/i',
450 - '/composer\.(json|lock)$/i',
451 - '/package(-lock)?\.json$/i',
452 - '/\.sql$/i',
453 - '/\.bak$/i',
454 - '/\.old$/i',
455 - '/\.log$/i',
456 - '/\.ini$/i',
457 - '/debug\.log$/i',
458 - '/error_log$/i',
459 - );
460 -
461 - foreach ( $sensitive_patterns as $pattern ) {
462 - if ( preg_match( $pattern, $uri ) ) {
463 - return __( 'Access to sensitive file blocked', 'vigilante' );
464 - }
465 - }
466 -
467 - return false;
468 - }
469 -
470 - /**
471 808 * Check for bad bots
472 809 *
473 810 * @return string|false Error message or false if safe.
474 811 */
@@ -678,10 +1015,49 @@
678 1015 return false;
679 1016 }
680 1017
681 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 + /**
682 1058 * Check HTTP method
683 - *
1059 + *
684 1060 * Logged-in users with edit capabilities are excluded to ensure
685 1061 * Gutenberg, REST API, and page builders work correctly.
686 1062 */
687 1063 private function check_http_method() {
@@ -693,10 +1069,9 @@
693 1069
694 1070 // Skip for WordPress REST API requests
695 1071 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
696 1072 // authentication and authorization layer — no need to filter methods here
697 - $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
698 - if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
1073 + if ( $this->is_rest_api_request() ) {
699 1074 return;
700 1075 }
701 1076
702 1077 $method = strtoupper( $this->request_data['method'] );
@@ -717,8 +1092,60 @@
717 1092 }
718 1093 }
719 1094
720 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 + /**
721 1148 * Check rate limiting
722 1149 */
723 1150 public function check_rate_limit() {
724 1151 // Skip rate limiting for whitelisted IPs
@@ -730,11 +1157,12 @@
730 1157 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
731 1158 return;
732 1159 }
733 1160
734 - // Allow other modules to opt out — Under Attack mode uses this so that
735 - // visitors who already passed the JS challenge don't burn the
736 - // aggressive 30 req/min cap loading a normal page's assets.
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.
737 1165 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
738 1166 return;
739 1167 }
740 1168
@@ -740,25 +1168,39 @@
740 1168
741 1169 $ip = $this->get_client_ip();
742 1170 $rate_limit = $this->options['rate_limiting'];
743 1171
744 - // Check if already blocked via queryable option (fast path)
745 - $active_blocks = get_option( 'vigilante_firewall_blocks', array() );
746 - if ( isset( $active_blocks[ $ip ] ) ) {
747 - if ( time() < $active_blocks[ $ip ]['expires'] ) {
748 - if ( ! headers_sent() ) {
749 - status_header( 429 );
750 - nocache_headers();
751 - }
752 - wp_die(
753 - esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
754 - esc_html__( 'Too Many Requests', 'vigilante' ),
755 - array( 'response' => 429 )
756 - );
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();
757 1197 }
758 - // Expired — clean up
759 - unset( $active_blocks[ $ip ] );
760 - update_option( 'vigilante_firewall_blocks', $active_blocks, false );
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 + );
761 1203 }
762 1204
763 1205 $max_requests = absint( $rate_limit['requests_per_minute'] );
764 1206
@@ -774,9 +1216,9 @@
774 1216 // silence". A logged-in editor publishing several posts in a row could
775 1217 // pile up 150+ requests while never exceeding 60 in any single minute,
776 1218 // and got a 429. Storing the window start makes the reset explicit
777 1219 // instead of relying on the transient expiring.
778 - $transient_key = 'vigilante_rate_' . md5( $ip );
1220 + $transient_key = 'vigilante_rate_' . $hash;
779 1221 $window = get_transient( $transient_key );
780 1222 $now = time();
781 1223
782 1224 // Counts stored before 2.9.5 were a bare integer with no window start.
@@ -810,9 +1252,9 @@
810 1252 $strikes = 1;
811 1253
812 1254 // Progressive blocking: double duration on each repeat offense
813 1255 if ( ! empty( $rate_limit['progressive'] ) ) {
814 - $strikes_key = 'vigilante_strikes_' . md5( $ip );
1256 + $strikes_key = 'vigilante_strikes_' . $hash;
815 1257 $strikes = absint( get_transient( $strikes_key ) ) + 1;
816 1258
817 1259 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
818 1260 $duration = min(
@@ -823,18 +1265,23 @@
823 1265 // Persist strikes for 24h so they accumulate across blocks
824 1266 set_transient( $strikes_key, $strikes, 86400 );
825 1267 }
826 1268
827 - // Store block in queryable option for admin UI
828 - $active_blocks[ $ip ] = array(
1269 + $block = array(
829 1270 'expires' => time() + $duration,
830 1271 'blocked_at' => time(),
831 1272 'duration' => $duration,
832 1273 'reason' => 'rate_limit',
833 1274 'strikes' => $strikes,
1275 + 'key' => $key,
834 1276 );
835 - update_option( 'vigilante_firewall_blocks', $active_blocks, false );
836 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 +
837 1284 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
838 1285 }
839 1286
840 1287 // The TTL only garbage-collects the payload once the IP goes quiet; what
@@ -856,12 +1303,12 @@
856 1303 'firewall',
857 1304 'blocked',
858 1305 $message,
859 1306 array(
860 - 'reason' => $reason,
861 - 'uri' => $this->request_data['uri'] ?? '',
862 - 'ip' => $this->get_client_ip(),
863 - 'user_agent'=> $this->request_data['user_agent'] ?? '',
1307 + 'reason' => $reason,
1308 + 'request_uri' => $this->loggable_uri(),
1309 + 'ip' => $this->get_client_ip(),
1310 + 'user_agent' => $this->request_data['user_agent'] ?? '',
864 1311 ),
865 1312 'warning'
866 1313 );
867 1314 }
@@ -871,8 +1318,24 @@
871 1318 status_header( $status_code );
872 1319 nocache_headers();
873 1320 }
874 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 +
875 1338 // Return appropriate response
876 1339 if ( 429 === $status_code ) {
877 1340 wp_die(
878 1341 esc_html( $message ),
@@ -888,8 +1351,46 @@
888 1351 );
889 1352 }
890 1353
891 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 + /**
892 1393 * Check if current IP is whitelisted
893 1394 *
894 1395 * @return bool
895 1396 */
@@ -1029,16 +1530,26 @@
1029 1530 if ( ! isset( $blocks[ $ip ] ) ) {
1030 1531 return false;
1031 1532 }
1032 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 +
1033 1542 unset( $blocks[ $ip ] );
1034 1543 update_option( 'vigilante_firewall_blocks', $blocks, false );
1035 1544
1036 1545 // Clean related transients
1037 - $hash = md5( $ip );
1038 - delete_transient( 'vigilante_rate_block_' . $hash );
1039 - delete_transient( 'vigilante_rate_' . $hash );
1040 - delete_transient( 'vigilante_strikes_' . $hash );
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 + }
1041 1552
1042 1553 return true;
1043 1554 }
1044 1555 }