PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
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 +662 -118 2.9.42.11.12 View file →
@@ -19,8 +19,17 @@
19 19 */
20 20 class Vigilante_Firewall {
21 21
22 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 + /**
23 32 * Settings instance
24 33 *
25 34 * @var Vigilante_Settings
26 35 */
@@ -47,8 +56,17 @@
47 56 */
48 57 private $request_data = array();
49 58
50 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 + /**
51 69 * Constructor
52 70 *
53 71 * @param Vigilante_Settings $settings Settings instance.
54 72 * @param Vigilante_Activity_Log $activity_log Activity log instance.
@@ -75,23 +93,42 @@
75 93 if ( $this->is_ip_whitelisted() ) {
76 94 return;
77 95 }
78 96
79 - // Skip for whitelisted User-Agents (ManageWP, MainWP, etc.)
80 - if ( $this->is_ua_whitelisted() ) {
81 - return;
82 - }
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();
83 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 +
84 122 // Check if IP is blacklisted
85 123 if ( $this->is_ip_blacklisted() ) {
86 124 $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) );
87 125 }
88 126
89 - // Gather request data
90 - $this->gather_request_data();
91 -
92 - // Check if User-Agent is blacklisted (after gathering request data)
93 - 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() ) {
94 131 $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) );
95 132 }
96 133
97 134 // Run security checks
@@ -108,9 +145,16 @@
108 145 'block_bad_bots' => 'check_bad_bots',
109 146 'block_empty_user_agent' => 'check_empty_user_agent',
110 147 );
111 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 +
112 152 foreach ( $checks as $option => $method ) {
153 + if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) {
154 + continue;
155 + }
156 +
113 157 if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) {
114 158 $result = $this->$method();
115 159 if ( is_string( $result ) ) {
116 160 $this->block_request( $option, $result );
@@ -127,11 +171,38 @@
127 171 /**
128 172 * Gather current request data
129 173 */
130 174 private function gather_request_data() {
175 + $this->haystack = null;
176 +
131 177 $this->request_data = array(
132 178 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
133 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'] ) : '',
134 205 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
135 206 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
136 207 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET',
137 208 'ip' => $this->get_client_ip(),
@@ -138,19 +209,51 @@
138 209 );
139 210 }
140 211
141 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 + /**
142 241 * Check for malicious query strings
143 242 *
144 243 * @return string|false Error message or false if safe.
145 244 */
146 245 private function check_query_strings() {
147 - $query = $this->request_data['query_string'];
246 + $query = $this->request_data['query_raw'];
148 247
149 248 if ( empty( $query ) ) {
150 249 return false;
151 250 }
152 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 +
153 256 // Dangerous patterns
154 257 $patterns = array(
155 258 // Too long query strings
156 259 '/^.{4000,}$/s' => __( 'Query string too long', 'vigilante' ),
@@ -174,9 +277,13 @@
174 277 '/document\.(cookie|location|write)/i' => __( 'DOM manipulation attempt', 'vigilante' ),
175 278 );
176 279
177 280 foreach ( $patterns as $pattern => $message ) {
178 - 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 ) ) {
179 286 return $message;
180 287 }
181 288 }
182 289
@@ -195,10 +302,9 @@
195 302 return false;
196 303 }
197 304
198 305 $to_check = array(
199 - $this->request_data['query_string'],
200 - $this->request_data['uri'],
306 + $this->inspection_haystack(),
201 307 );
202 308
203 309 // Check POST data, but exclude content fields that may contain legitimate code/text
204 310 // phpcs:ignore WordPress.Security.NonceVerification.Missing
@@ -281,21 +387,16 @@
281 387 *
282 388 * @return string|false Error message or false if safe.
283 389 */
284 390 private function check_xss_attacks() {
285 - $to_check = array(
286 - $this->request_data['query_string'],
287 - $this->request_data['uri'],
288 - );
391 + $combined = $this->inspection_haystack();
289 392
290 - $combined = implode( ' ', array_filter( $to_check ) );
291 -
292 393 if ( empty( $combined ) ) {
293 394 return false;
294 395 }
295 396
296 - // URL decode for checking
297 - $decoded = urldecode( $combined );
397 + // Already carries the decoded form, see inspection_haystack().
398 + $decoded = $combined;
298 399
299 400 // XSS patterns
300 401 $patterns = array(
301 402 // Script tags
@@ -300,10 +401,17 @@
300 401 $patterns = array(
301 402 // Script tags
302 403 '/<script[^>]*>/i' => __( 'Script tag detected', 'vigilante' ),
303 404
304 - // Event handlers
305 - '/\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' ),
306 414
307 415 // JavaScript protocol
308 416 '/javascript\s*:/i' => __( 'JavaScript protocol detected', 'vigilante' ),
309 417
@@ -337,21 +445,31 @@
337 445 *
338 446 * @return string|false Error message or false if safe.
339 447 */
340 448 private function check_file_inclusion() {
341 - $uri = $this->request_data['uri'];
342 - $query = $this->request_data['query_string'];
343 - $combined = $uri . ' ' . $query;
449 + $combined = $this->inspection_haystack();
344 450
345 451 if ( empty( $combined ) ) {
346 452 return false;
347 453 }
348 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 +
349 470 // File inclusion patterns
350 471 $patterns = array(
351 - // Remote file inclusion
352 - '/=\s*(https?|ftp):\/\//i' => __( 'Remote file inclusion attempt', 'vigilante' ),
353 -
354 472 // PHP wrappers
355 473 '/(php|zip|glob|phar|ssh2|rar|ogg|expect):\/\//i' => __( 'PHP wrapper detected', 'vigilante' ),
356 474
357 475 // System files
@@ -371,16 +489,284 @@
371 489 return false;
372 490 }
373 491
374 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 + /**
375 763 * Check for directory traversal attacks
376 764 *
377 765 * @return string|false Error message or false if safe.
378 766 */
379 767 private function check_directory_traversal() {
380 - $uri = $this->request_data['uri'];
381 - $query = $this->request_data['query_string'];
382 - $combined = urldecode( $uri . ' ' . $query );
768 + $combined = $this->inspection_haystack();
383 769
384 770 if ( empty( $combined ) ) {
385 771 return false;
386 772 }
@@ -407,9 +793,9 @@
407 793 *
408 794 * @return string|false Error message or false if safe.
409 795 */
410 796 private function check_php_in_uploads() {
411 - $uri = $this->request_data['uri'];
797 + $uri = $this->inspection_haystack();
412 798
413 799 // Check if accessing PHP in uploads directory
414 800 if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) {
415 801 return __( 'PHP execution in uploads blocked', 'vigilante' );
@@ -418,48 +804,8 @@
418 804 return false;
419 805 }
420 806
421 807 /**
422 - * Check for access to sensitive files
423 - *
424 - * @return string|false Error message or false if safe.
425 - */
426 - private function check_sensitive_files() {
427 - $uri = strtolower( $this->request_data['uri'] );
428 -
429 - // Sensitive file patterns
430 - $sensitive_patterns = array(
431 - '/\.htaccess$/i',
432 - '/\.htpasswd$/i',
433 - '/wp-config\.php$/i',
434 - '/wp-config-sample\.php$/i',
435 - '/readme\.html$/i',
436 - '/licen(se|cia)\.txt$/i',
437 - '/xmlrpc\.php$/i', // If XML-RPC is disabled
438 - '/\.git/i',
439 - '/\.svn/i',
440 - '/\.env$/i',
441 - '/composer\.(json|lock)$/i',
442 - '/package(-lock)?\.json$/i',
443 - '/\.sql$/i',
444 - '/\.bak$/i',
445 - '/\.old$/i',
446 - '/\.log$/i',
447 - '/\.ini$/i',
448 - '/debug\.log$/i',
449 - '/error_log$/i',
450 - );
451 -
452 - foreach ( $sensitive_patterns as $pattern ) {
453 - if ( preg_match( $pattern, $uri ) ) {
454 - return __( 'Access to sensitive file blocked', 'vigilante' );
455 - }
456 - }
457 -
458 - return false;
459 - }
460 -
461 - /**
462 808 * Check for bad bots
463 809 *
464 810 * @return string|false Error message or false if safe.
465 811 */
@@ -669,10 +1015,49 @@
669 1015 return false;
670 1016 }
671 1017
672 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 + /**
673 1058 * Check HTTP method
674 - *
1059 + *
675 1060 * Logged-in users with edit capabilities are excluded to ensure
676 1061 * Gutenberg, REST API, and page builders work correctly.
677 1062 */
678 1063 private function check_http_method() {
@@ -684,10 +1069,9 @@
684 1069
685 1070 // Skip for WordPress REST API requests
686 1071 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
687 1072 // authentication and authorization layer — no need to filter methods here
688 - $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
689 - if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
1073 + if ( $this->is_rest_api_request() ) {
690 1074 return;
691 1075 }
692 1076
693 1077 $method = strtoupper( $this->request_data['method'] );
@@ -708,8 +1092,60 @@
708 1092 }
709 1093 }
710 1094
711 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 + /**
712 1148 * Check rate limiting
713 1149 */
714 1150 public function check_rate_limit() {
715 1151 // Skip rate limiting for whitelisted IPs
@@ -721,11 +1157,12 @@
721 1157 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
722 1158 return;
723 1159 }
724 1160
725 - // Allow other modules to opt out — Under Attack mode uses this so that
726 - // visitors who already passed the JS challenge don't burn the
727 - // 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.
728 1165 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
729 1166 return;
730 1167 }
731 1168
@@ -731,25 +1168,39 @@
731 1168
732 1169 $ip = $this->get_client_ip();
733 1170 $rate_limit = $this->options['rate_limiting'];
734 1171
735 - // Check if already blocked via queryable option (fast path)
736 - $active_blocks = get_option( 'vigilante_firewall_blocks', array() );
737 - if ( isset( $active_blocks[ $ip ] ) ) {
738 - if ( time() < $active_blocks[ $ip ]['expires'] ) {
739 - if ( ! headers_sent() ) {
740 - status_header( 429 );
741 - nocache_headers();
742 - }
743 - wp_die(
744 - esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
745 - esc_html__( 'Too Many Requests', 'vigilante' ),
746 - array( 'response' => 429 )
747 - );
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();
748 1197 }
749 - // Expired — clean up
750 - unset( $active_blocks[ $ip ] );
751 - 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 + );
752 1203 }
753 1204
754 1205 $max_requests = absint( $rate_limit['requests_per_minute'] );
755 1206
@@ -755,21 +1206,44 @@
755 1206
756 1207 // Allow Under Attack mode (or other filters) to override threshold
757 1208 $max_requests = absint( apply_filters( 'vigilante_rate_limit_requests', $max_requests ) );
758 1209
759 - // Use transients for request counting (1 minute window)
760 - $transient_key = 'vigilante_rate_' . md5( $ip );
761 - $request_count = get_transient( $transient_key );
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();
762 1223
763 - if ( false === $request_count ) {
764 - // First request in this window
765 - set_transient( $transient_key, 1, 60 );
766 - return;
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 + );
767 1231 }
768 1232
769 - $request_count = absint( $request_count );
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 + }
770 1240
771 - if ( $request_count >= $max_requests ) {
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 ) {
772 1246 $base_duration = absint( $rate_limit['block_duration'] );
773 1247
774 1248 // Allow Under Attack mode (or other filters) to override duration
775 1249 $base_duration = absint( apply_filters( 'vigilante_rate_limit_duration', $base_duration ) );
@@ -778,9 +1252,9 @@
778 1252 $strikes = 1;
779 1253
780 1254 // Progressive blocking: double duration on each repeat offense
781 1255 if ( ! empty( $rate_limit['progressive'] ) ) {
782 - $strikes_key = 'vigilante_strikes_' . md5( $ip );
1256 + $strikes_key = 'vigilante_strikes_' . $hash;
783 1257 $strikes = absint( get_transient( $strikes_key ) ) + 1;
784 1258
785 1259 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
786 1260 $duration = min(
@@ -791,23 +1265,29 @@
791 1265 // Persist strikes for 24h so they accumulate across blocks
792 1266 set_transient( $strikes_key, $strikes, 86400 );
793 1267 }
794 1268
795 - // Store block in queryable option for admin UI
796 - $active_blocks[ $ip ] = array(
1269 + $block = array(
797 1270 'expires' => time() + $duration,
798 1271 'blocked_at' => time(),
799 1272 'duration' => $duration,
800 1273 'reason' => 'rate_limit',
801 1274 'strikes' => $strikes,
1275 + 'key' => $key,
802 1276 );
803 - update_option( 'vigilante_firewall_blocks', $active_blocks, false );
804 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 +
805 1284 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
806 1285 }
807 1286
808 - // Increment counter
809 - set_transient( $transient_key, $request_count + 1, 60 );
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 );
810 1290 }
811 1291
812 1292 /**
813 1293 * Block a request
@@ -823,12 +1303,12 @@
823 1303 'firewall',
824 1304 'blocked',
825 1305 $message,
826 1306 array(
827 - 'reason' => $reason,
828 - 'uri' => $this->request_data['uri'] ?? '',
829 - 'ip' => $this->get_client_ip(),
830 - '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'] ?? '',
831 1311 ),
832 1312 'warning'
833 1313 );
834 1314 }
@@ -838,8 +1318,24 @@
838 1318 status_header( $status_code );
839 1319 nocache_headers();
840 1320 }
841 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 +
842 1338 // Return appropriate response
843 1339 if ( 429 === $status_code ) {
844 1340 wp_die(
845 1341 esc_html( $message ),
@@ -855,8 +1351,46 @@
855 1351 );
856 1352 }
857 1353
858 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 + /**
859 1393 * Check if current IP is whitelisted
860 1394 *
861 1395 * @return bool
862 1396 */
@@ -996,16 +1530,26 @@
996 1530 if ( ! isset( $blocks[ $ip ] ) ) {
997 1531 return false;
998 1532 }
999 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 +
1000 1542 unset( $blocks[ $ip ] );
1001 1543 update_option( 'vigilante_firewall_blocks', $blocks, false );
1002 1544
1003 1545 // Clean related transients
1004 - $hash = md5( $ip );
1005 - delete_transient( 'vigilante_rate_block_' . $hash );
1006 - delete_transient( 'vigilante_rate_' . $hash );
1007 - 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 + }
1008 1552
1009 1553 return true;
1010 1554 }
1011 1555 }