PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
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 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-settings.php +609 -14 2.9.92.11.12 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
@@ -683,14 +689,39 @@
683 689 * @since 2.9.8
684 690 *
685 691 * @return bool
686 692 */
693 + /**
694 + * Whether this site is the one that owns the files a network shares.
695 + *
696 + * Pure site identity, with no capability in it, and that is the point. A
697 + * refresh that Vigilant performs by itself, such as rewriting its own
698 + * .htaccess block after an update, decides nothing: the content comes from
699 + * this site's own options whoever happens to be visiting. What must not
700 + * happen is a *different* site writing the shared file, and that is exactly
701 + * what this answers.
702 + *
703 + * can_write_shared_files() below adds the capability on top, and is the
704 + * right question for anything a person initiates from a settings screen.
705 + *
706 + * @since 2.10.1
707 + * @return bool
708 + */
709 + public static function owns_shared_files() {
710 + // One wp-config.php and one root .htaccess per installation, even with
711 + // several networks in it: is_main_site() alone is true on the main site
712 + // of every network. Since 2.11.8, found by the audit of the network.
713 + return ! is_multisite() || ( is_main_site() && is_main_network() );
714 + }
715 +
687 716 public static function can_write_shared_files() {
688 717 if ( ! is_multisite() ) {
689 718 return true;
690 719 }
691 720
692 - if ( ! is_main_site() ) {
721 + // See owns_shared_files(): the main site of a secondary network, and
722 + // its network administrator, do not own the installation's files.
723 + if ( ! is_main_site() || ! is_main_network() ) {
693 724 return false;
694 725 }
695 726
696 727 // WP-CLI with nobody logged in: there is no user to ask, and the site is
@@ -765,9 +796,9 @@
765 796 * @return array<string,string[]>
766 797 */
767 798 public static function get_user_data_keys() {
768 799 return array(
769 - 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header' ),
800 + 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header', 'trusted_proxies' ),
770 801 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
771 802 'user_security' => array( 'insecure_usernames' ),
772 803 'file_integrity' => array( 'excluded_paths', 'excluded_extensions' ),
773 804 'email' => array( 'additional_recipients' ),
@@ -784,10 +815,15 @@
784 815 *
785 816 * Note this is not every setting that reaches .htaccess. Blocking bad bots
786 817 * or empty user agents also runs in PHP, per site, so those stay editable on
787 818 * a subsite: the PHP half protects that site and the .htaccess half is
788 - * refused, leaving the main site's rules standing.
819 + * refused, leaving the main site's rules standing. On the main site they
820 + * are locked too, see get_main_site_file_settings().
789 821 *
822 + * The PHP blocks for plugins and themes have no field on the settings
823 + * screen, but an imported file carries them, and readme.html and
824 + * license.txt are removed from the root the whole network shares.
825 + *
790 826 * @since 2.9.8
791 827 *
792 828 * @return array<string,true|string[]>
793 829 */
@@ -794,13 +830,479 @@
794 830 public static function get_shared_file_settings() {
795 831 return array(
796 832 'security_headers' => true,
797 833 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
798 - 'firewall' => array( 'disable_directory_browsing', 'protect_wp_config', 'protect_wp_includes', 'protect_uploads_php', 'protect_sensitive_files', 'protect_wp_cron', 'limit_http_methods' ),
834 + 'firewall' => array( 'disable_directory_browsing', 'protect_wp_config', 'protect_wp_includes', 'protect_uploads_php', 'protect_sensitive_files', 'protect_wp_cron', 'limit_http_methods', 'block_php_in_plugins', 'block_php_in_themes' ),
835 + 'advanced' => array( 'remove_readme', 'remove_license' ),
799 836 );
800 837 }
801 838
802 839 /**
840 + * Settings the shared files are built from that also act on the site storing them
841 + *
842 + * get_shared_file_settings() lists what does nothing but end up in a shared
843 + * file. These do both: blocking bad bots and bad query strings, the visitor
844 + * IP detection and the two whitelists run in PHP for the site that stores
845 + * them, and on the main site of a network they are also what the .htaccess
846 + * rules of every site are generated from; the three writing module switches
847 + * (firewall, security_headers, wp_hardening) decide whether the .htaccess
848 + * blocks and the wp-config.php constants exist at all.
849 + *
850 + * Since 2.11.8 it also locks what decides whether the shared files are
851 + * WATCHED, not built: the File Integrity module and its scan_critical_config
852 + * switch. On the main site the critical-file scan is the network's canary
853 + * for a change to wp-config.php or the root .htaccess, which only a network
854 + * administrator can approve, so a main-site administrator without network
855 + * rights must not be able to silence it by turning either one off. Closing
856 + * the ignore list and the clear-results button in 2.11.8 left these two as
857 + * the remaining routes; found by the audit of the admin surface.
858 + *
859 + * On a subsite all of them only act on that site, so they stay editable
860 + * there (get_locked_file_settings() adds this set only when owns_shared_files()).
861 + *
862 + * Until 2.11.6 an administrator of the main site without network rights
863 + * could change any of them, and the file-only ones too: the write to the
864 + * file was refused at that moment, but the value stayed stored, and the
865 + * refresh after the next update, or the next save by a network
866 + * administrator, published it to the whole network.
867 + *
868 + * @since 2.11.6
869 + * @since 2.11.8 The file_integrity module and scan_critical_config.
870 + *
871 + * @return array<string,string[]>
872 + */
873 + /**
874 + * The two factor policy that governs this installation
875 + *
876 + * On a network the answer must not depend on which site the login happens to
877 + * arrive at, because the cookie WordPress issues does not: COOKIEHASH comes
878 + * from the network siteurl (wp-includes/default-constants.php) and
879 + * COOKIE_DOMAIN covers every host of the network
880 + * (wp-includes/ms-default-constants.php). Until 2.11.10 the settings, the
881 + * enforced roles and the TOTP table were all read per site, so somebody
882 + * holding the password of an administrator protected by 2FA on the main site
883 + * posted the login to a subsite where that account has no role, was never
884 + * asked for a code, and came out with a session valid across the network.
885 + * Measured on the Multisite install on 12 sep 2026 (user_requires_2fa true on
886 + * the main site, false on demo2 for the same account) and found by the
887 + * file-by-file review of 2.11.10.
888 + *
889 + * So the policy of the main site governs the whole network. On a single site
890 + * this is the site's own configuration and nothing changes.
891 + *
892 + * @since 2.11.10
893 + *
894 + * @return array The two_factor section that applies.
895 + */
896 + public static function two_factor_policy() {
897 + $options = is_multisite()
898 + ? get_blog_option( get_main_site_id(), self::OPTION_NAME, array() )
899 + : get_option( self::OPTION_NAME, array() );
900 +
901 + if ( ! is_array( $options ) ) {
902 + return array();
903 + }
904 +
905 + $login = isset( $options['login_security'] ) && is_array( $options['login_security'] )
906 + ? $options['login_security']
907 + : array();
908 +
909 + return ( isset( $login['two_factor'] ) && is_array( $login['two_factor'] ) ) ? $login['two_factor'] : array();
910 + }
911 +
912 + /**
913 + * Whether two factor is required for this account, anywhere in the network
914 + *
915 + * Union, and deliberately so. Reading the policy only from the main site,
916 + * which is what this release did at first, would have switched two factor off
917 + * for every network that has it configured per subsite, which until now was
918 + * the only way it could be configured at all: a silent downgrade of the very
919 + * protection being fixed. Found by the cross review of 2.11.10. So the
920 + * question is asked of every site the account belongs to, plus the main site,
921 + * each with its own enforced roles and exclusions, and one yes is enough.
922 + *
923 + * That also closes the bypass: the settings, the roles and the enrolment used
924 + * to be read from whichever site the login arrived at, while the cookie
925 + * WordPress issues is valid across the whole network (COOKIEHASH comes from
926 + * the network siteurl and COOKIE_DOMAIN covers every host of it), so an
927 + * account protected on one site could log in through another and come out
928 + * with a session valid everywhere.
929 + *
930 + * @since 2.11.10
931 + *
932 + * @param WP_User $user User being authenticated.
933 + * @return bool
934 + */
935 + public static function two_factor_required_for( $user ) {
936 + return array() !== self::two_factor_demands_for( $user );
937 + }
938 +
939 + /**
940 + * Which second factor methods this account is asked for, across the network
941 + *
942 + * Empty when nothing asks. On a network there can be more than one, because
943 + * each site keeps its own settings and the requirement is the union of them.
944 + *
945 + * @since 2.11.10
946 + *
947 + * @param WP_User $user User being authenticated.
948 + * @return string[] Methods asked for, without repeats.
949 + */
950 + public static function two_factor_methods_for( $user ) {
951 + $methods = array();
952 +
953 + foreach ( self::two_factor_demands_for( $user ) as $settings ) {
954 + $method = isset( $settings['method'] ) ? (string) $settings['method'] : 'email';
955 +
956 + // A method this version does not know is read as the default rather
957 + // than left to fall through. Registering nothing at all is how the
958 + // second factor of a whole network went quiet in silence: a saved
959 + // value of '' (which validate_section() lets through on an import,
960 + // since only the tab has an allowlist) matched neither class, so no
961 + // filter was registered anywhere while every screen still said two
962 + // factor was on. Found by the third cross review of 2.11.10.
963 + $methods[] = in_array( $method, array( 'email', 'totp' ), true ) ? $method : 'email';
964 + }
965 +
966 + return array_values( array_unique( $methods ) );
967 + }
968 +
969 + /**
970 + * Which of the two second factor classes handles this login
971 + *
972 + * The method cannot be read from one site's settings, and reading it from the
973 + * main site was the hole the third cross review of 2.11.10 found: with the
974 + * main site on totp and a subsite asking for a code by email, the class that
975 + * registered was TOTP, the account had no enrolment, and the "not set up yet"
976 + * branch let the login through. In 2.11.9 that same login was asked for its
977 + * emailed code. So the question is asked per account, not per site.
978 + *
979 + * The order is what keeps it closed at both ends:
980 + *
981 + * 1. An enrolment already made wins while some site asking for a second
982 + * factor asks for an authenticator app. It is the strongest factor the
983 + * account has and it is ready to use, wherever in the network it was set
984 + * up.
985 + * 2. Otherwise, if any site asking for a second factor asks for email, email
986 + * handles it. Email needs no enrolment, so it can never fall into the
987 + * branch that lets a login through for lack of one.
988 + * 3. Only when every site asking wants an authenticator app does TOTP handle
989 + * it, which is the case the grace period was written for.
990 + *
991 + * The condition on the first step came in 2.11.11. Without it an enrolment
992 + * left from a time when the site asked for an app outranked the method the
993 + * site asks for now: a single site set to email asked those accounts for an
994 + * authenticator code, which 2.11.9 never did and which locks out whoever
995 + * removed the app after the switch. Dropping that enrolment opens nothing,
996 + * because the account then goes to email, which needs no enrolment.
997 + *
998 + * @since 2.11.10
999 + * @since 2.11.11 An enrolment only wins while an authenticator app is asked for.
1000 + *
1001 + * @param WP_User $user User being authenticated.
1002 + * @param bool $enrolled Whether the account has a TOTP enrolment anywhere.
1003 + * @return string 'email', 'totp', or '' when nothing asks.
1004 + */
1005 + public static function two_factor_handler_for( $user, $enrolled ) {
1006 + $methods = self::two_factor_methods_for( $user );
1007 +
1008 + if ( ! $methods ) {
1009 + return '';
1010 + }
1011 +
1012 + if ( $enrolled && in_array( 'totp', $methods, true ) ) {
1013 + return 'totp';
1014 + }
1015 +
1016 + return in_array( 'email', $methods, true ) ? 'email' : 'totp';
1017 + }
1018 +
1019 + /**
1020 + * The two factor settings of every site that asks this account for one
1021 + *
1022 + * @since 2.11.10
1023 + *
1024 + * @param WP_User $user User being authenticated.
1025 + * @return array[] The two_factor section of each site that asks.
1026 + */
1027 + private static function two_factor_demands_for( $user ) {
1028 + if ( empty( $user->ID ) ) {
1029 + return array();
1030 + }
1031 +
1032 + if ( ! is_multisite() ) {
1033 + $roles = ( isset( $user->roles ) && is_array( $user->roles ) ) ? $user->roles : array();
1034 + $policy = self::two_factor_policy();
1035 +
1036 + return self::two_factor_site_requires( $policy, $user, $roles ) ? array( $policy ) : array();
1037 + }
1038 +
1039 + $demands = array();
1040 + $blog_ids = array( (int) get_main_site_id() );
1041 +
1042 + /*
1043 + * A super administrator is a member of almost no site (measured on the
1044 + * Multisite install: of three sites, the network owner belongs to one),
1045 + * yet can log in through any of them and the cookie is valid everywhere.
1046 + * Asking only the sites they belong to left the account with the most
1047 + * power in the network outside the policy, which is the bypass upside
1048 + * down. So for them every site of the network is consulted. There are
1049 + * few super administrators. It does not only run at login, though, which
1050 + * this note claimed until 2.11.11: the dashboard hooks of the TOTP class
1051 + * ask it on every admin screen of every site of a network, at least once
1052 + * per hook.
1053 + */
1054 + if ( is_super_admin( $user->ID ) ) {
1055 + $blog_ids = array_merge( $blog_ids, get_sites( array( 'fields' => 'ids', 'number' => 200 ) ) );
1056 + }
1057 +
1058 + foreach ( get_blogs_of_user( $user->ID ) as $blog ) {
1059 + if ( ! empty( $blog->userblog_id ) ) {
1060 + $blog_ids[] = (int) $blog->userblog_id;
1061 + }
1062 + }
1063 +
1064 + foreach ( array_unique( $blog_ids ) as $blog_id ) {
1065 + $options = get_blog_option( $blog_id, self::OPTION_NAME, array() );
1066 +
1067 + if ( ! is_array( $options ) || empty( $options['login_security']['two_factor'] ) ) {
1068 + continue;
1069 + }
1070 +
1071 + /*
1072 + * A site whose Login Security module is off asks for nothing, and
1073 + * reading only the sub-setting made it ask anyway: a subsite that had
1074 + * switched the whole module off, leaving an orphan two_factor.enabled
1075 + * behind, imposed a second factor on every account of the network,
1076 + * with no screen anywhere explaining why. It is the two-level toggle
1077 + * trap of this plugin read upside down. Found by the third cross
1078 + * review of 2.11.10.
1079 + */
1080 + if ( empty( $options['modules']['login_security'] ) ) {
1081 + continue;
1082 + }
1083 +
1084 + $elsewhere = new WP_User( $user->ID );
1085 + $elsewhere->for_site( $blog_id );
1086 +
1087 + // A super administrator can hold no role row anywhere, and is judged
1088 + // as an administrator so the strictest policy of the network reaches
1089 + // the account with the most power in it.
1090 + $roles = ( isset( $elsewhere->roles ) && is_array( $elsewhere->roles ) && $elsewhere->roles )
1091 + ? $elsewhere->roles
1092 + : ( is_super_admin( $user->ID ) ? array( 'administrator' ) : array() );
1093 +
1094 + if ( self::two_factor_site_requires( $options['login_security']['two_factor'], $user, $roles ) ) {
1095 + $demands[] = $options['login_security']['two_factor'];
1096 + }
1097 + }
1098 +
1099 + return $demands;
1100 + }
1101 +
1102 + /**
1103 + * Whether one site's two factor settings cover this account
1104 + *
1105 + * @since 2.11.10
1106 + *
1107 + * @param array $settings The two_factor section of one site.
1108 + * @param WP_User $user User being authenticated.
1109 + * @param string[] $roles Roles the account holds on that site.
1110 + * @return bool
1111 + */
1112 + private static function two_factor_site_requires( $settings, $user, $roles ) {
1113 + if ( ! is_array( $settings ) || empty( $settings['enabled'] ) ) {
1114 + return false;
1115 + }
1116 +
1117 + $excluded = isset( $settings['excluded_users'] ) ? array_map( 'absint', (array) $settings['excluded_users'] ) : array();
1118 +
1119 + if ( in_array( (int) $user->ID, $excluded, true ) ) {
1120 + return false;
1121 + }
1122 +
1123 + $enforced = isset( $settings['enforced_roles'] ) ? (array) $settings['enforced_roles'] : array( 'administrator', 'editor' );
1124 +
1125 + return (bool) array_intersect( (array) $roles, $enforced );
1126 + }
1127 +
1128 + /**
1129 + * Settings that only a network administrator may change on the main site
1130 + *
1131 + * @return array<string,string[]>
1132 + */
1133 + public static function get_main_site_file_settings() {
1134 + return array(
1135 + 'modules' => array( 'firewall', 'security_headers', 'wp_hardening', 'file_integrity' ),
1136 + // trusted_proxies goes with trusted_proxy_header, and leaving it out
1137 + // was a hole: the header decides which address the firewall of the
1138 + // whole installation acts on, and this list decides which peers may
1139 + // set that header. An administrator of the main site without network
1140 + // rights who could edit only this half turned every visitor into a
1141 + // trusted proxy. Found by the file-by-file review of 2.11.10.
1142 + 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'trusted_proxies', 'ip_whitelist', 'ua_whitelist' ),
1143 + 'file_integrity' => array( 'scan_critical_config' ),
1144 + );
1145 + }
1146 +
1147 + /**
1148 + * Shared file settings the current user may not change on this site
1149 + *
1150 + * Empty when the user can write the shared files. Otherwise the file-only
1151 + * settings on every site, plus, on the main site, the ones it also builds
1152 + * the shared files from.
1153 + *
1154 + * @since 2.11.6
1155 + *
1156 + * @return array<string,true|string[]>
1157 + */
1158 + public static function get_locked_file_settings() {
1159 + if ( self::can_write_shared_files() ) {
1160 + return array();
1161 + }
1162 +
1163 + $locked = self::get_shared_file_settings();
1164 +
1165 + if ( self::owns_shared_files() ) {
1166 + foreach ( self::get_main_site_file_settings() as $section => $keys ) {
1167 + if ( ! isset( $locked[ $section ] ) ) {
1168 + $locked[ $section ] = $keys;
1169 + } elseif ( is_array( $locked[ $section ] ) ) {
1170 + $locked[ $section ] = array_values( array_unique( array_merge( $locked[ $section ], $keys ) ) );
1171 + }
1172 + }
1173 + }
1174 +
1175 + return $locked;
1176 + }
1177 +
1178 + /**
1179 + * Put back the stored value of every shared file setting the user may not change
1180 + *
1181 + * For every writer of the whole configuration: saving a tab, importing a
1182 + * file, applying a preset, restoring the defaults. Hiding a field on the
1183 + * screen decides nothing, because the request can carry the key anyway. A
1184 + * key that was not stored is dropped, so its default keeps applying.
1185 + *
1186 + * @since 2.11.6
1187 + *
1188 + * @param array $options Configuration about to be stored.
1189 + * @param array $stored Configuration stored now, as read from the option.
1190 + * @return array
1191 + */
1192 + public static function keep_locked_file_settings( $options, $stored ) {
1193 + $options = is_array( $options ) ? $options : array();
1194 + $stored = is_array( $stored ) ? $stored : array();
1195 + $locked = self::get_locked_file_settings();
1196 +
1197 + if ( ! $locked ) {
1198 + return $options;
1199 + }
1200 +
1201 + /*
1202 + * A key that was never stored takes its default, which is what it was
1203 + * worth before. Until 2.11.8 it was dropped instead, and the sanitize
1204 + * callback of the option filled it in again, but validate_options()
1205 + * fills a missing module switch with false, not with its default.
1206 + */
1207 + $instance = new self();
1208 + $defaults = $instance->get_default_options();
1209 +
1210 + foreach ( $locked as $section => $keys ) {
1211 + if ( true === $keys ) {
1212 + if ( array_key_exists( $section, $stored ) ) {
1213 + $options[ $section ] = $stored[ $section ];
1214 + } elseif ( isset( $defaults[ $section ] ) ) {
1215 + $options[ $section ] = $defaults[ $section ];
1216 + } else {
1217 + unset( $options[ $section ] );
1218 + }
1219 + continue;
1220 + }
1221 +
1222 + $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
1223 + $default_section = ( isset( $defaults[ $section ] ) && is_array( $defaults[ $section ] ) ) ? $defaults[ $section ] : array();
1224 +
1225 + foreach ( $keys as $key ) {
1226 + if ( array_key_exists( $key, $stored_section ) ) {
1227 + $value = $stored_section[ $key ];
1228 + } elseif ( array_key_exists( $key, $default_section ) ) {
1229 + $value = $default_section[ $key ];
1230 + } else {
1231 + if ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
1232 + unset( $options[ $section ][ $key ] );
1233 + }
1234 + continue;
1235 + }
1236 +
1237 + if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
1238 + $options[ $section ] = array();
1239 + }
1240 + $options[ $section ][ $key ] = $value;
1241 + }
1242 + }
1243 +
1244 + return $options;
1245 + }
1246 +
1247 + /**
1248 + * Take a lock kept as a row of the options table, or report that another request holds it
1249 + *
1250 + * add_option() cannot be a lock: it runs INSERT ... ON DUPLICATE KEY UPDATE
1251 + * (wp-includes/option.php:1142 in WP 7.1), so two requests that both find
1252 + * the option missing both "create" it and both believe they hold it. INSERT
1253 + * IGNORE creates the row for exactly one of them, which is what core does in
1254 + * WP_Upgrader::create_lock() (wp-admin/includes/class-wp-upgrader.php:1065).
1255 + * A lock older than the timeout counts as abandoned, by a fatal error between
1256 + * taking and releasing it, and only one request takes it over.
1257 + *
1258 + * @since 2.11.8
1259 + *
1260 + * @param string $name Option name of the lock, in the current site's table.
1261 + * @param int $timeout Seconds after which a held lock counts as abandoned.
1262 + * @return bool True if this request now holds the lock.
1263 + */
1264 + public static function acquire_option_lock( $name, $timeout ) {
1265 + global $wpdb;
1266 +
1267 + // 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().
1268 + if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) ) ) {
1269 + wp_cache_delete( $name, 'options' );
1270 + return true;
1271 + }
1272 +
1273 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock row as stored right now, not a cached copy.
1274 + $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1275 +
1276 + if ( null === $held || ( time() - (int) $held ) < $timeout ) {
1277 + return false;
1278 + }
1279 +
1280 + // Abandoned: the delete only matches the value that was read, and only one
1281 + // request wins the insert that follows.
1282 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes an abandoned lock row.
1283 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $held ) );
1284 +
1285 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same atomic insert as above.
1286 + return (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) );
1287 + }
1288 +
1289 + /**
1290 + * Release a lock taken with acquire_option_lock()
1291 + *
1292 + * @since 2.11.8
1293 + *
1294 + * @param string $name Option name of the lock.
1295 + */
1296 + public static function release_option_lock( $name ) {
1297 + global $wpdb;
1298 +
1299 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes the row acquire_option_lock() inserted.
1300 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1301 + wp_cache_delete( $name, 'options' );
1302 + }
1303 +
1304 + /**
803 1305 * Put a configuration back to the defaults without deleting what the owner typed
804 1306 *
805 1307 * @since 2.9.8
806 1308 *
@@ -1146,17 +1648,102 @@
1146 1648 }
1147 1649 } elseif ( isset( $defaults[ $section ] ) ) {
1148 1650 // Validate other sections using generic validator
1149 1651 $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] );
1652 +
1653 + // The few keys that live outside get_default_options() on
1654 + // purpose (see apply_install_tweaks()) survive with their own
1655 + // validation, or an import would silently lose them and the
1656 + // XML-RPC resolver would fall back to blocking everything.
1657 + foreach ( self::undeclared_keys( $section ) as $key => $type ) {
1658 + if ( ! array_key_exists( $key, $data ) ) {
1659 + continue;
1660 + }
1661 + if ( 'bool' === $type ) {
1662 + $validated[ $section ][ $key ] = (bool) $data[ $key ];
1663 + } elseif ( is_array( $type ) && in_array( $data[ $key ], $type, true ) ) {
1664 + $validated[ $section ][ $key ] = $data[ $key ];
1665 + }
1666 + }
1150 1667 }
1151 1668 }
1152 1669
1670 + /*
1671 + * The lock on the settings the shared files are built from is NOT applied
1672 + * here, and that is a decision, not an oversight. The second cross review
1673 + * of 2.11.10 raised that register_setting( 'vigilante_options', ... )
1674 + * declares this as its sanitize callback with no lock in it, so anything
1675 + * reaching options.php with that option group would write the whole
1676 + * option. The chain does not close: the plugin prints no settings_fields()
1677 + * for that group anywhere, so the nonce it would need is not obtainable,
1678 + * and the five places that do save (saving a tab, importing, a preset,
1679 + * restoring the defaults, resetting a section) all apply
1680 + * keep_locked_file_settings() themselves.
1681 + *
1682 + * Putting it here instead would be worse than the door it closes. A
1683 + * register_setting() callback runs on EVERY update_option() of this
1684 + * option, so it would also lock the writes with no user behind them: the
1685 + * expiry of Under Attack restoring what it hardened, WP-CLI and cron.
1686 + * Those are already covered by matriz-red-ajustes-compartidos.sh, which
1687 + * is where such a change would show up as a row that stopped passing.
1688 + */
1689 +
1153 1690 return apply_filters( 'vigilante_validate_options', $validated, $input );
1154 1691 }
1155 1692
1156 1693 /**
1694 + * Keys deliberately absent from get_default_options(), with how to validate them
1695 + *
1696 + * Declaring them as defaults would break the fallback they exist for (see
1697 + * apply_install_tweaks()), but the validator still has to know them, or a
1698 + * settings import drops them (found in the 2.11.0 cross review).
1699 + *
1700 + * @since 2.11.0
1701 + *
1702 + * @param string $section Section name.
1703 + * @return array key => 'bool' or list of allowed values.
1704 + */
1705 + private static function undeclared_keys( $section ) {
1706 + $keys = array(
1707 + 'wp_hardening' => array( 'xmlrpc_mode' => array( 'full', 'pingback', 'none' ) ),
1708 + 'login_security' => array(
1709 + 'disable_xmlrpc' => 'bool',
1710 + 'disable_xmlrpc_pingback' => 'bool',
1711 + ),
1712 + );
1713 +
1714 + return isset( $keys[ $section ] ) ? $keys[ $section ] : array();
1715 + }
1716 +
1717 + /**
1718 + * Whether a default value describes a free list rather than a schema
1719 + *
1720 + * An empty array or sequential numeric keys (an IP whitelist, a list of
1721 + * roles) is a list: every entry the user typed is kept. Anything else is a
1722 + * schema: only its keys survive validation.
1723 + *
1724 + * @since 2.11.0
1725 + *
1726 + * @param array $defaults Default value of a setting.
1727 + * @return bool
1728 + */
1729 + private function is_list_default( $defaults ) {
1730 + if ( array() === $defaults ) {
1731 + return true;
1732 + }
1733 +
1734 + return array_keys( $defaults ) === range( 0, count( $defaults ) - 1 );
1735 + }
1736 +
1737 + /**
1157 1738 * Validate a section based on defaults
1158 1739 *
1740 + * Since 2.11.0 the result only holds keys the defaults know. The loop that
1741 + * used to reincorporate unknown keys "sanitized" meant a settings import
1742 + * could merge any key it liked into vigilante_options (S7 of the 28 Aug
1743 + * 2026 audit). Lists are the exception, handled first: their entries are
1744 + * data, not keys.
1745 + *
1159 1746 * @param array $input Input values.
1160 1747 * @param array $defaults Default values.
1161 1748 * @return array Validated values.
1162 1749 */
@@ -1162,8 +1749,25 @@
1162 1749 */
1163 1750 private function validate_section( $input, $defaults ) {
1164 1751 $validated = array();
1165 1752
1753 + if ( $this->is_list_default( $defaults ) ) {
1754 + if ( ! is_array( $input ) ) {
1755 + return array();
1756 + }
1757 +
1758 + $list = array();
1759 + foreach ( $input as $value ) {
1760 + if ( is_scalar( $value ) ) {
1761 + $list[] = sanitize_text_field( (string) $value );
1762 + } elseif ( is_array( $value ) ) {
1763 + $list[] = map_deep( $value, 'sanitize_text_field' );
1764 + }
1765 + }
1766 +
1767 + return $list;
1768 + }
1769 +
1166 1770 foreach ( $defaults as $key => $default_value ) {
1167 1771 if ( ! isset( $input[ $key ] ) ) {
1168 1772 $validated[ $key ] = $default_value;
1169 1773 continue;
@@ -1185,18 +1789,9 @@
1185 1789 $validated[ $key ] = sanitize_text_field( $value );
1186 1790 }
1187 1791 }
1188 1792
1189 - // Include any extra keys from input
1190 - foreach ( $input as $key => $value ) {
1191 - if ( ! isset( $validated[ $key ] ) ) {
1192 - if ( is_array( $value ) ) {
1193 - $validated[ $key ] = array_map( 'sanitize_text_field', $value );
1194 - } else {
1195 - $validated[ $key ] = sanitize_text_field( $value );
1196 - }
1197 - }
1198 - }
1793 + // Keys the defaults do not declare are dropped on purpose (S7).
1199 1794
1200 1795 return $validated;
1201 1796 }
1202 1797 }