PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
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 | includes/class-settings.php +677 -29 2.11.6 → 3.0.3 View file →
@@ -93,8 +93,14 @@
93 93 // Proxy / CDN: forwarded header to trust for the visitor IP.
94 94 // Empty = trust only REMOTE_ADDR (the real connection, unspoofable).
95 95 'trusted_proxy_header' => '',
96 96
97 + // Proxy / CDN: IPs or CIDR ranges the forwarded header is accepted
98 + // from. Empty = accept it only from your own network, and, for
99 + // CF-Connecting-IP, Cloudflare's own ranges. Since 2.11.9, so a
100 + // header cannot be forged by a visitor reaching the origin directly.
101 + 'trusted_proxies' => array(),
102 +
97 103 // User-Agent management
98 104 'ua_whitelist' => array(),
99 105 'ua_blacklist' => array(),
100 106
@@ -431,11 +437,14 @@
431 437 'auto_scan' => true,
432 438 'scan_frequency' => 'daily',
433 439 'notify_level' => 'suspicious_only',
434 440 'instant_alert' => false,
435 - 'excluded_paths' => array(
436 - 'wp-content/cache',
437 - ),
441 + // Empty for a new installation. It used to start with
442 + // wp-content/cache, from when nothing in the scan looked in
443 + // that folder anyway; the scan now searches it for hidden
444 + // code. A site that has the entry saved keeps it: what is in
445 + // this list is the site's own, and an update does not edit it.
446 + 'excluded_paths' => array(),
438 447 'excluded_extensions' => array(
439 448 // Translations (regenerated per-locale, never in checksums).
440 449 '.po', '.mo', '.pot',
441 450 // Binary images (cosmetic, not executable; often rewritten by image-optimizer plugins).
@@ -552,8 +561,34 @@
552 561
553 562 /**
554 563 * Deep merge arrays
555 564 *
565 + * Associative arrays are merged key by key, because that is what lets a
566 + * saved option keep working when a release adds a setting. Lists are
567 + * replaced whole, and that part is not cosmetic: merging a list key by key
568 + * means index by index, so a stored list shorter than the default came back
569 + * with the default's tail glued to it. Saving "administrator" alone into
570 + * enforced_roles (default administrator, editor) was read back as
571 + * administrator, editor, and unchecking every role was read back as the two
572 + * defaults, so three role pickers could not be narrowed at all. It is the
573 + * same bug merge_preset() was given its own merge for in 2.9.8; the fix
574 + * never reached this one or the one in the admin save.
575 + *
576 + * An empty saved list therefore means empty, not "fall back to the default".
577 + * That is the honest reading of what was saved, and the save path is what
578 + * makes sure a list only becomes empty when somebody emptied it: a list the
579 + * form did not carry is left alone rather than blanked.
580 + *
581 + * Which of the two happens is decided by the DEFAULT, never by what was
582 + * saved. Deciding by what was saved looks equivalent and is not: an empty
583 + * array is a list by any definition, so a section stored as array() would
584 + * have replaced a whole group of settings instead of merging into it, and
585 + * every key of that group would have come back missing. Found by the third
586 + * cross review of this release, measured on rest_api_security (7 keys to 0)
587 + * and on registration_approval (4 to 0, including its enabled flag).
588 + *
589 + * @since 3.0.1 Lists replace instead of merging index by index.
590 + *
556 591 * @param array $defaults Default values.
557 592 * @param array $saved Saved values.
558 593 * @return array Merged array.
559 594 */
@@ -558,17 +593,17 @@
558 593 * @return array Merged array.
559 594 */
560 595 private function array_merge_deep( $defaults, $saved ) {
561 596 $result = $defaults;
562 -
597 +
563 598 foreach ( $saved as $key => $value ) {
564 - if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
599 + if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) && ! self::is_list( $result[ $key ] ) ) {
565 600 $result[ $key ] = $this->array_merge_deep( $result[ $key ], $value );
566 601 } else {
567 602 $result[ $key ] = $value;
568 603 }
569 604 }
570 -
605 +
571 606 return $result;
572 607 }
573 608
574 609 /**
@@ -700,9 +735,12 @@
700 735 * @since 2.10.1
701 736 * @return bool
702 737 */
703 738 public static function owns_shared_files() {
704 - return ! is_multisite() || is_main_site();
739 + // One wp-config.php and one root .htaccess per installation, even with
740 + // several networks in it: is_main_site() alone is true on the main site
741 + // of every network. Since 2.11.8, found by the audit of the network.
742 + return ! is_multisite() || ( is_main_site() && is_main_network() );
705 743 }
706 744
707 745 public static function can_write_shared_files() {
708 746 if ( ! is_multisite() ) {
@@ -708,9 +746,11 @@
708 746 if ( ! is_multisite() ) {
709 747 return true;
710 748 }
711 749
712 - if ( ! is_main_site() ) {
750 + // See owns_shared_files(): the main site of a secondary network, and
751 + // its network administrator, do not own the installation's files.
752 + if ( ! is_main_site() || ! is_main_network() ) {
713 753 return false;
714 754 }
715 755
716 756 // WP-CLI with nobody logged in: there is no user to ask, and the site is
@@ -779,8 +819,15 @@
779 819 * addresses that receive the alerts is another, and nobody presses a button
780 820 * called "restore defaults" expecting that. Both the Standard preset and the
781 821 * two reset buttons leave these alone.
782 822 *
823 + * Read from outside the plugin: the third-party network plugin Vigilante
824 + * Network Sync calls this from its 2.0.3 to decide what NOT to copy between
825 + * the sites of a network, so a key it does not know about is preserved per
826 + * site instead of overwritten. Adding keys here is safe and helps it;
827 + * renaming or removing the method, or changing the shape of what it returns,
828 + * silently changes what that plugin replicates across a whole network.
829 + *
783 830 * @since 2.9.8
784 831 *
785 832 * @return array<string,string[]>
786 833 */
@@ -785,9 +832,9 @@
785 832 * @return array<string,string[]>
786 833 */
787 834 public static function get_user_data_keys() {
788 835 return array(
789 - 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header' ),
836 + 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header', 'trusted_proxies' ),
790 837 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
791 838 'user_security' => array( 'insecure_usernames' ),
792 839 'file_integrity' => array( 'excluded_paths', 'excluded_extensions' ),
793 840 'email' => array( 'additional_recipients' ),
@@ -831,12 +878,28 @@
831 878 * get_shared_file_settings() lists what does nothing but end up in a shared
832 879 * file. These do both: blocking bad bots and bad query strings, the visitor
833 880 * IP detection and the two whitelists run in PHP for the site that stores
834 881 * them, and on the main site of a network they are also what the .htaccess
835 - * rules of every site are generated from; the three module switches decide
836 - * whether the .htaccess blocks and the wp-config.php constants exist at all.
837 - * On a subsite they only act on that site, so they stay editable there.
882 + * rules of every site are generated from; the three writing module switches
883 + * (firewall, security_headers, wp_hardening) decide whether the .htaccess
884 + * blocks and the wp-config.php constants exist at all.
838 885 *
886 + * Since 2.11.8 it also locks what decides whether the shared files are
887 + * WATCHED, not built: the File Integrity module and its scan_critical_config
888 + * switch. On the main site the critical-file scan is the network's canary
889 + * for a change to wp-config.php or the root .htaccess, which only a network
890 + * administrator can approve, so a main-site administrator without network
891 + * rights must not be able to silence it by turning either one off. Closing
892 + * the ignore list and the clear-results button in 2.11.8 left these two as
893 + * the remaining routes; found by the audit of the admin surface.
894 + *
895 + * Since 3.0.0 the self-check has no setting at all, so there is nothing to lock: Vigilant's own
896 + * files are shared by every site, and on the main site the self-check is
897 + * the one that reports a change to them for the whole network.
898 + *
899 + * On a subsite all of them only act on that site, so they stay editable
900 + * there (get_locked_file_settings() adds this set only when owns_shared_files()).
901 + *
839 902 * Until 2.11.6 an administrator of the main site without network rights
840 903 * could change any of them, and the file-only ones too: the write to the
841 904 * file was refused at that moment, but the value stayed stored, and the
842 905 * refresh after the next update, or the next save by a network
@@ -842,15 +905,283 @@
842 905 * refresh after the next update, or the next save by a network
843 906 * administrator, published it to the whole network.
844 907 *
845 908 * @since 2.11.6
909 + * @since 2.11.8 The file_integrity module and scan_critical_config.
846 910 *
847 911 * @return array<string,string[]>
848 912 */
913 + /**
914 + * The two factor policy that governs this installation
915 + *
916 + * On a network the answer must not depend on which site the login happens to
917 + * arrive at, because the cookie WordPress issues does not: COOKIEHASH comes
918 + * from the network siteurl (wp-includes/default-constants.php) and
919 + * COOKIE_DOMAIN covers every host of the network
920 + * (wp-includes/ms-default-constants.php). Until 2.11.10 the settings, the
921 + * enforced roles and the TOTP table were all read per site, so somebody
922 + * holding the password of an administrator protected by 2FA on the main site
923 + * posted the login to a subsite where that account has no role, was never
924 + * asked for a code, and came out with a session valid across the network.
925 + * Measured on the Multisite install on 12 sep 2026 (user_requires_2fa true on
926 + * the main site, false on demo2 for the same account) and found by the
927 + * file-by-file review of 2.11.10.
928 + *
929 + * So the policy of the main site governs the whole network. On a single site
930 + * this is the site's own configuration and nothing changes.
931 + *
932 + * @since 2.11.10
933 + *
934 + * @return array The two_factor section that applies.
935 + */
936 + public static function two_factor_policy() {
937 + $options = is_multisite()
938 + ? get_blog_option( get_main_site_id(), self::OPTION_NAME, array() )
939 + : get_option( self::OPTION_NAME, array() );
940 +
941 + if ( ! is_array( $options ) ) {
942 + return array();
943 + }
944 +
945 + $login = isset( $options['login_security'] ) && is_array( $options['login_security'] )
946 + ? $options['login_security']
947 + : array();
948 +
949 + return ( isset( $login['two_factor'] ) && is_array( $login['two_factor'] ) ) ? $login['two_factor'] : array();
950 + }
951 +
952 + /**
953 + * Whether two factor is required for this account, anywhere in the network
954 + *
955 + * Union, and deliberately so. Reading the policy only from the main site,
956 + * which is what this release did at first, would have switched two factor off
957 + * for every network that has it configured per subsite, which until now was
958 + * the only way it could be configured at all: a silent downgrade of the very
959 + * protection being fixed. Found by the cross review of 2.11.10. So the
960 + * question is asked of every site the account belongs to, plus the main site,
961 + * each with its own enforced roles and exclusions, and one yes is enough.
962 + *
963 + * That also closes the bypass: the settings, the roles and the enrolment used
964 + * to be read from whichever site the login arrived at, while the cookie
965 + * WordPress issues is valid across the whole network (COOKIEHASH comes from
966 + * the network siteurl and COOKIE_DOMAIN covers every host of it), so an
967 + * account protected on one site could log in through another and come out
968 + * with a session valid everywhere.
969 + *
970 + * @since 2.11.10
971 + *
972 + * @param WP_User $user User being authenticated.
973 + * @return bool
974 + */
975 + public static function two_factor_required_for( $user ) {
976 + return array() !== self::two_factor_demands_for( $user );
977 + }
978 +
979 + /**
980 + * Which second factor methods this account is asked for, across the network
981 + *
982 + * Empty when nothing asks. On a network there can be more than one, because
983 + * each site keeps its own settings and the requirement is the union of them.
984 + *
985 + * @since 2.11.10
986 + *
987 + * @param WP_User $user User being authenticated.
988 + * @return string[] Methods asked for, without repeats.
989 + */
990 + public static function two_factor_methods_for( $user ) {
991 + $methods = array();
992 +
993 + foreach ( self::two_factor_demands_for( $user ) as $settings ) {
994 + $method = isset( $settings['method'] ) ? (string) $settings['method'] : 'email';
995 +
996 + // A method this version does not know is read as the default rather
997 + // than left to fall through. Registering nothing at all is how the
998 + // second factor of a whole network went quiet in silence: a saved
999 + // value of '' (which validate_section() lets through on an import,
1000 + // since only the tab has an allowlist) matched neither class, so no
1001 + // filter was registered anywhere while every screen still said two
1002 + // factor was on. Found by the third cross review of 2.11.10.
1003 + $methods[] = in_array( $method, array( 'email', 'totp' ), true ) ? $method : 'email';
1004 + }
1005 +
1006 + return array_values( array_unique( $methods ) );
1007 + }
1008 +
1009 + /**
1010 + * Which of the two second factor classes handles this login
1011 + *
1012 + * The method cannot be read from one site's settings, and reading it from the
1013 + * main site was the hole the third cross review of 2.11.10 found: with the
1014 + * main site on totp and a subsite asking for a code by email, the class that
1015 + * registered was TOTP, the account had no enrolment, and the "not set up yet"
1016 + * branch let the login through. In 2.11.9 that same login was asked for its
1017 + * emailed code. So the question is asked per account, not per site.
1018 + *
1019 + * The order is what keeps it closed at both ends:
1020 + *
1021 + * 1. An enrolment already made wins while some site asking for a second
1022 + * factor asks for an authenticator app. It is the strongest factor the
1023 + * account has and it is ready to use, wherever in the network it was set
1024 + * up.
1025 + * 2. Otherwise, if any site asking for a second factor asks for email, email
1026 + * handles it. Email needs no enrolment, so it can never fall into the
1027 + * branch that lets a login through for lack of one.
1028 + * 3. Only when every site asking wants an authenticator app does TOTP handle
1029 + * it, which is the case the grace period was written for.
1030 + *
1031 + * The condition on the first step came in 2.11.11. Without it an enrolment
1032 + * left from a time when the site asked for an app outranked the method the
1033 + * site asks for now: a single site set to email asked those accounts for an
1034 + * authenticator code, which 2.11.9 never did and which locks out whoever
1035 + * removed the app after the switch. Dropping that enrolment opens nothing,
1036 + * because the account then goes to email, which needs no enrolment.
1037 + *
1038 + * @since 2.11.10
1039 + * @since 2.11.11 An enrolment only wins while an authenticator app is asked for.
1040 + *
1041 + * @param WP_User $user User being authenticated.
1042 + * @param bool $enrolled Whether the account has a TOTP enrolment anywhere.
1043 + * @return string 'email', 'totp', or '' when nothing asks.
1044 + */
1045 + public static function two_factor_handler_for( $user, $enrolled ) {
1046 + $methods = self::two_factor_methods_for( $user );
1047 +
1048 + if ( ! $methods ) {
1049 + return '';
1050 + }
1051 +
1052 + if ( $enrolled && in_array( 'totp', $methods, true ) ) {
1053 + return 'totp';
1054 + }
1055 +
1056 + return in_array( 'email', $methods, true ) ? 'email' : 'totp';
1057 + }
1058 +
1059 + /**
1060 + * The two factor settings of every site that asks this account for one
1061 + *
1062 + * @since 2.11.10
1063 + *
1064 + * @param WP_User $user User being authenticated.
1065 + * @return array[] The two_factor section of each site that asks.
1066 + */
1067 + private static function two_factor_demands_for( $user ) {
1068 + if ( empty( $user->ID ) ) {
1069 + return array();
1070 + }
1071 +
1072 + if ( ! is_multisite() ) {
1073 + $roles = ( isset( $user->roles ) && is_array( $user->roles ) ) ? $user->roles : array();
1074 + $policy = self::two_factor_policy();
1075 +
1076 + return self::two_factor_site_requires( $policy, $user, $roles ) ? array( $policy ) : array();
1077 + }
1078 +
1079 + $demands = array();
1080 + $blog_ids = array( (int) get_main_site_id() );
1081 +
1082 + /*
1083 + * A super administrator is a member of almost no site (measured on the
1084 + * Multisite install: of three sites, the network owner belongs to one),
1085 + * yet can log in through any of them and the cookie is valid everywhere.
1086 + * Asking only the sites they belong to left the account with the most
1087 + * power in the network outside the policy, which is the bypass upside
1088 + * down. So for them every site of the network is consulted. There are
1089 + * few super administrators. It does not only run at login, though, which
1090 + * this note claimed until 2.11.11: the dashboard hooks of the TOTP class
1091 + * ask it on every admin screen of every site of a network, at least once
1092 + * per hook.
1093 + */
1094 + if ( is_super_admin( $user->ID ) ) {
1095 + $blog_ids = array_merge( $blog_ids, get_sites( array( 'fields' => 'ids', 'number' => 200 ) ) );
1096 + }
1097 +
1098 + foreach ( get_blogs_of_user( $user->ID ) as $blog ) {
1099 + if ( ! empty( $blog->userblog_id ) ) {
1100 + $blog_ids[] = (int) $blog->userblog_id;
1101 + }
1102 + }
1103 +
1104 + foreach ( array_unique( $blog_ids ) as $blog_id ) {
1105 + $options = get_blog_option( $blog_id, self::OPTION_NAME, array() );
1106 +
1107 + if ( ! is_array( $options ) || empty( $options['login_security']['two_factor'] ) ) {
1108 + continue;
1109 + }
1110 +
1111 + /*
1112 + * A site whose Login Security module is off asks for nothing, and
1113 + * reading only the sub-setting made it ask anyway: a subsite that had
1114 + * switched the whole module off, leaving an orphan two_factor.enabled
1115 + * behind, imposed a second factor on every account of the network,
1116 + * with no screen anywhere explaining why. It is the two-level toggle
1117 + * trap of this plugin read upside down. Found by the third cross
1118 + * review of 2.11.10.
1119 + */
1120 + if ( empty( $options['modules']['login_security'] ) ) {
1121 + continue;
1122 + }
1123 +
1124 + $elsewhere = new WP_User( $user->ID );
1125 + $elsewhere->for_site( $blog_id );
1126 +
1127 + // A super administrator can hold no role row anywhere, and is judged
1128 + // as an administrator so the strictest policy of the network reaches
1129 + // the account with the most power in it.
1130 + $roles = ( isset( $elsewhere->roles ) && is_array( $elsewhere->roles ) && $elsewhere->roles )
1131 + ? $elsewhere->roles
1132 + : ( is_super_admin( $user->ID ) ? array( 'administrator' ) : array() );
1133 +
1134 + if ( self::two_factor_site_requires( $options['login_security']['two_factor'], $user, $roles ) ) {
1135 + $demands[] = $options['login_security']['two_factor'];
1136 + }
1137 + }
1138 +
1139 + return $demands;
1140 + }
1141 +
1142 + /**
1143 + * Whether one site's two factor settings cover this account
1144 + *
1145 + * @since 2.11.10
1146 + *
1147 + * @param array $settings The two_factor section of one site.
1148 + * @param WP_User $user User being authenticated.
1149 + * @param string[] $roles Roles the account holds on that site.
1150 + * @return bool
1151 + */
1152 + private static function two_factor_site_requires( $settings, $user, $roles ) {
1153 + if ( ! is_array( $settings ) || empty( $settings['enabled'] ) ) {
1154 + return false;
1155 + }
1156 +
1157 + $excluded = isset( $settings['excluded_users'] ) ? array_map( 'absint', (array) $settings['excluded_users'] ) : array();
1158 +
1159 + if ( in_array( (int) $user->ID, $excluded, true ) ) {
1160 + return false;
1161 + }
1162 +
1163 + $enforced = isset( $settings['enforced_roles'] ) ? (array) $settings['enforced_roles'] : array( 'administrator', 'editor' );
1164 +
1165 + return (bool) array_intersect( (array) $roles, $enforced );
1166 + }
1167 +
1168 + /**
1169 + * Settings that only a network administrator may change on the main site
1170 + *
1171 + * @return array<string,string[]>
1172 + */
849 1173 public static function get_main_site_file_settings() {
850 1174 return array(
851 - 'modules' => array( 'firewall', 'security_headers', 'wp_hardening' ),
852 - 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'ip_whitelist', 'ua_whitelist' ),
1175 + 'modules' => array( 'firewall', 'security_headers', 'wp_hardening', 'file_integrity' ),
1176 + // trusted_proxies goes with trusted_proxy_header, and leaving it out
1177 + // was a hole: the header decides which address the firewall of the
1178 + // whole installation acts on, and this list decides which peers may
1179 + // set that header. An administrator of the main site without network
1180 + // rights who could edit only this half turned every visitor into a
1181 + // trusted proxy. Found by the file-by-file review of 2.11.10.
1182 + 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'trusted_proxies', 'ip_whitelist', 'ua_whitelist' ),
1183 + 'file_integrity' => array( 'scan_critical_config' ),
853 1184 );
854 1185 }
855 1186
856 1187 /**
@@ -900,13 +1231,29 @@
900 1231 */
901 1232 public static function keep_locked_file_settings( $options, $stored ) {
902 1233 $options = is_array( $options ) ? $options : array();
903 1234 $stored = is_array( $stored ) ? $stored : array();
1235 + $locked = self::get_locked_file_settings();
904 1236
905 - foreach ( self::get_locked_file_settings() as $section => $keys ) {
1237 + if ( ! $locked ) {
1238 + return $options;
1239 + }
1240 +
1241 + /*
1242 + * A key that was never stored takes its default, which is what it was
1243 + * worth before. Until 2.11.8 it was dropped instead, and the sanitize
1244 + * callback of the option filled it in again, but validate_options()
1245 + * fills a missing module switch with false, not with its default.
1246 + */
1247 + $instance = new self();
1248 + $defaults = $instance->get_default_options();
1249 +
1250 + foreach ( $locked as $section => $keys ) {
906 1251 if ( true === $keys ) {
907 1252 if ( array_key_exists( $section, $stored ) ) {
908 1253 $options[ $section ] = $stored[ $section ];
1254 + } elseif ( isset( $defaults[ $section ] ) ) {
1255 + $options[ $section ] = $defaults[ $section ];
909 1256 } else {
910 1257 unset( $options[ $section ] );
911 1258 }
912 1259 continue;
@@ -911,19 +1258,27 @@
911 1258 }
912 1259 continue;
913 1260 }
914 1261
915 - $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
1262 + $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
1263 + $default_section = ( isset( $defaults[ $section ] ) && is_array( $defaults[ $section ] ) ) ? $defaults[ $section ] : array();
916 1264
917 1265 foreach ( $keys as $key ) {
918 1266 if ( array_key_exists( $key, $stored_section ) ) {
919 - if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
920 - $options[ $section ] = array();
1267 + $value = $stored_section[ $key ];
1268 + } elseif ( array_key_exists( $key, $default_section ) ) {
1269 + $value = $default_section[ $key ];
1270 + } else {
1271 + if ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
1272 + unset( $options[ $section ][ $key ] );
921 1273 }
922 - $options[ $section ][ $key ] = $stored_section[ $key ];
923 - } elseif ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
924 - unset( $options[ $section ][ $key ] );
1274 + continue;
925 1275 }
1276 +
1277 + if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
1278 + $options[ $section ] = array();
1279 + }
1280 + $options[ $section ][ $key ] = $value;
926 1281 }
927 1282 }
928 1283
929 1284 return $options;
@@ -929,8 +1284,65 @@
929 1284 return $options;
930 1285 }
931 1286
932 1287 /**
1288 + * Take a lock kept as a row of the options table, or report that another request holds it
1289 + *
1290 + * add_option() cannot be a lock: it runs INSERT ... ON DUPLICATE KEY UPDATE
1291 + * (wp-includes/option.php:1142 in WP 7.1), so two requests that both find
1292 + * the option missing both "create" it and both believe they hold it. INSERT
1293 + * IGNORE creates the row for exactly one of them, which is what core does in
1294 + * WP_Upgrader::create_lock() (wp-admin/includes/class-wp-upgrader.php:1065).
1295 + * A lock older than the timeout counts as abandoned, by a fatal error between
1296 + * taking and releasing it, and only one request takes it over.
1297 + *
1298 + * @since 2.11.8
1299 + *
1300 + * @param string $name Option name of the lock, in the current site's table.
1301 + * @param int $timeout Seconds after which a held lock counts as abandoned.
1302 + * @return bool True if this request now holds the lock.
1303 + */
1304 + public static function acquire_option_lock( $name, $timeout ) {
1305 + global $wpdb;
1306 +
1307 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic lock needs INSERT IGNORE, which the options API does not offer; same query as WP_Upgrader::create_lock().
1308 + if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) ) ) {
1309 + wp_cache_delete( $name, 'options' );
1310 + return true;
1311 + }
1312 +
1313 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock row as stored right now, not a cached copy.
1314 + $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1315 +
1316 + if ( null === $held || ( time() - (int) $held ) < $timeout ) {
1317 + return false;
1318 + }
1319 +
1320 + // Abandoned: the delete only matches the value that was read, and only one
1321 + // request wins the insert that follows.
1322 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes an abandoned lock row.
1323 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $held ) );
1324 +
1325 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same atomic insert as above.
1326 + return (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) );
1327 + }
1328 +
1329 + /**
1330 + * Release a lock taken with acquire_option_lock()
1331 + *
1332 + * @since 2.11.8
1333 + *
1334 + * @param string $name Option name of the lock.
1335 + */
1336 + public static function release_option_lock( $name ) {
1337 + global $wpdb;
1338 +
1339 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes the row acquire_option_lock() inserted.
1340 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1341 + wp_cache_delete( $name, 'options' );
1342 + }
1343 +
1344 + /**
933 1345 * Put a configuration back to the defaults without deleting what the owner typed
934 1346 *
935 1347 * @since 2.9.8
936 1348 *
@@ -1024,8 +1436,55 @@
1024 1436 return $base;
1025 1437 }
1026 1438
1027 1439 /**
1440 + * A stored list, or the shipped one when it arrives empty
1441 + *
1442 + * For the four lists that have NO field on any screen: the allowed HTTP
1443 + * methods, the insecure usernames, the public REST endpoints and the roles
1444 + * of registration approval. Nobody can empty those from the interface, so
1445 + * an empty one is a leftover, and every version up to 3.0.0 produced them:
1446 + * saving any tab wrote array() over every list of that section whose key the
1447 + * form did not carry, which for these four was always. Reading the settings
1448 + * hid it by merging the shipped values on top, and 3.0.1 stops doing that,
1449 + * because a stored empty list is what makes unticking every role work.
1450 + *
1451 + * The migration in vigilante.php removes those keys once. This is the line
1452 + * of defence for every other route, including Under Attack, which
1453 + * photographs the raw option when it is switched on and puts it back whole
1454 + * when it expires, after the migration has already marked itself done.
1455 + *
1456 + * It goes through every reader of those four lists, the ones that enforce
1457 + * and the ones that only report, because a screen that says "no insecure
1458 + * user" while the check is blocking them is its own kind of wrong. The
1459 + * shipped value is read from get_default_options() rather than written out
1460 + * again here: a second copy of a list drifts from the first.
1461 + *
1462 + * @since 3.0.1
1463 + *
1464 + * @param mixed $value Stored value, as the caller already has it.
1465 + * @param string[] $path Path of the setting in the options array.
1466 + * @return array
1467 + */
1468 + public static function list_or_shipped( $value, $path ) {
1469 + if ( is_array( $value ) && array() !== $value ) {
1470 + return $value;
1471 + }
1472 +
1473 + $instance = new self();
1474 + $shipped = $instance->get_default_options();
1475 +
1476 + foreach ( (array) $path as $key ) {
1477 + if ( ! is_array( $shipped ) || ! array_key_exists( $key, $shipped ) ) {
1478 + return array();
1479 + }
1480 + $shipped = $shipped[ $key ];
1481 + }
1482 +
1483 + return is_array( $shipped ) ? $shipped : array();
1484 + }
1485 +
1486 + /**
1028 1487 * Whether an array is a plain list (0..n-1 keys)
1029 1488 *
1030 1489 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1031 1490 *
@@ -1030,12 +1489,16 @@
1030 1489 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1031 1490 *
1032 1491 * @since 2.9.8
1033 1492 *
1493 + * Public since 3.0.1 because the admin save path needs the same answer: the
1494 + * two places that used to merge lists index by index have to agree on what
1495 + * a list is, or they disagree on a different subset of the settings.
1496 + *
1034 1497 * @param array $value Array to inspect.
1035 1498 * @return bool
1036 1499 */
1037 - private static function is_list( $value ) {
1500 + public static function is_list( $value ) {
1038 1501 if ( array() === $value ) {
1039 1502 return true;
1040 1503 }
1041 1504
@@ -1251,8 +1714,171 @@
1251 1714 );
1252 1715 }
1253 1716
1254 1717 /**
1718 + * The settings that hold a list of file paths, by section
1719 + *
1720 + * A path is read by what it is. sanitize_text_field() deletes every %XX octet
1721 + * and, when it finds one, collapses repeated spaces, so an exclusion written
1722 + * for a folder called foto%20x was saved as fotox and excluded nothing: the
1723 + * scan compares what is stored with the paths it finds, one by one (measured
1724 + * on 1 oct 2026 against 3.0.1). Every other list in the options is a list of
1725 + * addresses, names or extensions, where nothing meaningful is lost, and keeps
1726 + * its reader.
1727 + *
1728 + * @since 3.0.2
1729 + *
1730 + * @return array<string,string[]>
1731 + */
1732 + public static function path_list_keys() {
1733 + return array(
1734 + 'file_integrity' => array( 'excluded_paths' ),
1735 + );
1736 + }
1737 +
1738 + /**
1739 + * One path as it was typed: invalid UTF-8 out, the ends trimmed, and nothing
1740 + * else touched. Invalid UTF-8 has to go because the option would not be
1741 + * written with it.
1742 + *
1743 + * A line that has tags (or a stray <) is dropped whole, not cut. A path has
1744 + * none, and wp_strip_all_tags() would cut it at the < and leave a shorter path
1745 + * than the one written, which excludes more than anyone asked: a folder
1746 + * called a<b would exclude a. Until 3.0.1 the < was kept as an entity, which
1747 + * matched nothing, and dropping the line has the same effect.
1748 + *
1749 + * A line with a line break inside is dropped too. The textarea is read line by
1750 + * line, so only an element of a list can have one (an export edited by hand),
1751 + * and the next save of the tab would show it as two lines and store two paths:
1752 + * a, a line break and wp-content would end up excluding all of wp-content.
1753 + * 3.0.1 turned the break into a space, which matched nothing, and dropping the
1754 + * line has the same effect (fourth cross review, 1 oct 2026).
1755 + *
1756 + * @since 3.0.2
1757 + *
1758 + * @param mixed $line Entry.
1759 + * @return string
1760 + */
1761 + public static function sanitize_path_line( $line ) {
1762 + if ( ! is_scalar( $line ) ) {
1763 + return '';
1764 + }
1765 +
1766 + $line = trim( wp_check_invalid_utf8( (string) $line, true ) );
1767 +
1768 + if ( false !== strpbrk( $line, "\r\n" ) ) {
1769 + return '';
1770 + }
1771 +
1772 + return wp_strip_all_tags( $line ) === $line ? $line : '';
1773 + }
1774 +
1775 + /**
1776 + * The text of a textarea of paths, one per line, each line read as one path.
1777 + *
1778 + * Line by line, because a stray < in one line would take the lines after it
1779 + * with it if the whole text went through wp_strip_all_tags().
1780 + *
1781 + * @since 3.0.2
1782 + *
1783 + * @param mixed $text Text.
1784 + * @return string
1785 + */
1786 + public static function sanitize_path_text( $text ) {
1787 + $lines = preg_split( '/\r\n|\r|\n/', (string) $text );
1788 +
1789 + return implode( "\n", array_map( array( __CLASS__, 'sanitize_path_line' ), (array) $lines ) );
1790 + }
1791 +
1792 + /**
1793 + * Take the lists of paths out of a settings tree before the generic sanitizer reads it
1794 + *
1795 + * Known limits (fourth cross review, 1 oct 2026), none reachable from the
1796 + * settings screen and all on the side of watching more: process_section_data()
1797 + * reads the entries of a list posted as an array (excluded_paths[]=) with
1798 + * sanitize_text_field() and applies sanitize_key() to the keys, so a key
1799 + * written with another spelling is not found here, and a form posted as an
1800 + * array instead of a string goes through a generic sanitizer. In those three
1801 + * the %XX are lost, as they were in 3.0.1.
1802 + *
1803 + * @since 3.0.2
1804 + *
1805 + * @param mixed $data Settings tree: section => key => value.
1806 + * @return array Only the path lists that are present, in the same shape, each already read as paths.
1807 + */
1808 + public static function take_path_lists( $data ) {
1809 + $taken = array();
1810 +
1811 + if ( ! is_array( $data ) ) {
1812 + return $taken;
1813 + }
1814 +
1815 + foreach ( self::path_list_keys() as $section => $keys ) {
1816 + if ( ! isset( $data[ $section ] ) || ! is_array( $data[ $section ] ) ) {
1817 + continue;
1818 + }
1819 +
1820 + foreach ( $keys as $key ) {
1821 + if ( ! isset( $data[ $section ][ $key ] ) ) {
1822 + continue;
1823 + }
1824 +
1825 + $value = $data[ $section ][ $key ];
1826 +
1827 + if ( is_array( $value ) ) {
1828 + $taken[ $section ][ $key ] = array_map( array( __CLASS__, 'sanitize_path_line' ), array_values( array_filter( $value, 'is_scalar' ) ) );
1829 + } elseif ( is_scalar( $value ) ) {
1830 + $taken[ $section ][ $key ] = self::sanitize_path_text( $value );
1831 + }
1832 + }
1833 + }
1834 +
1835 + return $taken;
1836 + }
1837 +
1838 + /**
1839 + * Put back the lists of paths take_path_lists() took out, over what the generic sanitizer left
1840 + *
1841 + * @since 3.0.2
1842 + *
1843 + * @param mixed $data Sanitized settings tree.
1844 + * @param array $taken What take_path_lists() returned for the same tree.
1845 + * @return mixed The tree, with the path lists as they were read.
1846 + */
1847 + public static function restore_path_lists( $data, $taken ) {
1848 + if ( ! is_array( $data ) ) {
1849 + return $data;
1850 + }
1851 +
1852 + foreach ( $taken as $section => $values ) {
1853 + if ( ! isset( $data[ $section ] ) || ! is_array( $data[ $section ] ) ) {
1854 + continue;
1855 + }
1856 +
1857 + foreach ( $values as $key => $value ) {
1858 + $data[ $section ][ $key ] = $value;
1859 + }
1860 + }
1861 +
1862 + return $data;
1863 + }
1864 +
1865 + /**
1866 + * Whether a position in the options, as section.key, is a list of paths
1867 + *
1868 + * @since 3.0.2
1869 + *
1870 + * @param string $trail Section and key joined by a dot.
1871 + * @return bool
1872 + */
1873 + private static function is_path_list( $trail ) {
1874 + $parts = explode( '.', (string) $trail, 2 );
1875 + $paths = self::path_list_keys();
1876 +
1877 + return 2 === count( $parts ) && isset( $paths[ $parts[0] ] ) && in_array( $parts[1], $paths[ $parts[0] ], true );
1878 + }
1879 +
1880 + /**
1255 1881 * Validate options before saving
1256 1882 *
1257 1883 * @param array $input Raw input to validate.
1258 1884 * @return array Validated options.
@@ -1275,9 +1901,9 @@
1275 1901 : false;
1276 1902 }
1277 1903 } elseif ( isset( $defaults[ $section ] ) ) {
1278 1904 // Validate other sections using generic validator
1279 - $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] );
1905 + $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ], $section );
1280 1906
1281 1907 // The few keys that live outside get_default_options() on
1282 1908 // purpose (see apply_install_tweaks()) survive with their own
1283 1909 // validation, or an import would silently lose them and the
@@ -1294,8 +1920,28 @@
1294 1920 }
1295 1921 }
1296 1922 }
1297 1923
1924 + /*
1925 + * The lock on the settings the shared files are built from is NOT applied
1926 + * here, and that is a decision, not an oversight. The second cross review
1927 + * of 2.11.10 raised that register_setting( 'vigilante_options', ... )
1928 + * declares this as its sanitize callback with no lock in it, so anything
1929 + * reaching options.php with that option group would write the whole
1930 + * option. The chain does not close: the plugin prints no settings_fields()
1931 + * for that group anywhere, so the nonce it would need is not obtainable,
1932 + * and the five places that do save (saving a tab, importing, a preset,
1933 + * restoring the defaults, resetting a section) all apply
1934 + * keep_locked_file_settings() themselves.
1935 + *
1936 + * Putting it here instead would be worse than the door it closes. A
1937 + * register_setting() callback runs on EVERY update_option() of this
1938 + * option, so it would also lock the writes with no user behind them: the
1939 + * expiry of Under Attack restoring what it hardened, WP-CLI and cron.
1940 + * Those are already covered by matriz-red-ajustes-compartidos.sh, which
1941 + * is where such a change would show up as a row that stopped passing.
1942 + */
1943 +
1298 1944 return apply_filters( 'vigilante_validate_options', $validated, $input );
1299 1945 }
1300 1946
1301 1947 /**
@@ -1350,13 +1996,14 @@
1350 1996 * could merge any key it liked into vigilante_options (S7 of the 28 Aug
1351 1997 * 2026 audit). Lists are the exception, handled first: their entries are
1352 1998 * data, not keys.
1353 1999 *
1354 - * @param array $input Input values.
1355 - * @param array $defaults Default values.
2000 + * @param array $input Input values.
2001 + * @param array $defaults Default values.
2002 + * @param string $trail Where this is in the options, as section.key, so a list of paths is read as paths.
1356 2003 * @return array Validated values.
1357 2004 */
1358 - private function validate_section( $input, $defaults ) {
2005 + private function validate_section( $input, $defaults, $trail = '' ) {
1359 2006 $validated = array();
1360 2007
1361 2008 if ( $this->is_list_default( $defaults ) ) {
1362 2009 if ( ! is_array( $input ) ) {
@@ -1362,12 +2009,13 @@
1362 2009 if ( ! is_array( $input ) ) {
1363 2010 return array();
1364 2011 }
1365 2012
1366 - $list = array();
2013 + $is_paths = self::is_path_list( $trail );
2014 + $list = array();
1367 2015 foreach ( $input as $value ) {
1368 2016 if ( is_scalar( $value ) ) {
1369 - $list[] = sanitize_text_field( (string) $value );
2017 + $list[] = $is_paths ? self::sanitize_path_line( $value ) : sanitize_text_field( (string) $value );
1370 2018 } elseif ( is_array( $value ) ) {
1371 2019 $list[] = map_deep( $value, 'sanitize_text_field' );
1372 2020 }
1373 2021 }
@@ -1388,9 +2036,9 @@
1388 2036 } elseif ( is_int( $default_value ) ) {
1389 2037 $validated[ $key ] = intval( $value );
1390 2038 } elseif ( is_array( $default_value ) ) {
1391 2039 if ( is_array( $value ) ) {
1392 - $validated[ $key ] = $this->validate_section( $value, $default_value );
2040 + $validated[ $key ] = $this->validate_section( $value, $default_value, '' === $trail ? (string) $key : $trail . '.' . $key );
1393 2041 } else {
1394 2042 $validated[ $key ] = $default_value;
1395 2043 }
1396 2044 } else {