| @@ -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 | } |