| @@ -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 |