| @@ -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 ); ?> |
| @@ -4773,8 +4793,9 @@ | ||
| 4773 | 4793 | <label> |
| 4774 | 4794 | <input type="checkbox" name="security_headers[csp][enabled]" value="1" <?php checked( ! empty( $options['csp']['enabled'] ) ); ?>> |
| 4775 | 4795 | <?php esc_html_e( 'Enable Content Security Policy', 'vigilante' ); ?> |
| 4776 | 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> | |
| 4777 | 4798 | </td> |
| 4778 | 4799 | </tr> |
| 4779 | 4800 | <tr> |
| 4780 | 4801 | <th scope="row"><?php esc_html_e( 'Report Only Mode', 'vigilante' ); ?></th> |
| @@ -7794,9 +7815,9 @@ | ||
| 7794 | 7815 | } elseif ( isset( $_POST['data'] ) ) { |
| 7795 | 7816 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 7796 | 7817 | $raw_data = wp_unslash( $_POST['data'] ); |
| 7797 | 7818 | parse_str( $raw_data, $data ); |
| 7798 | - $data = map_deep( $data, 'sanitize_textarea_field' ); | |
| 7819 | + $data = $this->sanitize_posted_settings( $data ); | |
| 7799 | 7820 | } else { |
| 7800 | 7821 | $data = array(); |
| 7801 | 7822 | } |
| 7802 | 7823 | |
| @@ -8186,8 +8207,48 @@ | ||
| 8186 | 8207 | return array( 'sent' => $sent, 'failed' => $failed ); |
| 8187 | 8208 | } |
| 8188 | 8209 | |
| 8189 | 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 | + /** | |
| 8190 | 8251 | * Process section data maintaining proper types from defaults |
| 8191 | 8252 | * |
| 8192 | 8253 | * @param array $submitted_data Data submitted from form. |
| 8193 | 8254 | * @param array $defaults Default values for this section. |
| @@ -8514,9 +8575,9 @@ | ||
| 8514 | 8575 | wp_send_json_error( __( 'Invalid JSON format.', 'vigilante' ) ); |
| 8515 | 8576 | } |
| 8516 | 8577 | |
| 8517 | 8578 | // Sanitize imported data recursively |
| 8518 | - $imported = map_deep( $imported, 'sanitize_text_field' ); | |
| 8579 | + $imported = $this->sanitize_imported_settings( $imported ); | |
| 8519 | 8580 | |
| 8520 | 8581 | // Validate structure: only sections and keys of the schema survive, and |
| 8521 | 8582 | // every value takes the type of its default. Until 2.11.0 this was an |
| 8522 | 8583 | // array_replace_recursive() of the file over the defaults, so any key in |
| @@ -8952,9 +9013,29 @@ | ||
| 8952 | 9013 | if ( ! current_user_can( 'manage_options' ) ) { |
| 8953 | 9014 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 8954 | 9015 | } |
| 8955 | 9016 | |
| 8956 | - $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'] ) ) : ''; | |
| 8957 | 9038 | |
| 8958 | 9039 | if ( empty( $file ) ) { |
| 8959 | 9040 | wp_send_json_error( __( 'No file specified.', 'vigilante' ) ); |
| 8960 | 9041 | } |
| @@ -9007,9 +9088,9 @@ | ||
| 9007 | 9088 | if ( ! current_user_can( 'manage_options' ) ) { |
| 9008 | 9089 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 9009 | 9090 | } |
| 9010 | 9091 | |
| 9011 | - $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'] ) ) : ''; | |
| 9012 | 9093 | |
| 9013 | 9094 | if ( empty( $file ) ) { |
| 9014 | 9095 | wp_send_json_error( __( 'No file specified.', 'vigilante' ) ); |
| 9015 | 9096 | } |
| @@ -9042,9 +9123,9 @@ | ||
| 9042 | 9123 | $files = array(); |
| 9043 | 9124 | $locked = $this->critical_approval_locked(); |
| 9044 | 9125 | $shared = $locked ? array( 'wp-config.php', '.htaccess' ) : array(); |
| 9045 | 9126 | foreach ( $raw_files as $f ) { |
| 9046 | - $clean = sanitize_text_field( $f ); | |
| 9127 | + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : ''; | |
| 9047 | 9128 | // Same rule as ajax_ignore_file(): the two shared files when the |
| 9048 | 9129 | // network locks them, and Vigilant's own files always. |
| 9049 | 9130 | if ( '' !== $clean && ! in_array( $clean, $shared, true ) && ! Vigilante_Self_Integrity::is_own_file_path( $clean ) ) { |
| 9050 | 9131 | $files[] = $clean; |
| @@ -9111,9 +9192,9 @@ | ||
| 9111 | 9192 | } |
| 9112 | 9193 | |
| 9113 | 9194 | $files = array(); |
| 9114 | 9195 | foreach ( $raw_files as $f ) { |
| 9115 | - $clean = sanitize_text_field( $f ); | |
| 9196 | + $clean = is_string( $f ) ? wp_strip_all_tags( $f ) : ''; | |
| 9116 | 9197 | if ( '' !== $clean ) { |
| 9117 | 9198 | $files[] = $clean; |
| 9118 | 9199 | } |
| 9119 | 9200 | } |