| @@ -1008,8 +1008,28 @@ | ||
| 1008 | 1008 | return count( $pending_users ); |
| 1009 | 1009 | } |
| 1010 | 1010 | |
| 1011 | 1011 | /** |
| 1012 | + * Whether XML-RPC is blocked, whole or its pingback methods | |
| 1013 | + * | |
| 1014 | + * Both levels have to say so: the three-way setting under WP Hardening, read | |
| 1015 | + * through the module's own resolver, and the WP Hardening module that | |
| 1016 | + * enforces it. Until 3.0.2 the Dashboard read login_security[disable_xmlrpc], | |
| 1017 | + * the checkbox that moved to WP Hardening in 2.9.7 and is never written any | |
| 1018 | + * more, so the score never granted its three points and the recommendation | |
| 1019 | + * to disable XML-RPC stayed on for good. | |
| 1020 | + * | |
| 1021 | + * @since 3.0.2 | |
| 1022 | + * | |
| 1023 | + * @param array $options Plugin options. | |
| 1024 | + * @return bool | |
| 1025 | + */ | |
| 1026 | + private function is_xmlrpc_protected( $options ) { | |
| 1027 | + return ! empty( $options['modules']['wp_hardening'] ) | |
| 1028 | + && 'none' !== Vigilante_Comment_Security::resolve_xmlrpc_mode( $this->settings ); | |
| 1029 | + } | |
| 1030 | + | |
| 1031 | + /** | |
| 1012 | 1032 | * Calculate comprehensive security score (0-100) |
| 1013 | 1033 | * |
| 1014 | 1034 | * @param array $options Plugin options. |
| 1015 | 1035 | * @return int Security score. |
| @@ -1067,10 +1087,10 @@ | ||
| 1067 | 1087 | // Max attempts configured |
| 1068 | 1088 | if ( isset( $login['max_attempts'] ) && $login['max_attempts'] <= 5 ) { |
| 1069 | 1089 | $score += 3; |
| 1070 | 1090 | } |
| 1071 | - // XML-RPC disabled | |
| 1072 | - if ( ! empty( $login['disable_xmlrpc'] ) ) { | |
| 1091 | + // XML-RPC blocked, whole or its pingback methods | |
| 1092 | + if ( $this->is_xmlrpc_protected( $options ) ) { | |
| 1073 | 1093 | $score += 3; |
| 1074 | 1094 | } |
| 1075 | 1095 | // 2FA enabled |
| 1076 | 1096 | if ( ! empty( $login['two_factor']['enabled'] ) ) { |
| @@ -1310,14 +1330,14 @@ | ||
| 1310 | 1330 | ); |
| 1311 | 1331 | } |
| 1312 | 1332 | |
| 1313 | 1333 | // Low: XML-RPC enabled (only if login security is active) |
| 1314 | - if ( ! empty( $options['modules']['login_security'] ) && empty( $login['disable_xmlrpc'] ) ) { | |
| 1334 | + if ( ! empty( $options['modules']['login_security'] ) && ! $this->is_xmlrpc_protected( $options ) ) { | |
| 1315 | 1335 | $recommendations[] = array( |
| 1316 | 1336 | 'icon' => 'info', |
| 1317 | 1337 | 'priority' => 'low', |
| 1318 | 1338 | 'message' => __( 'Disable XML-RPC if not needed (reduces attack surface).', 'vigilante' ), |
| 1319 | - 'tab' => 'login', | |
| 1339 | + 'tab' => 'wp-hardening', | |
| 1320 | 1340 | ); |
| 1321 | 1341 | } |
| 1322 | 1342 | |
| 1323 | 1343 | // Low: Strong passwords not enforced (only if user security is active) |
| @@ -2703,9 +2723,9 @@ | ||
| 2703 | 2723 | <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span> |
| 2704 | 2724 | <?php esc_html_e( 'Security Check', 'vigilante' ); ?> |
| 2705 | 2725 | </h2> |
| 2706 | 2726 | <p class="description"> |
| 2707 | - <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus 13 internal checks impossible from the outside.', 'vigilante' ); ?> | |
| 2727 | + <?php esc_html_e( 'On-demand audit of what an attacker would see right now, plus internal checks impossible from the outside.', 'vigilante' ); ?> | |
| 2708 | 2728 | </p> |
| 2709 | 2729 | <p class="vigilante-analyzer-last-scan" data-role="ran-at"> |
| 2710 | 2730 | <span class="dashicons dashicons-clock" aria-hidden="true"></span> |
| 2711 | 2731 | <?php echo esc_html( $ran_at_human ); ?> |
| @@ -4437,8 +4457,9 @@ | ||
| 4437 | 4457 | <tr> |
| 4438 | 4458 | <th scope="row"><?php esc_html_e( 'Enforce for roles', 'vigilante' ); ?></th> |
| 4439 | 4459 | <td> |
| 4440 | 4460 | <div class="vigilante-2fa-roles"> |
| 4461 | + <?php $this->list_sentinel( 'login_security[two_factor][enforced_roles]' ); ?> | |
| 4441 | 4462 | <?php foreach ( $all_roles as $role_slug => $role_data ) : |
| 4442 | 4463 | $user_count = count( get_users( array( 'role' => $role_slug, 'fields' => 'ID' ) ) ); |
| 4443 | 4464 | ?> |
| 4444 | 4465 | <label> |
| @@ -4466,8 +4487,9 @@ | ||
| 4466 | 4487 | autocomplete="off"> |
| 4467 | 4488 | <div class="vigilante-2fa-search-results"></div> |
| 4468 | 4489 | </div> |
| 4469 | 4490 | <div class="vigilante-2fa-excluded-users"> |
| 4491 | + <?php $this->list_sentinel( 'login_security[two_factor][excluded_users]' ); ?> | |
| 4470 | 4492 | <?php |
| 4471 | 4493 | foreach ( $excluded as $user_id ) : |
| 4472 | 4494 | $user = get_user_by( 'ID', $user_id ); |
| 4473 | 4495 | if ( ! $user ) continue; |
| @@ -4771,8 +4793,9 @@ | ||
| 4771 | 4793 | <label> |
| 4772 | 4794 | <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>> |
| 4773 | 4795 | <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?> |
| 4774 | 4796 | </label> |
| 4797 | + <p class="description"><?php esc_html_e( 'The default policy includes upgrade-insecure-requests, which asks browsers to request every http:// resource over https. Vigilant writes it only when the site address starts with https://, because on a site served over http it would stop every stylesheet, script and image from loading. If your site is served over https through a proxy or CDN that terminates TLS, set the site address to https:// in Settings > General; the rules follow the next time this tab is saved or Vigilant is updated.', 'vigilante' ); ?></p> | |
| 4775 | 4798 | </td> |
| 4776 | 4799 | </tr> |
| 4777 | 4800 | <tr> |
| 4778 | 4801 | <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th> |
| @@ -5123,8 +5146,9 @@ | ||
| 5123 | 5146 | </tr> |
| 5124 | 5147 | <tr> |
| 5125 | 5148 | <th scope="row"><?php esc_html_e( 'Apply Password Rules To', 'vigilante' ); ?></th> |
| 5126 | 5149 | <td> |
| 5150 | + <?php $this->list_sentinel( 'user_security[password_policy][affected_roles]' ); ?> | |
| 5127 | 5151 | <?php foreach ( wp_roles()->get_names() as $role_slug => $role_name ) : ?> |
| 5128 | 5152 | <label style="display:block;margin-bottom:5px;"> |
| 5129 | 5153 | <input type="checkbox" name="user_security[password_policy][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( empty( $pw_policy_roles ) || in_array( $role_slug, $pw_policy_roles, true ) ); ?>> |
| 5130 | 5154 | <?php echo esc_html( translate_user_role( $role_name ) ); ?> |
| @@ -5364,8 +5388,9 @@ | ||
| 5364 | 5388 | <td> |
| 5365 | 5389 | <?php |
| 5366 | 5390 | $affected_roles = $password_exp['affected_roles'] ?? array( 'administrator', 'editor' ); |
| 5367 | 5391 | $all_roles = wp_roles()->get_names(); |
| 5392 | + $this->list_sentinel( 'user_security[password_expiration][affected_roles]' ); | |
| 5368 | 5393 | foreach ( $all_roles as $role_slug => $role_name ) : |
| 5369 | 5394 | ?> |
| 5370 | 5395 | <label style="display: block; margin-bottom: 5px;"> |
| 5371 | 5396 | <input type="checkbox" name="user_security[password_expiration][affected_roles][]" value="<?php echo esc_attr( $role_slug ); ?>" <?php checked( in_array( $role_slug, $affected_roles, true ) ); ?>> |
| @@ -5387,8 +5412,9 @@ | ||
| 5387 | 5412 | autocomplete="off"> |
| 5388 | 5413 | <div class="vigilante-pwexp-search-results"></div> |
| 5389 | 5414 | </div> |
| 5390 | 5415 | <div class="vigilante-pwexp-excluded-users"> |
| 5416 | + <?php $this->list_sentinel( 'user_security[password_expiration][excluded_users]' ); ?> | |
| 5391 | 5417 | <?php |
| 5392 | 5418 | foreach ( $pwexp_excluded as $pwexp_excluded_id ) : |
| 5393 | 5419 | $excluded_user = get_user_by( 'ID', $pwexp_excluded_id ); |
| 5394 | 5420 | if ( ! $excluded_user ) { |
| @@ -7789,9 +7815,9 @@ | ||
| 7789 | 7815 | } elseif ( isset( $_POST['data'] ) ) { |
| 7790 | 7816 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 7791 | 7817 | $raw_data = wp_unslash( $_POST['data'] ); |
| 7792 | 7818 | parse_str( $raw_data, $data ); |
| 7793 | - $data = map_deep( $data, 'sanitize_textarea_field' ); | |
| 7819 | + $data = $this->sanitize_posted_settings( $data ); | |
| 7794 | 7820 | } else { |
| 7795 | 7821 | $data = array(); |
| 7796 | 7822 | } |
| 7797 | 7823 | |
| @@ -8181,8 +8207,48 @@ | ||
| 8181 | 8207 | return array( 'sent' => $sent, 'failed' => $failed ); |
| 8182 | 8208 | } |
| 8183 | 8209 | |
| 8184 | 8210 | /** |
| 8211 | + * Sanitize the settings a tab posted, once the serialized form is parsed | |
| 8212 | + * | |
| 8213 | + * Every value goes through sanitize_textarea_field() except the lists of | |
| 8214 | + * file paths (Vigilante_Settings::path_list_keys()). That one deletes every | |
| 8215 | + * %XX octet and, when it finds one, collapses repeated spaces, so an | |
| 8216 | + * exclusion for a folder called foto%20x was saved as fotox and excluded | |
| 8217 | + * nothing (measured on 1 oct 2026 against 3.0.1). The paths are read as | |
| 8218 | + * paths before the generic sanitizer runs and put back after it. | |
| 8219 | + * | |
| 8220 | + * @since 3.0.2 | |
| 8221 | + * | |
| 8222 | + * @param array $data Parsed form: section => key => value. | |
| 8223 | + * @return array | |
| 8224 | + */ | |
| 8225 | + private function sanitize_posted_settings( $data ) { | |
| 8226 | + $paths = Vigilante_Settings::take_path_lists( $data ); | |
| 8227 | + $data = map_deep( $data, 'sanitize_textarea_field' ); | |
| 8228 | + | |
| 8229 | + return Vigilante_Settings::restore_path_lists( $data, $paths ); | |
| 8230 | + } | |
| 8231 | + | |
| 8232 | + /** | |
| 8233 | + * Sanitize an imported settings file, once it is decoded | |
| 8234 | + * | |
| 8235 | + * Same rule as sanitize_posted_settings(): the lists of paths keep what the | |
| 8236 | + * export wrote, so an exclusion survives an export and an import. | |
| 8237 | + * | |
| 8238 | + * @since 3.0.2 | |
| 8239 | + * | |
| 8240 | + * @param array $imported Decoded export. | |
| 8241 | + * @return array | |
| 8242 | + */ | |
| 8243 | + private function sanitize_imported_settings( $imported ) { | |
| 8244 | + $paths = Vigilante_Settings::take_path_lists( $imported ); | |
| 8245 | + $imported = map_deep( $imported, 'sanitize_text_field' ); | |
| 8246 | + | |
| 8247 | + return Vigilante_Settings::restore_path_lists( $imported, $paths ); | |
| 8248 | + } | |
| 8249 | + | |
| 8250 | + /** | |
| 8185 | 8251 | * Process section data maintaining proper types from defaults |
| 8186 | 8252 | * |
| 8187 | 8253 | * @param array $submitted_data Data submitted from form. |
| 8188 | 8254 | * @param array $defaults Default values for this section. |
| @@ -8190,16 +8256,51 @@ | ||
| 8190 | 8256 | * @param string $section_name Section name for special handling. |
| 8191 | 8257 | * @return array Processed data. |
| 8192 | 8258 | */ |
| 8193 | 8259 | private function process_section_data( $submitted_data, $defaults, $current, $section_name = '' ) { |
| 8194 | - // Start with defaults, then merge current saved values | |
| 8260 | + // Start with defaults, then merge current saved values. Lists are put | |
| 8261 | + // back whole afterwards: array_replace_recursive() merges them index by | |
| 8262 | + // index, so a saved list shorter than the default kept the default's | |
| 8263 | + // tail (see below, and Vigilante_Settings::array_merge_deep()). | |
| 8195 | 8264 | $result = array_replace_recursive( $defaults, $current ); |
| 8196 | - | |
| 8265 | + $result = $this->restore_lists( $result, $current ); | |
| 8266 | + | |
| 8197 | 8267 | // Process each submitted value |
| 8198 | 8268 | foreach ( $submitted_data as $key => $value ) { |
| 8199 | 8269 | $key = sanitize_key( $key ); |
| 8200 | - | |
| 8201 | - if ( is_array( $value ) ) { | |
| 8270 | + | |
| 8271 | + if ( is_array( $value ) && $this->is_list_field( $key, $value, $defaults, $current ) ) { | |
| 8272 | + /* | |
| 8273 | + * A list of values, not a group of settings: the roles of a | |
| 8274 | + * checkbox group, the excluded user ids. What the form sent IS | |
| 8275 | + * the list, so it replaces the stored one outright. | |
| 8276 | + * | |
| 8277 | + * Until 3.0.1 this went through the recursion below, which | |
| 8278 | + * writes position by position and left every stored entry the | |
| 8279 | + * new list was too short to overwrite. Unchecking Editor in | |
| 8280 | + * "Enforce for roles" saved administrator over position 0 and | |
| 8281 | + * kept editor in position 1, so the box came back ticked | |
| 8282 | + * (reported on the support forum for 3.0.0, and the same bug | |
| 8283 | + * merge_preset() was fixed for in 2.9.8). | |
| 8284 | + * | |
| 8285 | + * Keys are dropped on purpose: a list is data, so anything the | |
| 8286 | + * request tried to name is not carried over. The empty string | |
| 8287 | + * is dropped too, which is what the hidden field that marks an | |
| 8288 | + * empty group sends. | |
| 8289 | + */ | |
| 8290 | + $list = array(); | |
| 8291 | + foreach ( $value as $entry ) { | |
| 8292 | + if ( ! is_scalar( $entry ) ) { | |
| 8293 | + continue; | |
| 8294 | + } | |
| 8295 | + $entry = sanitize_text_field( (string) $entry ); | |
| 8296 | + if ( '' === $entry ) { | |
| 8297 | + continue; | |
| 8298 | + } | |
| 8299 | + $list[] = $entry; | |
| 8300 | + } | |
| 8301 | + $result[ $key ] = $list; | |
| 8302 | + } elseif ( is_array( $value ) ) { | |
| 8202 | 8303 | // Nested array (like rate_limiting) |
| 8203 | 8304 | $nested_defaults = isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ? $defaults[ $key ] : array(); |
| 8204 | 8305 | $nested_current = isset( $result[ $key ] ) && is_array( $result[ $key ] ) ? $result[ $key ] : array(); |
| 8205 | 8306 | $result[ $key ] = $this->process_section_data( $value, $nested_defaults, $nested_current, $key ); |
| @@ -8285,10 +8386,26 @@ | ||
| 8285 | 8386 | } |
| 8286 | 8387 | } |
| 8287 | 8388 | |
| 8288 | 8389 | if ( $is_value_list ) { |
| 8289 | - // All items removed - reset to empty array | |
| 8290 | - $result[ $key ] = array(); | |
| 8390 | + /* | |
| 8391 | + * A list whose key did not travel is left as it was. Until | |
| 8392 | + * 3.0.1 it was blanked here, which read "the user unticked | |
| 8393 | + * everything" into something the form never asked about: | |
| 8394 | + * saving the Firewall tab blanked allowed_http_methods, | |
| 8395 | + * which has no field on any screen, and saving Users blanked | |
| 8396 | + * insecure_usernames. Both survived only because reading the | |
| 8397 | + * option put the defaults back on top, and that is exactly | |
| 8398 | + * the merge being removed in this release: with lists | |
| 8399 | + * replaced whole, blanking here would have left a site | |
| 8400 | + * answering 403 to every HTTP method. | |
| 8401 | + * | |
| 8402 | + * Emptying a group is still possible, and is now said out | |
| 8403 | + * loud: each checkbox group prints a hidden field with an | |
| 8404 | + * empty value, so an empty group arrives as an empty list | |
| 8405 | + * instead of as silence. See list_sentinel(). | |
| 8406 | + */ | |
| 8407 | + continue; | |
| 8291 | 8408 | } else { |
| 8292 | 8409 | // Nested settings group - handle boolean children |
| 8293 | 8410 | foreach ( $default_value as $nested_key => $nested_default ) { |
| 8294 | 8411 | if ( is_bool( $nested_default ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) { |
| @@ -8307,8 +8424,106 @@ | ||
| 8307 | 8424 | return $result; |
| 8308 | 8425 | } |
| 8309 | 8426 | |
| 8310 | 8427 | /** |
| 8428 | + * Whether a submitted value is a list of values rather than a group of settings | |
| 8429 | + * | |
| 8430 | + * The difference decides whether what arrived replaces the stored value or | |
| 8431 | + * is merged into it key by key, so it is answered from what the setting is | |
| 8432 | + * (its default, or what is stored today), not from the shape of the | |
| 8433 | + * request. A request that sends named keys where a list belongs gets them | |
| 8434 | + * dropped, which is one way this is narrower than what it replaces. | |
| 8435 | + * | |
| 8436 | + * @since 3.0.1 | |
| 8437 | + * | |
| 8438 | + * @param string $key Setting key, already through sanitize_key(). | |
| 8439 | + * @param array $value Value as submitted. | |
| 8440 | + * @param array $defaults Defaults of the section being processed. | |
| 8441 | + * @param array $current Stored values of the section being processed. | |
| 8442 | + * @return bool | |
| 8443 | + */ | |
| 8444 | + private function is_list_field( $key, $value, $defaults, $current ) { | |
| 8445 | + $default_known = array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] ); | |
| 8446 | + | |
| 8447 | + // The default has the first and the last word, the same rule that | |
| 8448 | + // array_merge_deep() and restore_lists() follow. Letting the stored | |
| 8449 | + // value decide, as this did until the fourth cross review of 3.0.1, | |
| 8450 | + // disagrees with them where it matters: an option corrupted into | |
| 8451 | + // two_factor = array( 'a', 'b' ) would have been treated as a list and | |
| 8452 | + // saving the tab would have replaced the whole group of settings with | |
| 8453 | + // the two values that arrived. | |
| 8454 | + if ( $default_known && array() !== $defaults[ $key ] ) { | |
| 8455 | + return Vigilante_Settings::is_list( $defaults[ $key ] ); | |
| 8456 | + } | |
| 8457 | + | |
| 8458 | + // Nothing declares it either way (an empty default, or a key with no | |
| 8459 | + // default at all), so what is stored, and then what was submitted, is | |
| 8460 | + // all there is to go on. | |
| 8461 | + if ( isset( $current[ $key ] ) && is_array( $current[ $key ] ) && array() !== $current[ $key ] && Vigilante_Settings::is_list( $current[ $key ] ) ) { | |
| 8462 | + return true; | |
| 8463 | + } | |
| 8464 | + | |
| 8465 | + return Vigilante_Settings::is_list( $value ); | |
| 8466 | + } | |
| 8467 | + | |
| 8468 | + /** | |
| 8469 | + * Put stored lists back after array_replace_recursive() | |
| 8470 | + * | |
| 8471 | + * array_replace_recursive() merges a list by position, so a stored list | |
| 8472 | + * shorter than the default comes out with the default's tail attached: | |
| 8473 | + * ['administrator'] over ['administrator','editor'] gives both back. Every | |
| 8474 | + * list that is stored is therefore restored whole before anything else is | |
| 8475 | + * processed, and only associative arrays are walked into. | |
| 8476 | + * | |
| 8477 | + * @since 3.0.1 | |
| 8478 | + * | |
| 8479 | + * @param array $result Defaults with the stored values merged over them. | |
| 8480 | + * @param array $current Stored values. | |
| 8481 | + * @return array | |
| 8482 | + */ | |
| 8483 | + private function restore_lists( $result, $current ) { | |
| 8484 | + if ( ! is_array( $current ) ) { | |
| 8485 | + return $result; | |
| 8486 | + } | |
| 8487 | + | |
| 8488 | + foreach ( $current as $key => $value ) { | |
| 8489 | + if ( ! is_array( $value ) || ! isset( $result[ $key ] ) || ! is_array( $result[ $key ] ) ) { | |
| 8490 | + continue; | |
| 8491 | + } | |
| 8492 | + | |
| 8493 | + // Decided by what is in $result, which comes from the defaults, and | |
| 8494 | + // never by the stored value: array() is a list by any definition, so | |
| 8495 | + // a section stored empty would have replaced a whole group of | |
| 8496 | + // settings with nothing. Same reasoning as in array_merge_deep(). | |
| 8497 | + if ( Vigilante_Settings::is_list( $result[ $key ] ) ) { | |
| 8498 | + $result[ $key ] = $value; | |
| 8499 | + continue; | |
| 8500 | + } | |
| 8501 | + | |
| 8502 | + $result[ $key ] = $this->restore_lists( $result[ $key ], $value ); | |
| 8503 | + } | |
| 8504 | + | |
| 8505 | + return $result; | |
| 8506 | + } | |
| 8507 | + | |
| 8508 | + /** | |
| 8509 | + * Print the hidden field that lets a checkbox group arrive empty | |
| 8510 | + * | |
| 8511 | + * A checkbox group sends nothing at all when every box is unticked, which | |
| 8512 | + * is indistinguishable from a form that never had that field. Since 3.0.1 | |
| 8513 | + * the save path leaves an absent list alone, so the difference has to be | |
| 8514 | + * stated: this empty entry always travels, and the save path drops empty | |
| 8515 | + * strings, so an untouched group arrives as an empty list. | |
| 8516 | + * | |
| 8517 | + * @since 3.0.1 | |
| 8518 | + * | |
| 8519 | + * @param string $name Field name without the trailing []. | |
| 8520 | + */ | |
| 8521 | + private function list_sentinel( $name ) { | |
| 8522 | + printf( '<input type="hidden" name="%s[]" value="">', esc_attr( $name ) ); | |
| 8523 | + } | |
| 8524 | + | |
| 8525 | + /** | |
| 8311 | 8526 | * AJAX: Export settings |
| 8312 | 8527 | */ |
| 8313 | 8528 | public function ajax_export_settings() { |
| 8314 | 8529 | check_ajax_referer( 'vigilante_admin_nonce', 'nonce' ); |
| @@ -8360,9 +8575,9 @@ | ||
| 8360 | 8575 | wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) ); |
| 8361 | 8576 | } |
| 8362 | 8577 | |
| 8363 | 8578 | // Sanitize imported data recursively |
| 8364 | - $imported = map_deep( $imported, 'sanitize_text_field' ); | |
| 8579 | + $imported = $this->sanitize_imported_settings( $imported ); | |
| 8365 | 8580 | |
| 8366 | 8581 | // Validate structure: only sections and keys of the schema survive, and |
| 8367 | 8582 | // every value takes the type of its default. Until 2.11.0 this was an |
| 8368 | 8583 | // array_replace_recursive() of the file over the defaults, so any key in |
| @@ -8798,9 +9013,29 @@ | ||
| 8798 | 9013 | if ( ! current_user_can( 'manage_options' ) ) { |
| 8799 | 9014 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 8800 | 9015 | } |
| 8801 | 9016 | |
| 8802 | - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : ''; | |
| 9017 | + /* | |
| 9018 | + * The path as the scan reported it, which is what the interface sends | |
| 9019 | + * back. sanitize_text_field() is the wrong reader for it: it deletes | |
| 9020 | + * every %XX octet and collapses repeated spaces, so a file named | |
| 9021 | + * foto%20nueva.php was stored as fotonueva.php, the scan compared the | |
| 9022 | + * list with the paths it finds one by one, never matched, and the | |
| 9023 | + * warning came back (measured on 1 oct 2026 against 3.0.1). The four | |
| 9024 | + * handlers of the ignore list read the path the same way. | |
| 9025 | + * | |
| 9026 | + * Known limits, found by the cross review, that only a file name with a | |
| 9027 | + * stray < in it reaches. wp_strip_all_tags() shortens such a name at the | |
| 9028 | + * < instead of refusing it, so the stored entry can match another file | |
| 9029 | + * (a.php<b is stored as a.php), and the value ignore_file() returns is | |
| 9030 | + * not read, so a refusal of its own is answered as success. The fix, if | |
| 9031 | + * a site ever needs it, is to answer with an error when | |
| 9032 | + * wp_strip_all_tags() changes what was sent and to check that return | |
| 9033 | + * value. An entry the old reader stored for such a name (it wrote the | |
| 9034 | + * < as <) is listed but "Stop ignoring" cannot remove it; "Clear all | |
| 9035 | + * ignored files" does. | |
| 9036 | + */ | |
| 9037 | + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : ''; | |
| 8803 | 9038 | |
| 8804 | 9039 | if ( empty( $file ) ) { |
| 8805 | 9040 | wp_send_json_error( __( 'No file specified.', 'vigilante' ) ); |
| 8806 | 9041 | } |
| @@ -8853,9 +9088,9 @@ | ||
| 8853 | 9088 | if ( ! current_user_can( 'manage_options' ) ) { |
| 8854 | 9089 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 8855 | 9090 | } |
| 8856 | 9091 | |
| 8857 | - $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : ''; | |
| 9092 | + $file = ( isset( $_POST['file'] ) && is_string( $_POST['file'] ) ) ? wp_strip_all_tags( wp_unslash( $_POST['file'] ) ) : ''; | |
| 8858 | 9093 | |
| 8859 | 9094 | if ( empty( $file ) ) { |
| 8860 | 9095 | wp_send_json_error( __( 'No file specified.', 'vigilante' ) ); |
| 8861 | 9096 | } |
| @@ -8888,9 +9123,9 @@ | ||
| 8888 | 9123 | $files = array(); |
| 8889 | 9124 | $locked = $this->critical_approval_locked(); |
| 8890 | 9125 | $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array(); |
| 8891 | 9126 | foreach ( $raw_files as $f ) { |
| 8892 | - $clean = sanitize_text_field( $f ); | |
| 9127 | + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : ''; | |
| 8893 | 9128 | // Same rule as ajax_ignore_file(): the two shared files when the |
| 8894 | 9129 | // network locks them, and Vigilant's own files always. |
| 8895 | 9130 | if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) { |
| 8896 | 9131 | $files[] = $clean; |
| @@ -8957,9 +9192,9 @@ | ||
| 8957 | 9192 | } |
| 8958 | 9193 | |
| 8959 | 9194 | $files = array(); |
| 8960 | 9195 | foreach ( $raw_files as $f ) { |
| 8961 | - $clean = sanitize_text_field( $f ); | |
| 9196 | + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : ''; | |
| 8962 | 9197 | if ( '' !== $clean ) { |
| 8963 | 9198 | $files[] = $clean; |
| 8964 | 9199 | } |
| 8965 | 9200 | } |