PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
← All changes | admin/class-admin.php +252 -17 3.0.0 → 3.0.2 View file →
@@ -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 &lt;) 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 }