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