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 +297 -14 2.10.52.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,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 }