PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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 +114 -14 2.11.72.11.8 View file →
@@ -700,9 +700,12 @@
700 700 * @since 2.10.1
701 701 * @return bool
702 702 */
703 703 public static function owns_shared_files() {
704 - return ! is_multisite() || is_main_site();
704 + // One wp-config.php and one root .htaccess per installation, even with
705 + // several networks in it: is_main_site() alone is true on the main site
706 + // of every network. Since 2.11.8, found by the audit of the network.
707 + return ! is_multisite() || ( is_main_site() && is_main_network() );
705 708 }
706 709
707 710 public static function can_write_shared_files() {
708 711 if ( ! is_multisite() ) {
@@ -708,9 +711,11 @@
708 711 if ( ! is_multisite() ) {
709 712 return true;
710 713 }
711 714
712 - if ( ! is_main_site() ) {
715 + // See owns_shared_files(): the main site of a secondary network, and
716 + // its network administrator, do not own the installation's files.
717 + if ( ! is_main_site() || ! is_main_network() ) {
713 718 return false;
714 719 }
715 720
716 721 // WP-CLI with nobody logged in: there is no user to ask, and the site is
@@ -831,12 +836,24 @@
831 836 * get_shared_file_settings() lists what does nothing but end up in a shared
832 837 * file. These do both: blocking bad bots and bad query strings, the visitor
833 838 * IP detection and the two whitelists run in PHP for the site that stores
834 839 * 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.
840 + * rules of every site are generated from; the three writing module switches
841 + * (firewall, security_headers, wp_hardening) decide whether the .htaccess
842 + * blocks and the wp-config.php constants exist at all.
838 843 *
844 + * Since 2.11.8 it also locks what decides whether the shared files are
845 + * WATCHED, not built: the File Integrity module and its scan_critical_config
846 + * switch. On the main site the critical-file scan is the network's canary
847 + * for a change to wp-config.php or the root .htaccess, which only a network
848 + * administrator can approve, so a main-site administrator without network
849 + * rights must not be able to silence it by turning either one off. Closing
850 + * the ignore list and the clear-results button in 2.11.8 left these two as
851 + * the remaining routes; found by the audit of the admin surface.
852 + *
853 + * On a subsite all of them only act on that site, so they stay editable
854 + * there (get_locked_file_settings() adds this set only when owns_shared_files()).
855 + *
839 856 * Until 2.11.6 an administrator of the main site without network rights
840 857 * could change any of them, and the file-only ones too: the write to the
841 858 * file was refused at that moment, but the value stayed stored, and the
842 859 * refresh after the next update, or the next save by a network
@@ -842,15 +859,17 @@
842 859 * refresh after the next update, or the next save by a network
843 860 * administrator, published it to the whole network.
844 861 *
845 862 * @since 2.11.6
863 + * @since 2.11.8 The file_integrity module and scan_critical_config.
846 864 *
847 865 * @return array<string,string[]>
848 866 */
849 867 public static function get_main_site_file_settings() {
850 868 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' ),
869 + 'modules' => array( 'firewall', 'security_headers', 'wp_hardening', 'file_integrity' ),
870 + 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'ip_whitelist', 'ua_whitelist' ),
871 + 'file_integrity' => array( 'scan_critical_config' ),
853 872 );
854 873 }
855 874
856 875 /**
@@ -900,13 +919,29 @@
900 919 */
901 920 public static function keep_locked_file_settings( $options, $stored ) {
902 921 $options = is_array( $options ) ? $options : array();
903 922 $stored = is_array( $stored ) ? $stored : array();
923 + $locked = self::get_locked_file_settings();
904 924
905 - foreach ( self::get_locked_file_settings() as $section => $keys ) {
925 + if ( ! $locked ) {
926 + return $options;
927 + }
928 +
929 + /*
930 + * A key that was never stored takes its default, which is what it was
931 + * worth before. Until 2.11.8 it was dropped instead, and the sanitize
932 + * callback of the option filled it in again, but validate_options()
933 + * fills a missing module switch with false, not with its default.
934 + */
935 + $instance = new self();
936 + $defaults = $instance->get_default_options();
937 +
938 + foreach ( $locked as $section => $keys ) {
906 939 if ( true === $keys ) {
907 940 if ( array_key_exists( $section, $stored ) ) {
908 941 $options[ $section ] = $stored[ $section ];
942 + } elseif ( isset( $defaults[ $section ] ) ) {
943 + $options[ $section ] = $defaults[ $section ];
909 944 } else {
910 945 unset( $options[ $section ] );
911 946 }
912 947 continue;
@@ -911,23 +946,88 @@
911 946 }
912 947 continue;
913 948 }
914 949
915 - $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
950 + $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
951 + $default_section = ( isset( $defaults[ $section ] ) && is_array( $defaults[ $section ] ) ) ? $defaults[ $section ] : array();
916 952
917 953 foreach ( $keys as $key ) {
918 954 if ( array_key_exists( $key, $stored_section ) ) {
919 - if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
920 - $options[ $section ] = array();
955 + $value = $stored_section[ $key ];
956 + } elseif ( array_key_exists( $key, $default_section ) ) {
957 + $value = $default_section[ $key ];
958 + } else {
959 + if ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
960 + unset( $options[ $section ][ $key ] );
921 961 }
922 - $options[ $section ][ $key ] = $stored_section[ $key ];
923 - } elseif ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
924 - unset( $options[ $section ][ $key ] );
962 + continue;
925 963 }
964 +
965 + if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
966 + $options[ $section ] = array();
967 + }
968 + $options[ $section ][ $key ] = $value;
926 969 }
927 970 }
928 971
929 972 return $options;
973 + }
974 +
975 + /**
976 + * Take a lock kept as a row of the options table, or report that another request holds it
977 + *
978 + * add_option() cannot be a lock: it runs INSERT ... ON DUPLICATE KEY UPDATE
979 + * (wp-includes/option.php:1142 in WP 7.1), so two requests that both find
980 + * the option missing both "create" it and both believe they hold it. INSERT
981 + * IGNORE creates the row for exactly one of them, which is what core does in
982 + * WP_Upgrader::create_lock() (wp-admin/includes/class-wp-upgrader.php:1065).
983 + * A lock older than the timeout counts as abandoned, by a fatal error between
984 + * taking and releasing it, and only one request takes it over.
985 + *
986 + * @since 2.11.8
987 + *
988 + * @param string $name Option name of the lock, in the current site's table.
989 + * @param int $timeout Seconds after which a held lock counts as abandoned.
990 + * @return bool True if this request now holds the lock.
991 + */
992 + public static function acquire_option_lock( $name, $timeout ) {
993 + global $wpdb;
994 +
995 + // 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().
996 + if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) ) ) {
997 + wp_cache_delete( $name, 'options' );
998 + return true;
999 + }
1000 +
1001 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock row as stored right now, not a cached copy.
1002 + $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1003 +
1004 + if ( null === $held || ( time() - (int) $held ) < $timeout ) {
1005 + return false;
1006 + }
1007 +
1008 + // Abandoned: the delete only matches the value that was read, and only one
1009 + // request wins the insert that follows.
1010 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes an abandoned lock row.
1011 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $held ) );
1012 +
1013 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same atomic insert as above.
1014 + return (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) );
1015 + }
1016 +
1017 + /**
1018 + * Release a lock taken with acquire_option_lock()
1019 + *
1020 + * @since 2.11.8
1021 + *
1022 + * @param string $name Option name of the lock.
1023 + */
1024 + public static function release_option_lock( $name ) {
1025 + global $wpdb;
1026 +
1027 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes the row acquire_option_lock() inserted.
1028 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1029 + wp_cache_delete( $name, 'options' );
930 1030 }
931 1031
932 1032 /**
933 1033 * Put a configuration back to the defaults without deleting what the owner typed