| @@ -1015,10 +1015,49 @@ | ||
| 1015 | 1015 | return false; |
| 1016 | 1016 | } |
| 1017 | 1017 | |
| 1018 | 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 | + /** | |
| 1019 | 1058 | * Check HTTP method |
| 1020 | - * | |
| 1059 | + * | |
| 1021 | 1060 | * Logged-in users with edit capabilities are excluded to ensure |
| 1022 | 1061 | * Gutenberg, REST API, and page builders work correctly. |
| 1023 | 1062 | */ |
| 1024 | 1063 | private function check_http_method() { |
| @@ -1030,10 +1069,9 @@ | ||
| 1030 | 1069 | |
| 1031 | 1070 | // Skip for WordPress REST API requests |
| 1032 | 1071 | // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own |
| 1033 | 1072 | // 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 . '/' ) ) { | |
| 1073 | + if ( $this->is_rest_api_request() ) { | |
| 1036 | 1074 | return; |
| 1037 | 1075 | } |
| 1038 | 1076 | |
| 1039 | 1077 | $method = strtoupper( $this->request_data['method'] ); |
| @@ -1119,11 +1157,12 @@ | ||
| 1119 | 1157 | if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) { |
| 1120 | 1158 | return; |
| 1121 | 1159 | } |
| 1122 | 1160 | |
| 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. | |
| 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. | |
| 1126 | 1165 | if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) { |
| 1127 | 1166 | return; |
| 1128 | 1167 | } |
| 1129 | 1168 | |
| @@ -1129,8 +1168,21 @@ | ||
| 1129 | 1168 | |
| 1130 | 1169 | $ip = $this->get_client_ip(); |
| 1131 | 1170 | $rate_limit = $this->options['rate_limiting']; |
| 1132 | 1171 | |
| 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 | + | |
| 1133 | 1185 | // Check if already blocked (fast path). The active block lives in a |
| 1134 | 1186 | // transient keyed by IP, so this path, which runs on every |
| 1135 | 1187 | // unauthenticated request, reads one row and not the whole index of |
| 1136 | 1188 | // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks |
| @@ -1136,9 +1188,9 @@ | ||
| 1136 | 1188 | // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks |
| 1137 | 1189 | // entire, an array with no upper bound that a distributed attack grew |
| 1138 | 1190 | // by one entry per new address, so the firewall amplified the attack it |
| 1139 | 1191 | // was blocking (S6). The transient expires with the block itself. |
| 1140 | - $block = get_transient( 'vigilante_rate_block_' . md5( $ip ) ); | |
| 1192 | + $block = get_transient( 'vigilante_rate_block_' . $hash ); | |
| 1141 | 1193 | if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) { |
| 1142 | 1194 | if ( ! headers_sent() ) { |
| 1143 | 1195 | status_header( 429 ); |
| 1144 | 1196 | nocache_headers(); |
| @@ -1164,9 +1216,9 @@ | ||
| 1164 | 1216 | // silence". A logged-in editor publishing several posts in a row could |
| 1165 | 1217 | // pile up 150+ requests while never exceeding 60 in any single minute, |
| 1166 | 1218 | // and got a 429. Storing the window start makes the reset explicit |
| 1167 | 1219 | // instead of relying on the transient expiring. |
| 1168 | - $transient_key = 'vigilante_rate_' . md5( $ip ); | |
| 1220 | + $transient_key = 'vigilante_rate_' . $hash; | |
| 1169 | 1221 | $window = get_transient( $transient_key ); |
| 1170 | 1222 | $now = time(); |
| 1171 | 1223 | |
| 1172 | 1224 | // Counts stored before 2.9.5 were a bare integer with no window start. |
| @@ -1200,9 +1252,9 @@ | ||
| 1200 | 1252 | $strikes = 1; |
| 1201 | 1253 | |
| 1202 | 1254 | // Progressive blocking: double duration on each repeat offense |
| 1203 | 1255 | if ( ! empty( $rate_limit['progressive'] ) ) { |
| 1204 | - $strikes_key = 'vigilante_strikes_' . md5( $ip ); | |
| 1256 | + $strikes_key = 'vigilante_strikes_' . $hash; | |
| 1205 | 1257 | $strikes = absint( get_transient( $strikes_key ) ) + 1; |
| 1206 | 1258 | |
| 1207 | 1259 | $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 ); |
| 1208 | 1260 | $duration = min( |
| @@ -1219,12 +1271,13 @@ | ||
| 1219 | 1271 | 'blocked_at' => time(), |
| 1220 | 1272 | 'duration' => $duration, |
| 1221 | 1273 | 'reason' => 'rate_limit', |
| 1222 | 1274 | 'strikes' => $strikes, |
| 1275 | + 'key' => $key, | |
| 1223 | 1276 | ); |
| 1224 | 1277 | |
| 1225 | 1278 | // The block itself, read by the fast path above on every request. |
| 1226 | - set_transient( 'vigilante_rate_block_' . md5( $ip ), $block, $duration ); | |
| 1279 | + set_transient( 'vigilante_rate_block_' . $hash, $block, $duration ); | |
| 1227 | 1280 | |
| 1228 | 1281 | // The bounded index the admin screen lists. |
| 1229 | 1282 | self::index_block( $ip, $block ); |
| 1230 | 1283 | |
| @@ -1477,16 +1530,26 @@ | ||
| 1477 | 1530 | if ( ! isset( $blocks[ $ip ] ) ) { |
| 1478 | 1531 | return false; |
| 1479 | 1532 | } |
| 1480 | 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 | + | |
| 1481 | 1542 | unset( $blocks[ $ip ] ); |
| 1482 | 1543 | update_option( 'vigilante_firewall_blocks', $blocks, false ); |
| 1483 | 1544 | |
| 1484 | 1545 | // 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 ); | |
| 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 | + } | |
| 1489 | 1552 | |
| 1490 | 1553 | return true; |
| 1491 | 1554 | } |
| 1492 | 1555 | } |