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 +214 -4 2.11.22.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
@@ -804,10 +809,15 @@
804 809 *
805 810 * Note this is not every setting that reaches .htaccess. Blocking bad bots
806 811 * or empty user agents also runs in PHP, per site, so those stay editable on
807 812 * a subsite: the PHP half protects that site and the .htaccess half is
808 - * refused, leaving the main site's rules standing.
813 + * refused, leaving the main site's rules standing. On the main site they
814 + * are locked too, see get_main_site_file_settings().
809 815 *
816 + * The PHP blocks for plugins and themes have no field on the settings
817 + * screen, but an imported file carries them, and readme.html and
818 + * license.txt are removed from the root the whole network shares.
819 + *
810 820 * @since 2.9.8
811 821 *
812 822 * @return array<string,true|string[]>
813 823 */
@@ -814,10 +824,210 @@
814 824 public static function get_shared_file_settings() {
815 825 return array(
816 826 'security_headers' => true,
817 827 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
818 - 'firewall' => array( 'disable_directory_browsing', 'protect_wp_config', 'protect_wp_includes', 'protect_uploads_php', 'protect_sensitive_files', 'protect_wp_cron', 'limit_http_methods' ),
828 + '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' ),
829 + 'advanced' => array( 'remove_readme', 'remove_license' ),
819 830 );
831 + }
832 +
833 + /**
834 + * Settings the shared files are built from that also act on the site storing them
835 + *
836 + * get_shared_file_settings() lists what does nothing but end up in a shared
837 + * file. These do both: blocking bad bots and bad query strings, the visitor
838 + * IP detection and the two whitelists run in PHP for the site that stores
839 + * them, and on the main site of a network they are also what the .htaccess
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.
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 + *
856 + * Until 2.11.6 an administrator of the main site without network rights
857 + * could change any of them, and the file-only ones too: the write to the
858 + * file was refused at that moment, but the value stayed stored, and the
859 + * refresh after the next update, or the next save by a network
860 + * administrator, published it to the whole network.
861 + *
862 + * @since 2.11.6
863 + * @since 2.11.8 The file_integrity module and scan_critical_config.
864 + *
865 + * @return array<string,string[]>
866 + */
867 + public static function get_main_site_file_settings() {
868 + return array(
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' ),
872 + );
873 + }
874 +
875 + /**
876 + * Shared file settings the current user may not change on this site
877 + *
878 + * Empty when the user can write the shared files. Otherwise the file-only
879 + * settings on every site, plus, on the main site, the ones it also builds
880 + * the shared files from.
881 + *
882 + * @since 2.11.6
883 + *
884 + * @return array<string,true|string[]>
885 + */
886 + public static function get_locked_file_settings() {
887 + if ( self::can_write_shared_files() ) {
888 + return array();
889 + }
890 +
891 + $locked = self::get_shared_file_settings();
892 +
893 + if ( self::owns_shared_files() ) {
894 + foreach ( self::get_main_site_file_settings() as $section => $keys ) {
895 + if ( ! isset( $locked[ $section ] ) ) {
896 + $locked[ $section ] = $keys;
897 + } elseif ( is_array( $locked[ $section ] ) ) {
898 + $locked[ $section ] = array_values( array_unique( array_merge( $locked[ $section ], $keys ) ) );
899 + }
900 + }
901 + }
902 +
903 + return $locked;
904 + }
905 +
906 + /**
907 + * Put back the stored value of every shared file setting the user may not change
908 + *
909 + * For every writer of the whole configuration: saving a tab, importing a
910 + * file, applying a preset, restoring the defaults. Hiding a field on the
911 + * screen decides nothing, because the request can carry the key anyway. A
912 + * key that was not stored is dropped, so its default keeps applying.
913 + *
914 + * @since 2.11.6
915 + *
916 + * @param array $options Configuration about to be stored.
917 + * @param array $stored Configuration stored now, as read from the option.
918 + * @return array
919 + */
920 + public static function keep_locked_file_settings( $options, $stored ) {
921 + $options = is_array( $options ) ? $options : array();
922 + $stored = is_array( $stored ) ? $stored : array();
923 + $locked = self::get_locked_file_settings();
924 +
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 ) {
939 + if ( true === $keys ) {
940 + if ( array_key_exists( $section, $stored ) ) {
941 + $options[ $section ] = $stored[ $section ];
942 + } elseif ( isset( $defaults[ $section ] ) ) {
943 + $options[ $section ] = $defaults[ $section ];
944 + } else {
945 + unset( $options[ $section ] );
946 + }
947 + continue;
948 + }
949 +
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();
952 +
953 + foreach ( $keys as $key ) {
954 + if ( array_key_exists( $key, $stored_section ) ) {
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 ] );
961 + }
962 + continue;
963 + }
964 +
965 + if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
966 + $options[ $section ] = array();
967 + }
968 + $options[ $section ][ $key ] = $value;
969 + }
970 + }
971 +
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' );
820 1030 }
821 1031
822 1032 /**
823 1033 * Put a configuration back to the defaults without deleting what the owner typed