PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-firewall.php +464 -58 2.10.22.11.8 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 );
@@ -431,9 +457,14 @@
431 457 // rule, and legitimate links carry those all the time: a redirect_to
432 458 // back to the site itself, a return_url, a payment gateway callback.
433 459 // What makes it an inclusion attempt is the target being somewhere
434 460 // else, so a URL pointing at this very site is left alone.
435 - if ( $this->has_remote_inclusion() ) {
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() ) {
436 467 return __( 'Remote file inclusion attempt', 'vigilante' );
437 468 }
438 469
439 470 // File inclusion patterns
@@ -458,10 +489,66 @@
458 489 return false;
459 490 }
460 491
461 492 /**
462 - * Whether the request carries a URL that points outside this site
493 + * Parameter names a remote inclusion payload travels in
463 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 + *
464 551 * Works on the parsed parameters rather than on a pattern match over the
465 552 * whole string, for two reasons: a link back to the site itself is not
466 553 * mistaken for an attack, and an encoded payload is seen for what it is.
467 554 * The copy of the query string kept for logging goes through
@@ -467,8 +554,18 @@
467 554 * The copy of the query string kept for logging goes through
468 555 * sanitize_text_field(), which strips every %XX sequence instead of
469 556 * decoding it, so the encoded form never looked like a URL there.
470 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 + *
471 568 * @since 2.9.9
472 569 *
473 570 * @return bool
474 571 */
@@ -483,21 +580,13 @@
483 580 // would have put in $_GET, without reading the superglobal.
484 581 $params = array();
485 582 parse_str( $query, $params );
486 583
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 - );
584 + $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) );
496 585
497 - $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) );
586 + foreach ( $this->flatten_query_params( $params ) as $pair ) {
587 + list( $names, $value ) = $pair;
498 588
499 - foreach ( $values as $value ) {
500 589 if ( ! preg_match_all( '/(?:https?|ftp):\/\/[^\s\'"<>]+/i', $value, $matches ) ) {
501 590 continue;
502 591 }
503 592
@@ -503,9 +592,14 @@
503 592
504 593 foreach ( $matches[0] as $url ) {
505 594 $host = $this->normalize_host( wp_parse_url( $url, PHP_URL_HOST ) );
506 595
507 - if ( '' === $host || $host !== $home_host ) {
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 ) ) {
508 602 return true;
509 603 }
510 604 }
511 605 }
@@ -513,8 +607,146 @@
513 607 return false;
514 608 }
515 609
516 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 + /**
517 749 * Host in a comparable form: lowercase and without a leading www.
518 750 *
519 751 * @since 2.9.9
520 752 *
@@ -783,10 +1015,49 @@
783 1015 return false;
784 1016 }
785 1017
786 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 + /**
787 1058 * Check HTTP method
788 - *
1059 + *
789 1060 * Logged-in users with edit capabilities are excluded to ensure
790 1061 * Gutenberg, REST API, and page builders work correctly.
791 1062 */
792 1063 private function check_http_method() {
@@ -798,10 +1069,9 @@
798 1069
799 1070 // Skip for WordPress REST API requests
800 1071 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
801 1072 // 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 . '/' ) ) {
1073 + if ( $this->is_rest_api_request() ) {
804 1074 return;
805 1075 }
806 1076
807 1077 $method = strtoupper( $this->request_data['method'] );
@@ -822,8 +1092,60 @@
822 1092 }
823 1093 }
824 1094
825 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 + /**
826 1148 * Check rate limiting
827 1149 */
828 1150 public function check_rate_limit() {
829 1151 // Skip rate limiting for whitelisted IPs
@@ -835,11 +1157,12 @@
835 1157 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
836 1158 return;
837 1159 }
838 1160
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.
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.
842 1165 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
843 1166 return;
844 1167 }
845 1168
@@ -845,25 +1168,39 @@
845 1168
846 1169 $ip = $this->get_client_ip();
847 1170 $rate_limit = $this->options['rate_limiting'];
848 1171
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 - );
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();
862 1197 }
863 - // Expired — clean up
864 - unset( $active_blocks[ $ip ] );
865 - 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 + );
866 1203 }
867 1204
868 1205 $max_requests = absint( $rate_limit['requests_per_minute'] );
869 1206
@@ -879,9 +1216,9 @@
879 1216 // silence". A logged-in editor publishing several posts in a row could
880 1217 // pile up 150+ requests while never exceeding 60 in any single minute,
881 1218 // and got a 429. Storing the window start makes the reset explicit
882 1219 // instead of relying on the transient expiring.
883 - $transient_key = 'vigilante_rate_' . md5( $ip );
1220 + $transient_key = 'vigilante_rate_' . $hash;
884 1221 $window = get_transient( $transient_key );
885 1222 $now = time();
886 1223
887 1224 // Counts stored before 2.9.5 were a bare integer with no window start.
@@ -915,9 +1252,9 @@
915 1252 $strikes = 1;
916 1253
917 1254 // Progressive blocking: double duration on each repeat offense
918 1255 if ( ! empty( $rate_limit['progressive'] ) ) {
919 - $strikes_key = 'vigilante_strikes_' . md5( $ip );
1256 + $strikes_key = 'vigilante_strikes_' . $hash;
920 1257 $strikes = absint( get_transient( $strikes_key ) ) + 1;
921 1258
922 1259 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
923 1260 $duration = min(
@@ -928,18 +1265,23 @@
928 1265 // Persist strikes for 24h so they accumulate across blocks
929 1266 set_transient( $strikes_key, $strikes, 86400 );
930 1267 }
931 1268
932 - // Store block in queryable option for admin UI
933 - $active_blocks[ $ip ] = array(
1269 + $block = array(
934 1270 'expires' => time() + $duration,
935 1271 'blocked_at' => time(),
936 1272 'duration' => $duration,
937 1273 'reason' => 'rate_limit',
938 1274 'strikes' => $strikes,
1275 + 'key' => $key,
939 1276 );
940 - update_option( 'vigilante_firewall_blocks', $active_blocks, false );
941 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 +
942 1284 $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 );
943 1285 }
944 1286
945 1287 // The TTL only garbage-collects the payload once the IP goes quiet; what
@@ -961,12 +1303,12 @@
961 1303 'firewall',
962 1304 'blocked',
963 1305 $message,
964 1306 array(
965 - 'reason' => $reason,
966 - 'uri' => $this->request_data['uri'] ?? '',
967 - 'ip' => $this->get_client_ip(),
968 - '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'] ?? '',
969 1311 ),
970 1312 'warning'
971 1313 );
972 1314 }
@@ -976,8 +1318,24 @@
976 1318 status_header( $status_code );
977 1319 nocache_headers();
978 1320 }
979 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 +
980 1338 // Return appropriate response
981 1339 if ( 429 === $status_code ) {
982 1340 wp_die(
983 1341 esc_html( $message ),
@@ -993,8 +1351,46 @@
993 1351 );
994 1352 }
995 1353
996 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 + /**
997 1393 * Check if current IP is whitelisted
998 1394 *
999 1395 * @return bool
1000 1396 */
@@ -1134,16 +1530,26 @@
1134 1530 if ( ! isset( $blocks[ $ip ] ) ) {
1135 1531 return false;
1136 1532 }
1137 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 +
1138 1542 unset( $blocks[ $ip ] );
1139 1543 update_option( 'vigilante_firewall_blocks', $blocks, false );
1140 1544
1141 1545 // 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 );
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 + }
1146 1552
1147 1553 return true;
1148 1554 }
1149 1555 }