PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
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 +495 -70 2.10.5 → 3.0.2 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 *
@@ -783,10 +1019,49 @@
783 1019 return false;
784 1020 }
785 1021
786 1022 /**
1023 + * Whether the request is addressed to the REST API itself
1024 + *
1025 + * The method filter lets the REST API through, and until 2.11.8 it asked
1026 + * whether "/wp-json/" appeared anywhere in the address, query string
1027 + * included: TRACE /?x=/wp-json/ skipped the filter and reached a page that
1028 + * is not the REST API at all. Found by the audit of the firewall for
1029 + * 2.11.8. Core routes the pretty REST URLs from the start of the home
1030 + * path, directly or through index.php, so the path has to start there.
1031 + * The ?rest_route= form never matched the old test and still does not:
1032 + * widening the exemption was not the point.
1033 + *
1034 + * @since 2.11.8
1035 + *
1036 + * @return bool
1037 + */
1038 + private function is_rest_api_request() {
1039 + /*
1040 + * The path is cut by hand, not with wp_parse_url(): with two leading
1041 + * slashes that reads "//wp-json/..." as a host, and WordPress still routes
1042 + * it to the REST API. And both the home path and the root are accepted,
1043 + * for the language folders some multilingual plugins add to home_url().
1044 + * Both from the cross review of 2.11.8.
1045 + */
1046 + $path = preg_replace( '#/{2,}#', '/', (string) preg_replace( '/[?#].*$/s', '', (string) ( $this->request_data['uri_raw'] ?? '' ) ) );
1047 + $home = trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) );
1048 + $prefix = trim( rest_get_url_prefix(), '/' );
1049 +
1050 + foreach ( array_unique( array( $home, '/' ) ) as $root ) {
1051 + foreach ( array( $root . $prefix, $root . 'index.php/' . $prefix ) as $base ) {
1052 + if ( $path === $base || 0 === strpos( (string) $path, $base . '/' ) ) {
1053 + return true;
1054 + }
1055 + }
1056 + }
1057 +
1058 + return false;
1059 + }
1060 +
1061 + /**
787 1062 * Check HTTP method
788 - *
1063 + *
789 1064 * Logged-in users with edit capabilities are excluded to ensure
790 1065 * Gutenberg, REST API, and page builders work correctly.
791 1066 */
792 1067 private function check_http_method() {
@@ -798,17 +1073,31 @@
798 1073
799 1074 // Skip for WordPress REST API requests
800 1075 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
801 1076 // 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 . '/' ) ) {
1077 + if ( $this->is_rest_api_request() ) {
804 1078 return;
805 1079 }
806 1080
807 1081 $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' );
1082 + /*
1083 + * An empty list here means "nothing was stored", not "the site wants
1084 + * none". These four lists have no field on any screen, so nobody can
1085 + * empty them on purpose, and every version up to 3.0.0 blanked them in
1086 + * the database whenever their tab was saved. Reading the settings used
1087 + * to hide it by merging the shipped values over the top; since 3.0.1 a
1088 + * stored empty list is respected, which is what makes unticking every
1089 + * role work. The migration in vigilante.php removes the blanked keys
1090 + * once, and this is the second line of defence: a list that arrives
1091 + * empty by any other route falls back to what ships, instead of
1092 + * blocking every HTTP method, hiding a protection that the screen says
1093 + * is on, or cutting the public REST API. The list itself lives in
1094 + * get_default_options(), read through list_or_shipped().
1095 + */
1096 + $allowed_methods = Vigilante_Settings::list_or_shipped(
1097 + $this->options['allowed_http_methods'] ?? array(),
1098 + array( 'firewall', 'allowed_http_methods' )
1099 + );
811 1100 $allowed = array_map( 'strtoupper', $allowed_methods );
812 1101
813 1102 if ( ! in_array( $method, $allowed, true ) ) {
814 1103 $this->block_request(
@@ -822,8 +1111,60 @@
822 1111 }
823 1112 }
824 1113
825 1114 /**
1115 + * Upper bound of the vigilante_firewall_blocks index
1116 + *
1117 + * The index only feeds the admin screen; enforcement reads a transient per
1118 + * IP. Under a distributed attack the oldest entries are dropped first, so
1119 + * the option cannot grow without limit (S6).
1120 + *
1121 + * @since 2.11.0
1122 + */
1123 + const MAX_TRACKED_BLOCKS = 500;
1124 +
1125 + /**
1126 + * Add a block to the bounded admin index
1127 + *
1128 + * Prunes expired entries on every write, not only when an administrator
1129 + * opens the Firewall tab, and keeps at most MAX_TRACKED_BLOCKS entries,
1130 + * dropping the oldest by blocked_at.
1131 + *
1132 + * @since 2.11.0
1133 + *
1134 + * @param string $ip Blocked address.
1135 + * @param array $block Block data (expires, blocked_at, duration, reason, strikes).
1136 + */
1137 + private static function index_block( $ip, $block ) {
1138 + $blocks = get_option( 'vigilante_firewall_blocks', array() );
1139 + $now = time();
1140 +
1141 + if ( ! is_array( $blocks ) ) {
1142 + $blocks = array();
1143 + }
1144 +
1145 + foreach ( $blocks as $blocked_ip => $data ) {
1146 + if ( ! is_array( $data ) || ! isset( $data['expires'] ) || $now >= (int) $data['expires'] ) {
1147 + unset( $blocks[ $blocked_ip ] );
1148 + }
1149 + }
1150 +
1151 + $blocks[ $ip ] = $block;
1152 +
1153 + if ( count( $blocks ) > self::MAX_TRACKED_BLOCKS ) {
1154 + uasort(
1155 + $blocks,
1156 + static function ( $a, $b ) {
1157 + return (int) ( $a['blocked_at'] ?? 0 ) <=> (int) ( $b['blocked_at'] ?? 0 );
1158 + }
1159 + );
1160 + $blocks = array_slice( $blocks, count( $blocks ) - self::MAX_TRACKED_BLOCKS, null, true );
1161 + }
1162 +
1163 + update_option( 'vigilante_firewall_blocks', $blocks, false );
1164 + }
1165 +
1166 + /**
826 1167 * Check rate limiting
827 1168 */
828 1169 public function check_rate_limit() {
829 1170 // Skip rate limiting for whitelisted IPs
@@ -835,11 +1176,12 @@
835 1176 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
836 1177 return;
837 1178 }
838 1179
839 - // Allow other modules to opt out — Under Attack mode uses this so that
840 - // visitors who already passed the JS challenge don't burn the
841 - // aggressive 30 req/min cap loading a normal page's assets.
1180 + // Allow other code to opt out. Under Attack mode used this until 2.11.8
1181 + // to exempt visitors who had passed the JS challenge, which exempted a
1182 + // bot that solved it once, too; it now raises their limit instead,
1183 + // through vigilante_rate_limit_requests below.
842 1184 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
843 1185 return;
844 1186 }
845 1187
@@ -845,25 +1187,39 @@
845 1187
846 1188 $ip = $this->get_client_ip();
847 1189 $rate_limit = $this->options['rate_limiting'];
848 1190
849 - // Check if already blocked via queryable option (fast path)
850 - $active_blocks = get_option( 'vigilante_firewall_blocks', array() );
851 - if ( isset( $active_blocks[ $ip ] ) ) {
852 - if ( time() < $active_blocks[ $ip ]['expires'] ) {
853 - if ( ! headers_sent() ) {
854 - status_header( 429 );
855 - nocache_headers();
856 - }
857 - wp_die(
858 - esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
859 - esc_html__( 'Too Many Requests', 'vigilante' ),
860 - array( 'response' => 429 )
861 - );
1191 + /*
1192 + * What the count and the block are kept under: the address, unless a
1193 + * filter narrows it. Under Attack mode gives visitors who passed its
1194 + * challenge a count of their own, because counting them with everybody
1195 + * else at their address let one unverified client behind the same NAT
1196 + * lock them out for fifteen minutes with its own block. Found by the
1197 + * cross review of 2.11.8, the same shape as the challenge nonce the
1198 + * automated review reported on 2.11.7.
1199 + */
1200 + $key = (string) apply_filters( 'vigilante_rate_limit_key', $ip );
1201 + $key = '' !== $key ? $key : $ip;
1202 + $hash = md5( $key );
1203 +
1204 + // Check if already blocked (fast path). The active block lives in a
1205 + // transient keyed by IP, so this path, which runs on every
1206 + // unauthenticated request, reads one row and not the whole index of
1207 + // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
1208 + // entire, an array with no upper bound that a distributed attack grew
1209 + // by one entry per new address, so the firewall amplified the attack it
1210 + // was blocking (S6). The transient expires with the block itself.
1211 + $block = get_transient( 'vigilante_rate_block_' . $hash );
1212 + if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) {
1213 + if ( ! headers_sent() ) {
1214 + status_header( 429 );
1215 + nocache_headers();
862 1216 }
863 - // Expired — clean up
864 - unset( $active_blocks[ $ip ] );
865 - update_option( 'vigilante_firewall_blocks', $active_blocks, false );
1217 + wp_die(
1218 + esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ),
1219 + esc_html__( 'Too Many Requests', 'vigilante' ),
1220 + array( 'response' => 429 )
1221 + );
866 1222 }
867 1223
868 1224 $max_requests = absint( $rate_limit['requests_per_minute'] );
869 1225
@@ -879,9 +1235,9 @@
879 1235 // silence". A logged-in editor publishing several posts in a row could
880 1236 // pile up 150+ requests while never exceeding 60 in any single minute,
881 1237 // and got a 429. Storing the window start makes the reset explicit
882 1238 // instead of relying on the transient expiring.
883 - $transient_key = 'vigilante_rate_' . md5( $ip );
1239 + $transient_key = 'vigilante_rate_' . $hash;
884 1240 $window = get_transient( $transient_key );
885 1241 $now = time();
886 1242
887 1243 // Counts stored before 2.9.5 were a bare integer with no window start.
@@ -915,9 +1271,9 @@
915 1271 $strikes = 1;
916 1272
917 1273 // Progressive blocking: double duration on each repeat offense
918 1274 if ( ! empty( $rate_limit['progressive'] ) ) {
919 - $strikes_key = 'vigilante_strikes_' . md5( $ip );
1275 + $strikes_key = 'vigilante_strikes_' . $hash;
920 1276 $strikes = absint( get_transient( $strikes_key ) ) + 1;
921 1277
922 1278 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
923 1279 $duration = min(
@@ -928,18 +1284,23 @@
928 1284 // Persist strikes for 24h so they accumulate across blocks
929 1285 set_transient( $strikes_key, $strikes, 86400 );
930 1286 }
931 1287
932 - // Store block in queryable option for admin UI
933 - $active_blocks[ $ip ] = array(
1288 + $block = array(
934 1289 'expires' => time() + $duration,
935 1290 'blocked_at' => time(),
936 1291 'duration' => $duration,
937 1292 'reason' => 'rate_limit',
938 1293 'strikes' => $strikes,
1294 + 'key' => $key,
939 1295 );
940 - update_option( 'vigilante_firewall_blocks', $active_blocks, false );
941 1296
1297 + // The block itself, read by the fast path above on every request.
1298 + set_transient( 'vigilante_rate_block_' . $hash, $block, $duration );
1299 +
1300 + // The bounded index the admin screen lists.
1301 + self::index_block( $ip, $block );
1302 +
942 1303 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
943 1304 }
944 1305
945 1306 // The TTL only garbage-collects the payload once the IP goes quiet; what
@@ -961,12 +1322,12 @@
961 1322 'firewall',
962 1323 'blocked',
963 1324 $message,
964 1325 array(
965 - 'reason' => $reason,
966 - 'uri' => $this->request_data['uri'] ?? '',
967 - 'ip' => $this->get_client_ip(),
968 - 'user_agent'=> $this->request_data['user_agent'] ?? '',
1326 + 'reason' => $reason,
1327 + 'request_uri' => $this->loggable_uri(),
1328 + 'ip' => $this->get_client_ip(),
1329 + 'user_agent' => $this->request_data['user_agent'] ?? '',
969 1330 ),
970 1331 'warning'
971 1332 );
972 1333 }
@@ -976,8 +1337,24 @@
976 1337 status_header( $status_code );
977 1338 nocache_headers();
978 1339 }
979 1340
1341 + // A REST client gets the refusal in the shape it can read. Until
1342 + // 2.11.0 every block answered with the HTML "Forbidden" page, so the
1343 + // block editor could only show its own generic message and the reason
1344 + // was reachable only by opening the activity log. Same status code,
1345 + // same message, the envelope core uses for an error.
1346 + if ( '' !== $this->current_rest_route() ) {
1347 + wp_send_json(
1348 + array(
1349 + 'code' => 'vigilante_firewall_blocked',
1350 + 'message' => $message,
1351 + 'data' => array( 'status' => $status_code ),
1352 + ),
1353 + $status_code
1354 + );
1355 + }
1356 +
980 1357 // Return appropriate response
981 1358 if ( 429 === $status_code ) {
982 1359 wp_die(
983 1360 esc_html( $message ),
@@ -993,8 +1370,46 @@
993 1370 );
994 1371 }
995 1372
996 1373 /**
1374 + * Upper bound of the address stored with a logged block
1375 + *
1376 + * @since 2.11.1
1377 + */
1378 + const MAX_LOGGED_URI = 512;
1379 +
1380 + /**
1381 + * The blocked address, in a form that still says what was blocked
1382 + *
1383 + * The copy kept for logging goes through sanitize_text_field(), which
1384 + * deletes every %XX sequence instead of decoding it. A browser percent
1385 + * encodes the URL it puts in a parameter, so a blocked embed was recorded
1386 + * as "/wp-json/oembed/1.0/proxy?url=httpswww.youtube.comwatchv..." and the
1387 + * owner could not tell what the request had been. Reported on 9 sep 2026.
1388 + *
1389 + * Nothing is sanitized away here beyond control characters, and that is
1390 + * the point. The value is stored, never executed: it is escaped where it
1391 + * is shown, by escapeHtml() in the log detail and by csvCell() in the
1392 + * export. Invalid UTF-8 is stripped because wp_json_encode() returns false
1393 + * on it, which would have thrown away the whole entry's context.
1394 + *
1395 + * @since 2.11.1
1396 + *
1397 + * @return string
1398 + */
1399 + private function loggable_uri() {
1400 + $uri = (string) ( $this->request_data['uri_raw'] ?? '' );
1401 + $uri = (string) preg_replace( '/[\x00-\x1F\x7F]/', '', $uri );
1402 + $uri = wp_check_invalid_utf8( $uri, true );
1403 +
1404 + if ( strlen( $uri ) > self::MAX_LOGGED_URI ) {
1405 + $uri = substr( $uri, 0, self::MAX_LOGGED_URI ) . '...';
1406 + }
1407 +
1408 + return $uri;
1409 + }
1410 +
1411 + /**
997 1412 * Check if current IP is whitelisted
998 1413 *
999 1414 * @return bool
1000 1415 */
@@ -1134,16 +1549,26 @@
1134 1549 if ( ! isset( $blocks[ $ip ] ) ) {
1135 1550 return false;
1136 1551 }
1137 1552
1553 + // The address, and the narrower key the block was kept under, if any
1554 + // (a verified visitor of Under Attack mode, since 2.11.8).
1555 + $keys = array( $ip );
1556 +
1557 + if ( is_array( $blocks[ $ip ] ) && ! empty( $blocks[ $ip ]['key'] ) && is_string( $blocks[ $ip ]['key'] ) ) {
1558 + $keys[] = $blocks[ $ip ]['key'];
1559 + }
1560 +
1138 1561 unset( $blocks[ $ip ] );
1139 1562 update_option( 'vigilante_firewall_blocks', $blocks, false );
1140 1563
1141 1564 // Clean related transients
1142 - $hash = md5( $ip );
1143 - delete_transient( 'vigilante_rate_block_' . $hash );
1144 - delete_transient( 'vigilante_rate_' . $hash );
1145 - delete_transient( 'vigilante_strikes_' . $hash );
1565 + foreach ( array_unique( $keys ) as $key ) {
1566 + $hash = md5( $key );
1567 + delete_transient( 'vigilante_rate_block_' . $hash );
1568 + delete_transient( 'vigilante_rate_' . $hash );
1569 + delete_transient( 'vigilante_strikes_' . $hash );
1570 + }
1146 1571
1147 1572 return true;
1148 1573 }
1149 1574 }