PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
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
← All changes | includes/class-firewall.php +423 -71 2.11.0 → 3.0.3 View file →
@@ -93,23 +93,42 @@
93 93 if ( $this->is_ip_whitelisted() ) {
94 94 return;
95 95 }
96 96
97 - // Skip for whitelisted User-Agents (ManageWP, MainWP, etc.)
98 - if ( $this->is_ua_whitelisted() ) {
99 - return;
100 - }
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();
101 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 +
102 122 // Check if IP is blacklisted
103 123 if ( $this->is_ip_blacklisted() ) {
104 124 $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) );
105 125 }
106 126
107 - // Gather request data
108 - $this->gather_request_data();
109 -
110 - // Check if User-Agent is blacklisted (after gathering request data)
111 - 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() ) {
112 131 $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) );
113 132 }
114 133
115 134 // Run security checks
@@ -126,9 +145,16 @@
126 145 'block_bad_bots' => 'check_bad_bots',
127 146 'block_empty_user_agent' => 'check_empty_user_agent',
128 147 );
129 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 +
130 152 foreach ( $checks as $option => $method ) {
153 + if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) {
154 + continue;
155 + }
156 +
131 157 if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) {
132 158 $result = $this->$method();
133 159 if ( is_string( $result ) ) {
134 160 $this->block_request( $option, $result );
@@ -148,18 +174,19 @@
148 174 private function gather_request_data() {
149 175 $this->haystack = null;
150 176
151 177 $this->request_data = array(
152 - 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
153 - 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '',
154 178 /*
155 179 * Copies that keep the percent encoding, used only as the haystack
156 180 * of the pattern checks and never logged, printed or stored.
157 181 *
158 182 * They exist because sanitize_text_field() deletes every %XX
159 - * sequence instead of decoding it: the copies above are the payload
183 + * sequence instead of decoding it: a sanitized copy is the payload
160 184 * with the evidence removed, so an encoded attack was invisible to
161 - * every rule that reads them. Measured on 22 aug 2026 against 2.9.8,
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,
162 189 * ?x=%3Cscript%3E, javascript%3A, php%3A%2F%2F and GLOBALS%5B all
163 190 * reached the checks as harmless text and went straight through.
164 191 *
165 192 * No sanitizer is applied, and that is the point: every one of them
@@ -165,13 +192,16 @@
165 192 * No sanitizer is applied, and that is the point: every one of them
166 193 * destroys exactly what has to be matched. sanitize_text_field()
167 194 * deletes the %XX sequences and strips tags. esc_url_raw() is worse
168 195 * here: measured on 22 aug 2026, it returns an empty string for a
169 - * query that carries an unencoded :// , which is precisely the
170 - * remote inclusion shape, so it would blind the firewall instead of
171 - * arming it. These two values are never echoed, never stored and
172 - * never reach a query; they are the haystack of preg_match() and
173 - * nothing else.
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.
174 204 */
175 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.
176 206 'uri_raw' => isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '',
177 207 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as uri_raw.
@@ -431,9 +461,14 @@
431 461 // rule, and legitimate links carry those all the time: a redirect_to
432 462 // back to the site itself, a return_url, a payment gateway callback.
433 463 // What makes it an inclusion attempt is the target being somewhere
434 464 // else, so a URL pointing at this very site is left alone.
435 - if ( $this->has_remote_inclusion() ) {
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() ) {
436 471 return __( 'Remote file inclusion attempt', 'vigilante' );
437 472 }
438 473
439 474 // File inclusion patterns
@@ -458,10 +493,66 @@
458 493 return false;
459 494 }
460 495
461 496 /**
462 - * Whether the request carries a URL that points outside this site
497 + * Parameter names a remote inclusion payload travels in
463 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 + *
464 555 * Works on the parsed parameters rather than on a pattern match over the
465 556 * whole string, for two reasons: a link back to the site itself is not
466 557 * mistaken for an attack, and an encoded payload is seen for what it is.
467 558 * The copy of the query string kept for logging goes through
@@ -467,8 +558,18 @@
467 558 * The copy of the query string kept for logging goes through
468 559 * sanitize_text_field(), which strips every %XX sequence instead of
469 560 * decoding it, so the encoded form never looked like a URL there.
470 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 + *
471 572 * @since 2.9.9
472 573 *
473 574 * @return bool
474 575 */
@@ -483,21 +584,13 @@
483 584 // would have put in $_GET, without reading the superglobal.
484 585 $params = array();
485 586 parse_str( $query, $params );
486 587
487 - $values = array();
488 - array_walk_recursive(
489 - $params,
490 - function ( $value ) use ( &$values ) {
491 - if ( is_scalar( $value ) ) {
492 - $values[] = (string) $value;
493 - }
494 - }
495 - );
588 + $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) );
496 589
497 - $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) );
590 + foreach ( $this->flatten_query_params( $params ) as $pair ) {
591 + list( $names, $value ) = $pair;
498 592
499 - foreach ( $values as $value ) {
500 593 if ( ! preg_match_all( '/(?:https?|ftp):\/\/[^\s\'"<>]+/i', $value, $matches ) ) {
501 594 continue;
502 595 }
503 596
@@ -503,9 +596,14 @@
503 596
504 597 foreach ( $matches[0] as $url ) {
505 598 $host = $this->normalize_host( wp_parse_url( $url, PHP_URL_HOST ) );
506 599
507 - if ( '' === $host || $host !== $home_host ) {
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 ) ) {
508 606 return true;
509 607 }
510 608 }
511 609 }
@@ -513,8 +611,146 @@
513 611 return false;
514 612 }
515 613
516 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 + /**
517 753 * Host in a comparable form: lowercase and without a leading www.
518 754 *
519 755 * @since 2.9.9
520 756 *
@@ -556,24 +792,8 @@
556 792 return false;
557 793 }
558 794
559 795 /**
560 - * Check for PHP execution in uploads
561 - *
562 - * @return string|false Error message or false if safe.
563 - */
564 - private function check_php_in_uploads() {
565 - $uri = $this->inspection_haystack();
566 -
567 - // Check if accessing PHP in uploads directory
568 - if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) {
569 - return __( 'PHP execution in uploads blocked', 'vigilante' );
570 - }
571 -
572 - return false;
573 - }
574 -
575 - /**
576 796 * Check for bad bots
577 797 *
578 798 * @return string|false Error message or false if safe.
579 799 */
@@ -760,10 +980,10 @@
760 980
761 981 foreach ( $bad_bots as $bot ) {
762 982 if ( strpos( $user_agent, $bot ) !== false ) {
763 983 return sprintf(
764 - /* translators: %s: Bot name */
765 - __( 'Bad bot blocked: %s', 'vigilante' ),
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' ),
766 986 $bot
767 987 );
768 988 }
769 989 }
@@ -783,10 +1003,49 @@
783 1003 return false;
784 1004 }
785 1005
786 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 + /**
787 1046 * Check HTTP method
788 - *
1047 + *
789 1048 * Logged-in users with edit capabilities are excluded to ensure
790 1049 * Gutenberg, REST API, and page builders work correctly.
791 1050 */
792 1051 private function check_http_method() {
@@ -798,17 +1057,31 @@
798 1057
799 1058 // Skip for WordPress REST API requests
800 1059 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
801 1060 // authentication and authorization layer — no need to filter methods here
802 - $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
803 - if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
1061 + if ( $this->is_rest_api_request() ) {
804 1062 return;
805 1063 }
806 1064
807 1065 $method = strtoupper( $this->request_data['method'] );
808 - $allowed_methods = isset( $this->options['allowed_http_methods'] )
809 - ? $this->options['allowed_http_methods']
810 - : array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' );
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 + );
811 1084 $allowed = array_map( 'strtoupper', $allowed_methods );
812 1085
813 1086 if ( ! in_array( $method, $allowed, true ) ) {
814 1087 $this->block_request(
@@ -887,11 +1160,12 @@
887 1160 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
888 1161 return;
889 1162 }
890 1163
891 - // Allow other modules to opt out — Under Attack mode uses this so that
892 - // visitors who already passed the JS challenge don't burn the
893 - // aggressive 30 req/min cap loading a normal page's assets.
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.
894 1168 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
895 1169 return;
896 1170 }
897 1171
@@ -897,8 +1171,21 @@
897 1171
898 1172 $ip = $this->get_client_ip();
899 1173 $rate_limit = $this->options['rate_limiting'];
900 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 +
901 1188 // Check if already blocked (fast path). The active block lives in a
902 1189 // transient keyed by IP, so this path, which runs on every
903 1190 // unauthenticated request, reads one row and not the whole index of
904 1191 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
@@ -904,9 +1191,9 @@
904 1191 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
905 1192 // entire, an array with no upper bound that a distributed attack grew
906 1193 // by one entry per new address, so the firewall amplified the attack it
907 1194 // was blocking (S6). The transient expires with the block itself.
908 - $block = get_transient( 'vigilante_rate_block_' . md5( $ip ) );
1195 + $block = get_transient( 'vigilante_rate_block_' . $hash );
909 1196 if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) {
910 1197 if ( ! headers_sent() ) {
911 1198 status_header( 429 );
912 1199 nocache_headers();
@@ -932,9 +1219,9 @@
932 1219 // silence". A logged-in editor publishing several posts in a row could
933 1220 // pile up 150+ requests while never exceeding 60 in any single minute,
934 1221 // and got a 429. Storing the window start makes the reset explicit
935 1222 // instead of relying on the transient expiring.
936 - $transient_key = 'vigilante_rate_' . md5( $ip );
1223 + $transient_key = 'vigilante_rate_' . $hash;
937 1224 $window = get_transient( $transient_key );
938 1225 $now = time();
939 1226
940 1227 // Counts stored before 2.9.5 were a bare integer with no window start.
@@ -968,9 +1255,9 @@
968 1255 $strikes = 1;
969 1256
970 1257 // Progressive blocking: double duration on each repeat offense
971 1258 if ( ! empty( $rate_limit['progressive'] ) ) {
972 - $strikes_key = 'vigilante_strikes_' . md5( $ip );
1259 + $strikes_key = 'vigilante_strikes_' . $hash;
973 1260 $strikes = absint( get_transient( $strikes_key ) ) + 1;
974 1261
975 1262 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
976 1263 $duration = min(
@@ -987,12 +1274,13 @@
987 1274 'blocked_at' => time(),
988 1275 'duration' => $duration,
989 1276 'reason' => 'rate_limit',
990 1277 'strikes' => $strikes,
1278 + 'key' => $key,
991 1279 );
992 1280
993 1281 // The block itself, read by the fast path above on every request.
994 - set_transient( 'vigilante_rate_block_' . md5( $ip ), $block, $duration );
1282 + set_transient( 'vigilante_rate_block_' . $hash, $block, $duration );
995 1283
996 1284 // The bounded index the admin screen lists.
997 1285 self::index_block( $ip, $block );
998 1286
@@ -1018,12 +1306,12 @@
1018 1306 'firewall',
1019 1307 'blocked',
1020 1308 $message,
1021 1309 array(
1022 - 'reason' => $reason,
1023 - 'uri' => $this->request_data['uri'] ?? '',
1024 - 'ip' => $this->get_client_ip(),
1025 - 'user_agent'=> $this->request_data['user_agent'] ?? '',
1310 + 'reason' => $reason,
1311 + 'request_uri' => $this->loggable_uri(),
1312 + 'ip' => $this->get_client_ip(),
1313 + 'user_agent' => $this->request_data['user_agent'] ?? '',
1026 1314 ),
1027 1315 'warning'
1028 1316 );
1029 1317 }
@@ -1033,8 +1321,24 @@
1033 1321 status_header( $status_code );
1034 1322 nocache_headers();
1035 1323 }
1036 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 +
1037 1341 // Return appropriate response
1038 1342 if ( 429 === $status_code ) {
1039 1343 wp_die(
1040 1344 esc_html( $message ),
@@ -1050,8 +1354,46 @@
1050 1354 );
1051 1355 }
1052 1356
1053 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 + /**
1054 1396 * Check if current IP is whitelisted
1055 1397 *
1056 1398 * @return bool
1057 1399 */
@@ -1191,16 +1533,26 @@
1191 1533 if ( ! isset( $blocks[ $ip ] ) ) {
1192 1534 return false;
1193 1535 }
1194 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 +
1195 1545 unset( $blocks[ $ip ] );
1196 1546 update_option( 'vigilante_firewall_blocks', $blocks, false );
1197 1547
1198 1548 // Clean related transients
1199 - $hash = md5( $ip );
1200 - delete_transient( 'vigilante_rate_block_' . $hash );
1201 - delete_transient( 'vigilante_rate_' . $hash );
1202 - delete_transient( 'vigilante_strikes_' . $hash );
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 + }
1203 1555
1204 1556 return true;
1205 1557 }
1206 1558 }