PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 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 All 93 releases
← All changes | includes/class-settings.php +270 -14 2.11.11 → 3.0.3 View file →
@@ -437,11 +437,14 @@
437 437 'auto_scan' => true,
438 438 'scan_frequency' => 'daily',
439 439 'notify_level' => 'suspicious_only',
440 440 'instant_alert' => false,
441 - 'excluded_paths' => array(
442 - 'wp-content/cache',
443 - ),
441 + // Empty for a new installation. It used to start with
442 + // wp-content/cache, from when nothing in the scan looked in
443 + // that folder anyway; the scan now searches it for hidden
444 + // code. A site that has the entry saved keeps it: what is in
445 + // this list is the site's own, and an update does not edit it.
446 + 'excluded_paths' => array(),
444 447 'excluded_extensions' => array(
445 448 // Translations (regenerated per-locale, never in checksums).
446 449 '.po', '.mo', '.pot',
447 450 // Binary images (cosmetic, not executable; often rewritten by image-optimizer plugins).
@@ -558,8 +561,34 @@
558 561
559 562 /**
560 563 * Deep merge arrays
561 564 *
565 + * Associative arrays are merged key by key, because that is what lets a
566 + * saved option keep working when a release adds a setting. Lists are
567 + * replaced whole, and that part is not cosmetic: merging a list key by key
568 + * means index by index, so a stored list shorter than the default came back
569 + * with the default's tail glued to it. Saving "administrator" alone into
570 + * enforced_roles (default administrator, editor) was read back as
571 + * administrator, editor, and unchecking every role was read back as the two
572 + * defaults, so three role pickers could not be narrowed at all. It is the
573 + * same bug merge_preset() was given its own merge for in 2.9.8; the fix
574 + * never reached this one or the one in the admin save.
575 + *
576 + * An empty saved list therefore means empty, not "fall back to the default".
577 + * That is the honest reading of what was saved, and the save path is what
578 + * makes sure a list only becomes empty when somebody emptied it: a list the
579 + * form did not carry is left alone rather than blanked.
580 + *
581 + * Which of the two happens is decided by the DEFAULT, never by what was
582 + * saved. Deciding by what was saved looks equivalent and is not: an empty
583 + * array is a list by any definition, so a section stored as array() would
584 + * have replaced a whole group of settings instead of merging into it, and
585 + * every key of that group would have come back missing. Found by the third
586 + * cross review of this release, measured on rest_api_security (7 keys to 0)
587 + * and on registration_approval (4 to 0, including its enabled flag).
588 + *
589 + * @since 3.0.1 Lists replace instead of merging index by index.
590 + *
562 591 * @param array $defaults Default values.
563 592 * @param array $saved Saved values.
564 593 * @return array Merged array.
565 594 */
@@ -564,17 +593,17 @@
564 593 * @return array Merged array.
565 594 */
566 595 private function array_merge_deep( $defaults, $saved ) {
567 596 $result = $defaults;
568 -
597 +
569 598 foreach ( $saved as $key => $value ) {
570 - if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
599 + if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) && ! self::is_list( $result[ $key ] ) ) {
571 600 $result[ $key ] = $this->array_merge_deep( $result[ $key ], $value );
572 601 } else {
573 602 $result[ $key ] = $value;
574 603 }
575 604 }
576 -
605 +
577 606 return $result;
578 607 }
579 608
580 609 /**
@@ -790,8 +819,15 @@
790 819 * addresses that receive the alerts is another, and nobody presses a button
791 820 * called "restore defaults" expecting that. Both the Standard preset and the
792 821 * two reset buttons leave these alone.
793 822 *
823 + * Read from outside the plugin: the third-party network plugin Vigilante
824 + * Network Sync calls this from its 2.0.3 to decide what NOT to copy between
825 + * the sites of a network, so a key it does not know about is preserved per
826 + * site instead of overwritten. Adding keys here is safe and helps it;
827 + * renaming or removing the method, or changing the shape of what it returns,
828 + * silently changes what that plugin replicates across a whole network.
829 + *
794 830 * @since 2.9.8
795 831 *
796 832 * @return array<string,string[]>
797 833 */
@@ -855,8 +891,12 @@
855 891 * rights must not be able to silence it by turning either one off. Closing
856 892 * the ignore list and the clear-results button in 2.11.8 left these two as
857 893 * the remaining routes; found by the audit of the admin surface.
858 894 *
895 + * Since 3.0.0 the self-check has no setting at all, so there is nothing to lock: Vigilant's own
896 + * files are shared by every site, and on the main site the self-check is
897 + * the one that reports a change to them for the whole network.
898 + *
859 899 * On a subsite all of them only act on that site, so they stay editable
860 900 * there (get_locked_file_settings() adds this set only when owns_shared_files()).
861 901 *
862 902 * Until 2.11.6 an administrator of the main site without network rights
@@ -1396,8 +1436,55 @@
1396 1436 return $base;
1397 1437 }
1398 1438
1399 1439 /**
1440 + * A stored list, or the shipped one when it arrives empty
1441 + *
1442 + * For the four lists that have NO field on any screen: the allowed HTTP
1443 + * methods, the insecure usernames, the public REST endpoints and the roles
1444 + * of registration approval. Nobody can empty those from the interface, so
1445 + * an empty one is a leftover, and every version up to 3.0.0 produced them:
1446 + * saving any tab wrote array() over every list of that section whose key the
1447 + * form did not carry, which for these four was always. Reading the settings
1448 + * hid it by merging the shipped values on top, and 3.0.1 stops doing that,
1449 + * because a stored empty list is what makes unticking every role work.
1450 + *
1451 + * The migration in vigilante.php removes those keys once. This is the line
1452 + * of defence for every other route, including Under Attack, which
1453 + * photographs the raw option when it is switched on and puts it back whole
1454 + * when it expires, after the migration has already marked itself done.
1455 + *
1456 + * It goes through every reader of those four lists, the ones that enforce
1457 + * and the ones that only report, because a screen that says "no insecure
1458 + * user" while the check is blocking them is its own kind of wrong. The
1459 + * shipped value is read from get_default_options() rather than written out
1460 + * again here: a second copy of a list drifts from the first.
1461 + *
1462 + * @since 3.0.1
1463 + *
1464 + * @param mixed $value Stored value, as the caller already has it.
1465 + * @param string[] $path Path of the setting in the options array.
1466 + * @return array
1467 + */
1468 + public static function list_or_shipped( $value, $path ) {
1469 + if ( is_array( $value ) && array() !== $value ) {
1470 + return $value;
1471 + }
1472 +
1473 + $instance = new self();
1474 + $shipped = $instance->get_default_options();
1475 +
1476 + foreach ( (array) $path as $key ) {
1477 + if ( ! is_array( $shipped ) || ! array_key_exists( $key, $shipped ) ) {
1478 + return array();
1479 + }
1480 + $shipped = $shipped[ $key ];
1481 + }
1482 +
1483 + return is_array( $shipped ) ? $shipped : array();
1484 + }
1485 +
1486 + /**
1400 1487 * Whether an array is a plain list (0..n-1 keys)
1401 1488 *
1402 1489 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1403 1490 *
@@ -1402,12 +1489,16 @@
1402 1489 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1403 1490 *
1404 1491 * @since 2.9.8
1405 1492 *
1493 + * Public since 3.0.1 because the admin save path needs the same answer: the
1494 + * two places that used to merge lists index by index have to agree on what
1495 + * a list is, or they disagree on a different subset of the settings.
1496 + *
1406 1497 * @param array $value Array to inspect.
1407 1498 * @return bool
1408 1499 */
1409 - private static function is_list( $value ) {
1500 + public static function is_list( $value ) {
1410 1501 if ( array() === $value ) {
1411 1502 return true;
1412 1503 }
1413 1504
@@ -1623,8 +1714,171 @@
1623 1714 );
1624 1715 }
1625 1716
1626 1717 /**
1718 + * The settings that hold a list of file paths, by section
1719 + *
1720 + * A path is read by what it is. sanitize_text_field() deletes every %XX octet
1721 + * and, when it finds one, collapses repeated spaces, so an exclusion written
1722 + * for a folder called foto%20x was saved as fotox and excluded nothing: the
1723 + * scan compares what is stored with the paths it finds, one by one (measured
1724 + * on 1 oct 2026 against 3.0.1). Every other list in the options is a list of
1725 + * addresses, names or extensions, where nothing meaningful is lost, and keeps
1726 + * its reader.
1727 + *
1728 + * @since 3.0.2
1729 + *
1730 + * @return array<string,string[]>
1731 + */
1732 + public static function path_list_keys() {
1733 + return array(
1734 + 'file_integrity' => array( 'excluded_paths' ),
1735 + );
1736 + }
1737 +
1738 + /**
1739 + * One path as it was typed: invalid UTF-8 out, the ends trimmed, and nothing
1740 + * else touched. Invalid UTF-8 has to go because the option would not be
1741 + * written with it.
1742 + *
1743 + * A line that has tags (or a stray <) is dropped whole, not cut. A path has
1744 + * none, and wp_strip_all_tags() would cut it at the < and leave a shorter path
1745 + * than the one written, which excludes more than anyone asked: a folder
1746 + * called a<b would exclude a. Until 3.0.1 the < was kept as an entity, which
1747 + * matched nothing, and dropping the line has the same effect.
1748 + *
1749 + * A line with a line break inside is dropped too. The textarea is read line by
1750 + * line, so only an element of a list can have one (an export edited by hand),
1751 + * and the next save of the tab would show it as two lines and store two paths:
1752 + * a, a line break and wp-content would end up excluding all of wp-content.
1753 + * 3.0.1 turned the break into a space, which matched nothing, and dropping the
1754 + * line has the same effect (fourth cross review, 1 oct 2026).
1755 + *
1756 + * @since 3.0.2
1757 + *
1758 + * @param mixed $line Entry.
1759 + * @return string
1760 + */
1761 + public static function sanitize_path_line( $line ) {
1762 + if ( ! is_scalar( $line ) ) {
1763 + return '';
1764 + }
1765 +
1766 + $line = trim( wp_check_invalid_utf8( (string) $line, true ) );
1767 +
1768 + if ( false !== strpbrk( $line, "\r\n" ) ) {
1769 + return '';
1770 + }
1771 +
1772 + return wp_strip_all_tags( $line ) === $line ? $line : '';
1773 + }
1774 +
1775 + /**
1776 + * The text of a textarea of paths, one per line, each line read as one path.
1777 + *
1778 + * Line by line, because a stray < in one line would take the lines after it
1779 + * with it if the whole text went through wp_strip_all_tags().
1780 + *
1781 + * @since 3.0.2
1782 + *
1783 + * @param mixed $text Text.
1784 + * @return string
1785 + */
1786 + public static function sanitize_path_text( $text ) {
1787 + $lines = preg_split( '/\r\n|\r|\n/', (string) $text );
1788 +
1789 + return implode( "\n", array_map( array( __CLASS__, 'sanitize_path_line' ), (array) $lines ) );
1790 + }
1791 +
1792 + /**
1793 + * Take the lists of paths out of a settings tree before the generic sanitizer reads it
1794 + *
1795 + * Known limits (fourth cross review, 1 oct 2026), none reachable from the
1796 + * settings screen and all on the side of watching more: process_section_data()
1797 + * reads the entries of a list posted as an array (excluded_paths[]=) with
1798 + * sanitize_text_field() and applies sanitize_key() to the keys, so a key
1799 + * written with another spelling is not found here, and a form posted as an
1800 + * array instead of a string goes through a generic sanitizer. In those three
1801 + * the %XX are lost, as they were in 3.0.1.
1802 + *
1803 + * @since 3.0.2
1804 + *
1805 + * @param mixed $data Settings tree: section => key => value.
1806 + * @return array Only the path lists that are present, in the same shape, each already read as paths.
1807 + */
1808 + public static function take_path_lists( $data ) {
1809 + $taken = array();
1810 +
1811 + if ( ! is_array( $data ) ) {
1812 + return $taken;
1813 + }
1814 +
1815 + foreach ( self::path_list_keys() as $section => $keys ) {
1816 + if ( ! isset( $data[ $section ] ) || ! is_array( $data[ $section ] ) ) {
1817 + continue;
1818 + }
1819 +
1820 + foreach ( $keys as $key ) {
1821 + if ( ! isset( $data[ $section ][ $key ] ) ) {
1822 + continue;
1823 + }
1824 +
1825 + $value = $data[ $section ][ $key ];
1826 +
1827 + if ( is_array( $value ) ) {
1828 + $taken[ $section ][ $key ] = array_map( array( __CLASS__, 'sanitize_path_line' ), array_values( array_filter( $value, 'is_scalar' ) ) );
1829 + } elseif ( is_scalar( $value ) ) {
1830 + $taken[ $section ][ $key ] = self::sanitize_path_text( $value );
1831 + }
1832 + }
1833 + }
1834 +
1835 + return $taken;
1836 + }
1837 +
1838 + /**
1839 + * Put back the lists of paths take_path_lists() took out, over what the generic sanitizer left
1840 + *
1841 + * @since 3.0.2
1842 + *
1843 + * @param mixed $data Sanitized settings tree.
1844 + * @param array $taken What take_path_lists() returned for the same tree.
1845 + * @return mixed The tree, with the path lists as they were read.
1846 + */
1847 + public static function restore_path_lists( $data, $taken ) {
1848 + if ( ! is_array( $data ) ) {
1849 + return $data;
1850 + }
1851 +
1852 + foreach ( $taken as $section => $values ) {
1853 + if ( ! isset( $data[ $section ] ) || ! is_array( $data[ $section ] ) ) {
1854 + continue;
1855 + }
1856 +
1857 + foreach ( $values as $key => $value ) {
1858 + $data[ $section ][ $key ] = $value;
1859 + }
1860 + }
1861 +
1862 + return $data;
1863 + }
1864 +
1865 + /**
1866 + * Whether a position in the options, as section.key, is a list of paths
1867 + *
1868 + * @since 3.0.2
1869 + *
1870 + * @param string $trail Section and key joined by a dot.
1871 + * @return bool
1872 + */
1873 + private static function is_path_list( $trail ) {
1874 + $parts = explode( '.', (string) $trail, 2 );
1875 + $paths = self::path_list_keys();
1876 +
1877 + return 2 === count( $parts ) && isset( $paths[ $parts[0] ] ) && in_array( $parts[1], $paths[ $parts[0] ], true );
1878 + }
1879 +
1880 + /**
1627 1881 * Validate options before saving
1628 1882 *
1629 1883 * @param array $input Raw input to validate.
1630 1884 * @return array Validated options.
@@ -1647,9 +1901,9 @@
1647 1901 : false;
1648 1902 }
1649 1903 } elseif ( isset( $defaults[ $section ] ) ) {
1650 1904 // Validate other sections using generic validator
1651 - $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] );
1905 + $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ], $section );
1652 1906
1653 1907 // The few keys that live outside get_default_options() on
1654 1908 // purpose (see apply_install_tweaks()) survive with their own
1655 1909 // validation, or an import would silently lose them and the
@@ -1742,13 +1996,14 @@
1742 1996 * could merge any key it liked into vigilante_options (S7 of the 28 Aug
1743 1997 * 2026 audit). Lists are the exception, handled first: their entries are
1744 1998 * data, not keys.
1745 1999 *
1746 - * @param array $input Input values.
1747 - * @param array $defaults Default values.
2000 + * @param array $input Input values.
2001 + * @param array $defaults Default values.
2002 + * @param string $trail Where this is in the options, as section.key, so a list of paths is read as paths.
1748 2003 * @return array Validated values.
1749 2004 */
1750 - private function validate_section( $input, $defaults ) {
2005 + private function validate_section( $input, $defaults, $trail = '' ) {
1751 2006 $validated = array();
1752 2007
1753 2008 if ( $this->is_list_default( $defaults ) ) {
1754 2009 if ( ! is_array( $input ) ) {
@@ -1754,12 +2009,13 @@
1754 2009 if ( ! is_array( $input ) ) {
1755 2010 return array();
1756 2011 }
1757 2012
1758 - $list = array();
2013 + $is_paths = self::is_path_list( $trail );
2014 + $list = array();
1759 2015 foreach ( $input as $value ) {
1760 2016 if ( is_scalar( $value ) ) {
1761 - $list[] = sanitize_text_field( (string) $value );
2017 + $list[] = $is_paths ? self::sanitize_path_line( $value ) : sanitize_text_field( (string) $value );
1762 2018 } elseif ( is_array( $value ) ) {
1763 2019 $list[] = map_deep( $value, 'sanitize_text_field' );
1764 2020 }
1765 2021 }
@@ -1780,9 +2036,9 @@
1780 2036 } elseif ( is_int( $default_value ) ) {
1781 2037 $validated[ $key ] = intval( $value );
1782 2038 } elseif ( is_array( $default_value ) ) {
1783 2039 if ( is_array( $value ) ) {
1784 - $validated[ $key ] = $this->validate_section( $value, $default_value );
2040 + $validated[ $key ] = $this->validate_section( $value, $default_value, '' === $trail ? (string) $key : $trail . '.' . $key );
1785 2041 } else {
1786 2042 $validated[ $key ] = $default_value;
1787 2043 }
1788 2044 } else {