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 +108 -26 2.11.4 → 3.0.2 View file →
@@ -174,18 +174,19 @@
174 174 private function gather_request_data() {
175 175 $this->haystack = null;
176 176
177 177 $this->request_data = array(
178 - 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
179 - 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '',
180 178 /*
181 179 * Copies that keep the percent encoding, used only as the haystack
182 180 * of the pattern checks and never logged, printed or stored.
183 181 *
184 182 * They exist because sanitize_text_field() deletes every %XX
185 - * sequence instead of decoding it: the copies above are the payload
183 + * sequence instead of decoding it: a sanitized copy is the payload
186 184 * with the evidence removed, so an encoded attack was invisible to
187 - * every rule that reads them. Measured on 22 aug 2026 against 2.9.8,
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,
188 189 * ?x=%3Cscript%3E, javascript%3A, php%3A%2F%2F and GLOBALS%5B all
189 190 * reached the checks as harmless text and went straight through.
190 191 *
191 192 * No sanitizer is applied, and that is the point: every one of them
@@ -191,13 +192,16 @@
191 192 * No sanitizer is applied, and that is the point: every one of them
192 193 * destroys exactly what has to be matched. sanitize_text_field()
193 194 * deletes the %XX sequences and strips tags. esc_url_raw() is worse
194 195 * here: measured on 22 aug 2026, it returns an empty string for a
195 - * query that carries an unencoded :// , which is precisely the
196 - * remote inclusion shape, so it would blind the firewall instead of
197 - * arming it. These two values are never echoed, never stored and
198 - * never reach a query; they are the haystack of preg_match() and
199 - * nothing else.
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.
200 204 */
201 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.
202 206 'uri_raw' => isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '',
203 207 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as uri_raw.
@@ -1015,10 +1019,49 @@
1015 1019 return false;
1016 1020 }
1017 1021
1018 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 + /**
1019 1062 * Check HTTP method
1020 - *
1063 + *
1021 1064 * Logged-in users with edit capabilities are excluded to ensure
1022 1065 * Gutenberg, REST API, and page builders work correctly.
1023 1066 */
1024 1067 private function check_http_method() {
@@ -1030,17 +1073,31 @@
1030 1073
1031 1074 // Skip for WordPress REST API requests
1032 1075 // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own
1033 1076 // authentication and authorization layer — no need to filter methods here
1034 - $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json'
1035 - if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) {
1077 + if ( $this->is_rest_api_request() ) {
1036 1078 return;
1037 1079 }
1038 1080
1039 1081 $method = strtoupper( $this->request_data['method'] );
1040 - $allowed_methods = isset( $this->options['allowed_http_methods'] )
1041 - ? $this->options['allowed_http_methods']
1042 - : 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 + );
1043 1100 $allowed = array_map( 'strtoupper', $allowed_methods );
1044 1101
1045 1102 if ( ! in_array( $method, $allowed, true ) ) {
1046 1103 $this->block_request(
@@ -1119,11 +1176,12 @@
1119 1176 if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
1120 1177 return;
1121 1178 }
1122 1179
1123 - // Allow other modules to opt out — Under Attack mode uses this so that
1124 - // visitors who already passed the JS challenge don't burn the
1125 - // 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.
1126 1184 if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) {
1127 1185 return;
1128 1186 }
1129 1187
@@ -1129,8 +1187,21 @@
1129 1187
1130 1188 $ip = $this->get_client_ip();
1131 1189 $rate_limit = $this->options['rate_limiting'];
1132 1190
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 +
1133 1204 // Check if already blocked (fast path). The active block lives in a
1134 1205 // transient keyed by IP, so this path, which runs on every
1135 1206 // unauthenticated request, reads one row and not the whole index of
1136 1207 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
@@ -1136,9 +1207,9 @@
1136 1207 // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks
1137 1208 // entire, an array with no upper bound that a distributed attack grew
1138 1209 // by one entry per new address, so the firewall amplified the attack it
1139 1210 // was blocking (S6). The transient expires with the block itself.
1140 - $block = get_transient( 'vigilante_rate_block_' . md5( $ip ) );
1211 + $block = get_transient( 'vigilante_rate_block_' . $hash );
1141 1212 if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) {
1142 1213 if ( ! headers_sent() ) {
1143 1214 status_header( 429 );
1144 1215 nocache_headers();
@@ -1164,9 +1235,9 @@
1164 1235 // silence". A logged-in editor publishing several posts in a row could
1165 1236 // pile up 150+ requests while never exceeding 60 in any single minute,
1166 1237 // and got a 429. Storing the window start makes the reset explicit
1167 1238 // instead of relying on the transient expiring.
1168 - $transient_key = 'vigilante_rate_' . md5( $ip );
1239 + $transient_key = 'vigilante_rate_' . $hash;
1169 1240 $window = get_transient( $transient_key );
1170 1241 $now = time();
1171 1242
1172 1243 // Counts stored before 2.9.5 were a bare integer with no window start.
@@ -1200,9 +1271,9 @@
1200 1271 $strikes = 1;
1201 1272
1202 1273 // Progressive blocking: double duration on each repeat offense
1203 1274 if ( ! empty( $rate_limit['progressive'] ) ) {
1204 - $strikes_key = 'vigilante_strikes_' . md5( $ip );
1275 + $strikes_key = 'vigilante_strikes_' . $hash;
1205 1276 $strikes = absint( get_transient( $strikes_key ) ) + 1;
1206 1277
1207 1278 $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 );
1208 1279 $duration = min(
@@ -1219,12 +1290,13 @@
1219 1290 'blocked_at' => time(),
1220 1291 'duration' => $duration,
1221 1292 'reason' => 'rate_limit',
1222 1293 'strikes' => $strikes,
1294 + 'key' => $key,
1223 1295 );
1224 1296
1225 1297 // The block itself, read by the fast path above on every request.
1226 - set_transient( 'vigilante_rate_block_' . md5( $ip ), $block, $duration );
1298 + set_transient( 'vigilante_rate_block_' . $hash, $block, $duration );
1227 1299
1228 1300 // The bounded index the admin screen lists.
1229 1301 self::index_block( $ip, $block );
1230 1302
@@ -1477,16 +1549,26 @@
1477 1549 if ( ! isset( $blocks[ $ip ] ) ) {
1478 1550 return false;
1479 1551 }
1480 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 +
1481 1561 unset( $blocks[ $ip ] );
1482 1562 update_option( 'vigilante_firewall_blocks', $blocks, false );
1483 1563
1484 1564 // Clean related transients
1485 - $hash = md5( $ip );
1486 - delete_transient( 'vigilante_rate_block_' . $hash );
1487 - delete_transient( 'vigilante_rate_' . $hash );
1488 - 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 + }
1489 1571
1490 1572 return true;
1491 1573 }
1492 1574 }