| @@ -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,13 +824,213 @@ | ||
| 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 | ); |
| 820 | 831 | } |
| 821 | 832 | |
| 822 | 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' ); | |
| 1030 | + } | |
| 1031 | + | |
| 1032 | + /** | |
| 823 | 1033 | * Put a configuration back to the defaults without deleting what the owner typed |
| 824 | 1034 | * |
| 825 | 1035 | * @since 2.9.8 |
| 826 | 1036 | * |
| @@ -1166,8 +1376,23 @@ | ||
| 1166 | 1376 | } |
| 1167 | 1377 | } elseif ( isset( $defaults[ $section ] ) ) { |
| 1168 | 1378 | // Validate other sections using generic validator |
| 1169 | 1379 | $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] ); |
| 1380 | + | |
| 1381 | + // The few keys that live outside get_default_options() on | |
| 1382 | + // purpose (see apply_install_tweaks()) survive with their own | |
| 1383 | + // validation, or an import would silently lose them and the | |
| 1384 | + // XML-RPC resolver would fall back to blocking everything. | |
| 1385 | + foreach ( self::undeclared_keys( $section ) as $key => $type ) { | |
| 1386 | + if ( ! array_key_exists( $key, $data ) ) { | |
| 1387 | + continue; | |
| 1388 | + } | |
| 1389 | + if ( 'bool' === $type ) { | |
| 1390 | + $validated[ $section ][ $key ] = (bool) $data[ $key ]; | |
| 1391 | + } elseif ( is_array( $type ) && in_array( $data[ $key ], $type, true ) ) { | |
| 1392 | + $validated[ $section ][ $key ] = $data[ $key ]; | |
| 1393 | + } | |
| 1394 | + } | |
| 1170 | 1395 | } |
| 1171 | 1396 | } |
| 1172 | 1397 | |
| 1173 | 1398 | return apply_filters( 'vigilante_validate_options', $validated, $input ); |
| @@ -1173,10 +1398,60 @@ | ||
| 1173 | 1398 | return apply_filters( 'vigilante_validate_options', $validated, $input ); |
| 1174 | 1399 | } |
| 1175 | 1400 | |
| 1176 | 1401 | /** |
| 1402 | + * Keys deliberately absent from get_default_options(), with how to validate them | |
| 1403 | + * | |
| 1404 | + * Declaring them as defaults would break the fallback they exist for (see | |
| 1405 | + * apply_install_tweaks()), but the validator still has to know them, or a | |
| 1406 | + * settings import drops them (found in the 2.11.0 cross review). | |
| 1407 | + * | |
| 1408 | + * @since 2.11.0 | |
| 1409 | + * | |
| 1410 | + * @param string $section Section name. | |
| 1411 | + * @return array key => 'bool' or list of allowed values. | |
| 1412 | + */ | |
| 1413 | + private static function undeclared_keys( $section ) { | |
| 1414 | + $keys = array( | |
| 1415 | + 'wp_hardening' => array( 'xmlrpc_mode' => array( 'full', 'pingback', 'none' ) ), | |
| 1416 | + 'login_security' => array( | |
| 1417 | + 'disable_xmlrpc' => 'bool', | |
| 1418 | + 'disable_xmlrpc_pingback' => 'bool', | |
| 1419 | + ), | |
| 1420 | + ); | |
| 1421 | + | |
| 1422 | + return isset( $keys[ $section ] ) ? $keys[ $section ] : array(); | |
| 1423 | + } | |
| 1424 | + | |
| 1425 | + /** | |
| 1426 | + * Whether a default value describes a free list rather than a schema | |
| 1427 | + * | |
| 1428 | + * An empty array or sequential numeric keys (an IP whitelist, a list of | |
| 1429 | + * roles) is a list: every entry the user typed is kept. Anything else is a | |
| 1430 | + * schema: only its keys survive validation. | |
| 1431 | + * | |
| 1432 | + * @since 2.11.0 | |
| 1433 | + * | |
| 1434 | + * @param array $defaults Default value of a setting. | |
| 1435 | + * @return bool | |
| 1436 | + */ | |
| 1437 | + private function is_list_default( $defaults ) { | |
| 1438 | + if ( array() === $defaults ) { | |
| 1439 | + return true; | |
| 1440 | + } | |
| 1441 | + | |
| 1442 | + return array_keys( $defaults ) === range( 0, count( $defaults ) - 1 ); | |
| 1443 | + } | |
| 1444 | + | |
| 1445 | + /** | |
| 1177 | 1446 | * Validate a section based on defaults |
| 1178 | 1447 | * |
| 1448 | + * Since 2.11.0 the result only holds keys the defaults know. The loop that | |
| 1449 | + * used to reincorporate unknown keys "sanitized" meant a settings import | |
| 1450 | + * could merge any key it liked into vigilante_options (S7 of the 28 Aug | |
| 1451 | + * 2026 audit). Lists are the exception, handled first: their entries are | |
| 1452 | + * data, not keys. | |
| 1453 | + * | |
| 1179 | 1454 | * @param array $input Input values. |
| 1180 | 1455 | * @param array $defaults Default values. |
| 1181 | 1456 | * @return array Validated values. |
| 1182 | 1457 | */ |
| @@ -1182,8 +1457,25 @@ | ||
| 1182 | 1457 | */ |
| 1183 | 1458 | private function validate_section( $input, $defaults ) { |
| 1184 | 1459 | $validated = array(); |
| 1185 | 1460 | |
| 1461 | + if ( $this->is_list_default( $defaults ) ) { | |
| 1462 | + if ( ! is_array( $input ) ) { | |
| 1463 | + return array(); | |
| 1464 | + } | |
| 1465 | + | |
| 1466 | + $list = array(); | |
| 1467 | + foreach ( $input as $value ) { | |
| 1468 | + if ( is_scalar( $value ) ) { | |
| 1469 | + $list[] = sanitize_text_field( (string) $value ); | |
| 1470 | + } elseif ( is_array( $value ) ) { | |
| 1471 | + $list[] = map_deep( $value, 'sanitize_text_field' ); | |
| 1472 | + } | |
| 1473 | + } | |
| 1474 | + | |
| 1475 | + return $list; | |
| 1476 | + } | |
| 1477 | + | |
| 1186 | 1478 | foreach ( $defaults as $key => $default_value ) { |
| 1187 | 1479 | if ( ! isset( $input[ $key ] ) ) { |
| 1188 | 1480 | $validated[ $key ] = $default_value; |
| 1189 | 1481 | continue; |
| @@ -1205,18 +1497,9 @@ | ||
| 1205 | 1497 | $validated[ $key ] = sanitize_text_field( $value ); |
| 1206 | 1498 | } |
| 1207 | 1499 | } |
| 1208 | 1500 | |
| 1209 | - // Include any extra keys from input | |
| 1210 | - foreach ( $input as $key => $value ) { | |
| 1211 | - if ( ! isset( $validated[ $key ] ) ) { | |
| 1212 | - if ( is_array( $value ) ) { | |
| 1213 | - $validated[ $key ] = array_map( 'sanitize_text_field', $value ); | |
| 1214 | - } else { | |
| 1215 | - $validated[ $key ] = sanitize_text_field( $value ); | |
| 1216 | - } | |
| 1217 | - } | |
| 1218 | - } | |
| 1501 | + // Keys the defaults do not declare are dropped on purpose (S7). | |
| 1219 | 1502 | |
| 1220 | 1503 | return $validated; |
| 1221 | 1504 | } |
| 1222 | 1505 | } |