PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
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 +140 -50 2.11.1 → 3.0.5 View file →
@@ -93,12 +93,27 @@
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
102 117 // Gather request data first: a block is logged with the address it
103 118 // turned away, and until 2.11.1 the blacklist ran before this, so the
104 119 // entry for a blacklisted IP recorded no address at all.
@@ -108,10 +123,12 @@
108 123 if ( $this->is_ip_blacklisted() ) {
109 124 $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) );
110 125 }
111 126
112 - // Check if User-Agent is blacklisted (after gathering request data)
113 - 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() ) {
114 131 $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) );
115 132 }
116 133
117 134 // Run security checks
@@ -128,9 +145,16 @@
128 145 'block_bad_bots' => 'check_bad_bots',
129 146 'block_empty_user_agent' => 'check_empty_user_agent',
130 147 );
131 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 +
132 152 foreach ( $checks as $option => $method ) {
153 + if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) {
154 + continue;
155 + }
156 +
133 157 if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) {
134 158 $result = $this->$method();
135 159 if ( is_string( $result ) ) {
136 160 $this->block_request( $option, $result );
@@ -150,18 +174,19 @@
150 174 private function gather_request_data() {
151 175 $this->haystack = null;
152 176
153 177 $this->request_data = array(
154 - 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
155 - 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '',
156 178 /*
157 179 * Copies that keep the percent encoding, used only as the haystack
158 180 * of the pattern checks and never logged, printed or stored.
159 181 *
160 182 * They exist because sanitize_text_field() deletes every %XX
161 - * sequence instead of decoding it: the copies above are the payload
183 + * sequence instead of decoding it: a sanitized copy is the payload
162 184 * with the evidence removed, so an encoded attack was invisible to
163 - * 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,
164 189 * ?x=%3Cscript%3E, javascript%3A, php%3A%2F%2F and GLOBALS%5B all
165 190 * reached the checks as harmless text and went straight through.
166 191 *
167 192 * No sanitizer is applied, and that is the point: every one of them
@@ -167,13 +192,16 @@
167 192 * No sanitizer is applied, and that is the point: every one of them
168 193 * destroys exactly what has to be matched. sanitize_text_field()
169 194 * deletes the %XX sequences and strips tags. esc_url_raw() is worse
170 195 * here: measured on 22 aug 2026, it returns an empty string for a
171 - * query that carries an unencoded :// , which is precisely the
172 - * remote inclusion shape, so it would blind the firewall instead of
173 - * arming it. These two values are never echoed, never stored and
174 - * never reach a query; they are the haystack of preg_match() and
175 - * 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.
176 204 */
177 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.
178 206 'uri_raw' => isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '',
179 207 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as uri_raw.
@@ -764,24 +792,8 @@
764 792 return false;
765 793 }
766 794
767 795 /**
768 - * Check for PHP execution in uploads
769 - *
770 - * @return string|false Error message or false if safe.
771 - */
772 - private function check_php_in_uploads() {
773 - $uri = $this->inspection_haystack();
774 -
775 - // Check if accessing PHP in uploads directory
776 - if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) {
777 - return __( 'PHP execution in uploads blocked', 'vigilante' );
778 - }
779 -
780 - return false;
781 - }
782 -
783 - /**
784 796 * Check for bad bots
785 797 *
786 798 * @return string|false Error message or false if safe.
787 799 */
@@ -968,10 +980,10 @@
968 980
969 981 foreach ( $bad_bots as $bot ) {
970 982 if ( strpos( $user_agent, $bot ) !== false ) {
971 983 return sprintf(
972 - /* translators: %s: Bot name */
973 - __( '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' ),
974 986 $bot
975 987 );
976 988 }
977 989 }
@@ -991,10 +1003,49 @@
991 1003 return false;
992 1004 }
993 1005
994 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 + /**
995 1046 * Check HTTP method
996 - *
1047 + *
997 1048 * Logged-in users with edit capabilities are excluded to ensure
998 1049 * Gutenberg, REST API, and page builders work correctly.
999 1050 */
1000 1051 private function check_http_method() {
@@ -1006,17 +1057,31 @@
1006 1057
1007 1058 // Skip for WordPress REST API requests
1008 1059 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
1009 1060 // authentication and authorization layer — no need to filter methods here
1010 - $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
1011 - if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
1061 + if ( $this->is_rest_api_request() ) {
1012 1062 return;
1013 1063 }
1014 1064
1015 1065 $method = strtoupper( $this->request_data['method'] );
1016 - $allowed_methods = isset( $this->options['allowed_http_methods'] )
1017 - ? $this->options['allowed_http_methods']
1018 - : 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 + );
1019 1084 $allowed = array_map( 'strtoupper', $allowed_methods );
1020 1085
1021 1086 if ( ! in_array( $method, $allowed, true ) ) {
1022 1087 $this->block_request(
@@ -1095,11 +1160,12 @@
1095 1160 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
1096 1161 return;
1097 1162 }
1098 1163
1099 - // Allow other modules to opt out — Under Attack mode uses this so that
1100 - // visitors who already passed the JS challenge don't burn the
1101 - // 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.
1102 1168 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
1103 1169 return;
1104 1170 }
1105 1171
@@ -1105,8 +1171,21 @@
1105 1171
1106 1172 $ip = $this->get_client_ip();
1107 1173 $rate_limit = $this->options['rate_limiting'];
1108 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 +
1109 1188 // Check if already blocked (fast path). The active block lives in a
1110 1189 // transient keyed by IP, so this path, which runs on every
1111 1190 // unauthenticated request, reads one row and not the whole index of
1112 1191 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
@@ -1112,9 +1191,9 @@
1112 1191 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
1113 1192 // entire, an array with no upper bound that a distributed attack grew
1114 1193 // by one entry per new address, so the firewall amplified the attack it
1115 1194 // was blocking (S6). The transient expires with the block itself.
1116 - $block = get_transient( 'vigilante_rate_block_' . md5( $ip ) );
1195 + $block = get_transient( 'vigilante_rate_block_' . $hash );
1117 1196 if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) {
1118 1197 if ( ! headers_sent() ) {
1119 1198 status_header( 429 );
1120 1199 nocache_headers();
@@ -1140,9 +1219,9 @@
1140 1219 // silence". A logged-in editor publishing several posts in a row could
1141 1220 // pile up 150+ requests while never exceeding 60 in any single minute,
1142 1221 // and got a 429. Storing the window start makes the reset explicit
1143 1222 // instead of relying on the transient expiring.
1144 - $transient_key = 'vigilante_rate_' . md5( $ip );
1223 + $transient_key = 'vigilante_rate_' . $hash;
1145 1224 $window = get_transient( $transient_key );
1146 1225 $now = time();
1147 1226
1148 1227 // Counts stored before 2.9.5 were a bare integer with no window start.
@@ -1176,9 +1255,9 @@
1176 1255 $strikes = 1;
1177 1256
1178 1257 // Progressive blocking: double duration on each repeat offense
1179 1258 if ( ! empty( $rate_limit['progressive'] ) ) {
1180 - $strikes_key = 'vigilante_strikes_' . md5( $ip );
1259 + $strikes_key = 'vigilante_strikes_' . $hash;
1181 1260 $strikes = absint( get_transient( $strikes_key ) ) + 1;
1182 1261
1183 1262 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
1184 1263 $duration = min(
@@ -1195,12 +1274,13 @@
1195 1274 'blocked_at' => time(),
1196 1275 'duration' => $duration,
1197 1276 'reason' => 'rate_limit',
1198 1277 'strikes' => $strikes,
1278 + 'key' => $key,
1199 1279 );
1200 1280
1201 1281 // The block itself, read by the fast path above on every request.
1202 - set_transient( 'vigilante_rate_block_' . md5( $ip ), $block, $duration );
1282 + set_transient( 'vigilante_rate_block_' . $hash, $block, $duration );
1203 1283
1204 1284 // The bounded index the admin screen lists.
1205 1285 self::index_block( $ip, $block );
1206 1286
@@ -1453,16 +1533,26 @@
1453 1533 if ( ! isset( $blocks[ $ip ] ) ) {
1454 1534 return false;
1455 1535 }
1456 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 +
1457 1545 unset( $blocks[ $ip ] );
1458 1546 update_option( 'vigilante_firewall_blocks', $blocks, false );
1459 1547
1460 1548 // Clean related transients
1461 - $hash = md5( $ip );
1462 - delete_transient( 'vigilante_rate_block_' . $hash );
1463 - delete_transient( 'vigilante_rate_' . $hash );
1464 - 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 + }
1465 1555
1466 1556 return true;
1467 1557 }
1468 1558 }