| @@ -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. |
| @@ -788,24 +792,8 @@ | ||
| 788 | 792 | return false; |
| 789 | 793 | } |
| 790 | 794 | |
| 791 | 795 | /** |
| 792 | - * Check for PHP execution in uploads | |
| 793 | - * | |
| 794 | - * @return string|false Error message or false if safe. | |
| 795 | - */ | |
| 796 | - private function check_php_in_uploads() { | |
| 797 | - $uri = $this->inspection_haystack(); | |
| 798 | - | |
| 799 | - // Check if accessing PHP in uploads directory | |
| 800 | - if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) { | |
| 801 | - return __( 'PHP execution in uploads blocked', 'vigilante' ); | |
| 802 | - } | |
| 803 | - | |
| 804 | - return false; | |
| 805 | - } | |
| 806 | - | |
| 807 | - /** | |
| 808 | 796 | * Check for bad bots |
| 809 | 797 | * |
| 810 | 798 | * @return string|false Error message or false if safe. |
| 811 | 799 | */ |
| @@ -992,10 +980,10 @@ | ||
| 992 | 980 | |
| 993 | 981 | foreach ( $bad_bots as $bot ) { |
| 994 | 982 | if ( strpos( $user_agent, $bot ) !== false ) { |
| 995 | 983 | return sprintf( |
| 996 | - /* translators: %s: Bot name */ | |
| 997 | - __( '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' ), | |
| 998 | 986 | $bot |
| 999 | 987 | ); |
| 1000 | 988 | } |
| 1001 | 989 | } |
| @@ -1074,11 +1062,26 @@ | ||
| 1074 | 1062 | return; |
| 1075 | 1063 | } |
| 1076 | 1064 | |
| 1077 | 1065 | $method = strtoupper( $this->request_data['method'] ); |
| 1078 | - $allowed_methods = isset( $this->options['allowed_http_methods'] ) | |
| 1079 | - ? $this->options['allowed_http_methods'] | |
| 1080 | - : 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 | + ); | |
| 1081 | 1084 | $allowed = array_map( 'strtoupper', $allowed_methods ); |
| 1082 | 1085 | |
| 1083 | 1086 | if ( ! in_array( $method, $allowed, true ) ) { |
| 1084 | 1087 | $this->block_request( |