PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
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 +948 -91 2.9.73.0.0 View file →
@@ -93,16 +93,17 @@
93 93 // Proxy / CDN: forwarded header to trust for the visitor IP.
94 94 // Empty = trust only REMOTE_ADDR (the real connection, unspoofable).
95 95 'trusted_proxy_header' => '',
96 96
97 + // Proxy / CDN: IPs or CIDR ranges the forwarded header is accepted
98 + // from. Empty = accept it only from your own network, and, for
99 + // CF-Connecting-IP, Cloudflare's own ranges. Since 2.11.9, so a
100 + // header cannot be forged by a visitor reaching the origin directly.
101 + 'trusted_proxies' => array(),
102 +
97 103 // User-Agent management
98 104 'ua_whitelist' => array(),
99 105 'ua_blacklist' => array(),
100 - 'country_blocking' => array(
101 - 'enabled' => false,
102 - 'mode' => 'blacklist',
103 - 'countries' => array(),
104 - ),
105 106
106 107 // File protection (htaccess-based)
107 108 'disable_directory_browsing' => true,
108 109 'protect_wp_config' => true,
@@ -116,12 +117,8 @@
116 117 'block_php_in_themes' => false,
117 118 'limit_http_methods' => true,
118 119 // All methods needed for WordPress core, Gutenberg, REST API, and page builders
119 120 'allowed_http_methods' => array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' ),
120 - 'protected_file_extensions' => array(
121 - 'htaccess', 'htpasswd', 'ini', 'log', 'sql',
122 - 'bak', 'old', 'tmp', 'swp', 'save', 'backup'
123 - ),
124 121 ),
125 122
126 123 // Security Headers settings (includes HTTPS enforcer)
127 124 'security_headers' => array(
@@ -192,10 +189,33 @@
192 189 // an address that does not answer. It is an opt-in decision per
193 190 // site, made from the Security Headers tab. Sites whose URLs a
194 191 // previous version already rewrote keep them; nothing reverts them.
195 192 'force_https' => false,
196 - 'redirect_http_to_https' => true,
197 - 'fix_mixed_content' => true,
193 + // Off by default for the same reason as force_https above: the
194 + // plugin does not decide that a site is on HTTPS. It only
195 + // redirects when the site's own home URL already says https, so
196 + // shipping it on was harmless in practice, but it is still a
197 + // decision that belongs to the site owner, not to us. Sites that
198 + // already have it on keep it.
199 + 'redirect_http_to_https' => false,
200 + // Rewrites http:// URLs of this same site to https://, and only
201 + // on a site already served over HTTPS, so it cannot reach a third
202 + // party and cannot make a resource fail. It still ships off: a
203 + // setting whose own description says it rewrites http to https
204 + // does not belong in the factory configuration of a plugin that
205 + // deliberately does not decide whether a site is on HTTPS. Every
206 + // https-related setting here is the owner's call, and this one is
207 + // one click away for anyone who has just migrated and wants their
208 + // old content rewritten.
209 + 'fix_mixed_content' => false,
210 + // The Content-Security-Policy directive that tells the browser to
211 + // upgrade every http:// request, including the ones pointing at
212 + // other people's servers. If any of those has no HTTPS the
213 + // resource simply stops loading, so this is the one piece of
214 + // mixed content handling that can break a page, and it is off by
215 + // default like everything else here that forces HTTPS. It used to
216 + // ride along with fix_mixed_content with no way to separate them.
217 + 'upgrade_insecure_requests' => false,
198 218
199 219 // Server Protection (moved from firewall in v2.0.0)
200 220 'hide_server_signature' => true,
201 221 'remove_fingerprinting_headers' => true,
@@ -279,20 +299,24 @@
279 299 'force_strong_passwords' => true,
280 300 'min_password_length' => 12,
281 301
282 302 // Granular password policy. Applies only while
283 - // force_strong_passwords is on. Defaults reproduce the previous
284 - // all-requirements behaviour so existing sites keep the same
285 - // rules until the admin relaxes them. block_username is the only
286 - // new opt-in rule (off by default to avoid rejecting passwords
287 - // that were valid before). affected_roles empty = all roles.
303 + // force_strong_passwords is on. The defaults follow current
304 + // guidance (NIST SP 800-63B): what makes a password weak is being
305 + // guessable, not lacking a symbol, and composition rules push
306 + // people towards predictable substitutions and towards writing
307 + // the password down. So out of the box only the two rules that
308 + // block genuinely guessable passwords are on, and the four
309 + // character-class requirements ship off for anyone to turn on.
310 + // Existing sites keep whatever they have stored.
311 + // affected_roles empty = all roles.
288 312 'password_policy' => array(
289 - 'require_uppercase' => true,
290 - 'require_lowercase' => true,
291 - 'require_number' => true,
292 - 'require_special' => true,
313 + 'require_uppercase' => false,
314 + 'require_lowercase' => false,
315 + 'require_number' => false,
316 + 'require_special' => false,
293 317 'block_common' => true,
294 - 'block_username' => false,
318 + 'block_username' => true,
295 319 'affected_roles' => array(),
296 320 ),
297 321
298 322 'prevent_display_name_login_match' => true,
@@ -297,12 +321,16 @@
297 321
298 322 'prevent_display_name_login_match' => true,
299 323
300 324 // Admin monitoring
325 + // The two alerts that report someone gaining power ship on: a
326 + // new administrator and a role being raised are the signature of
327 + // an account takeover, and they are rare enough not to be noise.
328 + // The other two are ordinary admin housekeeping and stay opt-in.
301 329 'admin_monitoring' => array(
302 - 'alert_new_admin' => false,
330 + 'alert_new_admin' => true,
303 331 'alert_admin_email_change' => false,
304 - 'alert_permission_elevation' => false,
332 + 'alert_permission_elevation' => true,
305 333 'alert_admin_password_change' => false,
306 334 ),
307 335
308 336 // Force password reset (no options, uses native WordPress flow)
@@ -322,17 +350,23 @@
322 350 ),
323 351
324 352 // Session limits
325 353 'session_limits' => array(
326 - 'enabled' => false,
354 + 'enabled' => true,
327 355 'max_sessions' => 3,
328 356 'behavior' => 'close_oldest',
329 357 'exclude_admins' => false,
330 358 ),
331 359
332 - // Password expiration
360 + // Password expiration ships OFF. Forced rotation is no longer
361 + // recommended (NIST SP 800-63B advises against it) and it is by
362 + // far the biggest source of support here: people locked out mid
363 + // task, cron reminders that never arrive, roles nobody meant to
364 + // include. The feature stays for anyone who has to comply with a
365 + // policy that still demands it, and the Configuration Score keeps
366 + // pointing at it, which is what that score is for.
333 367 'password_expiration' => array(
334 - 'enabled' => true,
368 + 'enabled' => false,
335 369 'expire_days' => 90,
336 370 'warning_days' => 14,
337 371 'affected_roles' => array( 'administrator', 'editor' ),
338 372 'excluded_users' => array(),
@@ -355,9 +389,15 @@
355 389
356 390 // wp-config security
357 391 'disallow_file_edit' => true,
358 392 'disallow_file_mods' => false,
359 - 'force_ssl_admin' => true,
393 + // Off by default. This one writes FORCE_SSL_ADMIN into
394 + // wp-config.php, and it used to do so on activation with no
395 + // check that the site answers over HTTPS at all, which locks the
396 + // owner out of their own admin. Forcing HTTPS is an opt-in
397 + // decision per site, consistent with force_https and with HSTS,
398 + // both of which already ship off.
399 + 'force_ssl_admin' => false,
360 400 'wp_debug' => true,
361 401 // Off by default — only safe when host has a real server-side cron job;
362 402 // pairs with firewall.protect_wp_cron to block both internal triggering
363 403 // (this constant) and external HTTP abuse (the .htaccess rule).
@@ -411,19 +451,8 @@
411 451 // Strict-mode users can remove it (CSS injection is still a
412 452 // vector, defended primarily by CSP in the headers module).
413 453 '.css',
414 454 ),
415 - 'suspicious_patterns' => array(
416 - 'eval(',
417 - 'base64_decode(',
418 - 'gzinflate(',
419 - 'str_rot13(',
420 - 'exec(',
421 - 'shell_exec(',
422 - 'system(',
423 - 'passthru(',
424 - 'assert(',
425 - ),
426 455 ),
427 456
428 457 // Activity Log settings
429 458 'activity_log' => array(
@@ -445,10 +474,8 @@
445 474 ),
446 475
447 476 // Backup settings
448 477 'backup' => array(
449 - 'auto_backup' => true,
450 - 'backup_before_update' => true,
451 478 'keep_backups' => 5,
452 479 ),
453 480
454 481 // Notification settings (centralized recipients for all admin emails)
@@ -461,12 +488,8 @@
461 488 // Advanced settings
462 489 'advanced' => array(
463 490 'remove_readme' => true,
464 491 'remove_license' => true,
465 - 'block_author_archives' => false,
466 - 'disable_embeds' => false,
467 - 'uninstall_cleanup' => true,
468 - 'debug_mode' => false,
469 492 ),
470 493
471 494 // Security Analyzer (v2.1.0) — on-demand + weekly Security Check
472 495 'security_analyzer' => array(
@@ -644,8 +667,766 @@
644 667 wp_cache_delete( self::OPTION_NAME, 'options' );
645 668 }
646 669
647 670 /**
671 + * Whether this context is allowed to write the files a network shares
672 + *
673 + * wp-config.php and the root .htaccess are single files for the whole
674 + * network, while Vigilant's settings are per site. Without a gate, every
675 + * save, activation and deactivation from any site rewrites those files from
676 + * that site's own options, so the last one to save wins and silently undoes
677 + * the rest. Measured on a real network: the main site enables "disable file
678 + * editing", a subsite admin presses Save on their own screen without
679 + * touching it, and the constant disappears from wp-config.php while the main
680 + * site's screen keeps showing the box ticked.
681 + *
682 + * So on a network only the main site decides, and only a network
683 + * administrator. WP-CLI on the main site counts too: there is no user to ask
684 + * there, but the site is the right one, and a network admin running
685 + * `wp plugin activate --network` expects the files to be written.
686 + *
687 + * On a single site this is always true and nothing changes.
688 + *
689 + * @since 2.9.8
690 + *
691 + * @return bool
692 + */
693 + /**
694 + * Whether this site is the one that owns the files a network shares.
695 + *
696 + * Pure site identity, with no capability in it, and that is the point. A
697 + * refresh that Vigilant performs by itself, such as rewriting its own
698 + * .htaccess block after an update, decides nothing: the content comes from
699 + * this site's own options whoever happens to be visiting. What must not
700 + * happen is a *different* site writing the shared file, and that is exactly
701 + * what this answers.
702 + *
703 + * can_write_shared_files() below adds the capability on top, and is the
704 + * right question for anything a person initiates from a settings screen.
705 + *
706 + * @since 2.10.1
707 + * @return bool
708 + */
709 + public static function owns_shared_files() {
710 + // One wp-config.php and one root .htaccess per installation, even with
711 + // several networks in it: is_main_site() alone is true on the main site
712 + // of every network. Since 2.11.8, found by the audit of the network.
713 + return ! is_multisite() || ( is_main_site() && is_main_network() );
714 + }
715 +
716 + public static function can_write_shared_files() {
717 + if ( ! is_multisite() ) {
718 + return true;
719 + }
720 +
721 + // See owns_shared_files(): the main site of a secondary network, and
722 + // its network administrator, do not own the installation's files.
723 + if ( ! is_main_site() || ! is_main_network() ) {
724 + return false;
725 + }
726 +
727 + // WP-CLI with nobody logged in: there is no user to ask, and the site is
728 + // the right one, so a network admin running `wp plugin activate --network`
729 + // gets the files written. With a user set (wp --user=...) the capability
730 + // is checked like anywhere else, so the gate cannot be side-stepped by
731 + // running as a subsite administrator.
732 + if ( defined( 'WP_CLI' ) && WP_CLI && ! get_current_user_id() ) {
733 + return true;
734 + }
735 +
736 + return current_user_can( 'manage_network_options' );
737 + }
738 +
739 + /**
740 + * The one message shown wherever a shared-file setting is out of reach
741 + *
742 + * Deliberately a single string reused by every section, instead of one per
743 + * section: it says the same thing everywhere and there is no reason to make
744 + * translators write it four times.
745 + *
746 + * @since 2.9.8
747 + *
748 + * @return string
749 + */
750 + public static function get_shared_files_notice() {
751 + return __( 'These settings are written to wp-config.php and .htaccess, files the whole network shares. So that one site cannot overwrite another, they are managed from the main site of the network by a network administrator.', 'vigilante' );
752 + }
753 +
754 + /**
755 + * Apply the tweaks a brand new installation gets on top of the raw defaults
756 + *
757 + * A few keys are deliberately missing from get_default_options() because
758 + * declaring them would break something else. XML-RPC is the one case today:
759 + * declaring wp_hardening.xmlrpc_mode would make the defaults merge fill it in
760 + * always and hide the fallback that reads the old pair of settings on sites
761 + * that have not re-saved the tab. With nothing stored the resolver answers
762 + * 'full', which blocks XML-RPC completely, and that is not what we want a new
763 + * site to get.
764 + *
765 + * Everything that seeds a clean configuration has to run this: the activation
766 + * hook, the per-section reset and the global reset to defaults. Otherwise the
767 + * defaults you get by pressing a button are not the defaults you get by
768 + * installing the plugin, which is exactly what happened until 2.9.8.
769 + *
770 + * @since 2.9.8
771 + *
772 + * @param array $options Options array to adjust.
773 + * @return array
774 + */
775 + public static function apply_install_tweaks( $options ) {
776 + if ( ! isset( $options['wp_hardening'] ) || ! is_array( $options['wp_hardening'] ) ) {
777 + $options['wp_hardening'] = array();
778 + }
779 +
780 + $options['wp_hardening']['xmlrpc_mode'] = 'pingback';
781 +
782 + return $options;
783 + }
784 +
785 + /**
786 + * Keys that hold what the site owner typed in, never wiped by a restore
787 + *
788 + * Putting the security posture back to its defaults is one thing; deleting
789 + * an IP whitelist, the secret login address, the two factor setup or the
790 + * addresses that receive the alerts is another, and nobody presses a button
791 + * called "restore defaults" expecting that. Both the Standard preset and the
792 + * two reset buttons leave these alone.
793 + *
794 + * Read from outside the plugin: the third-party network plugin Vigilante
795 + * Network Sync calls this from its 2.0.3 to decide what NOT to copy between
796 + * the sites of a network, so a key it does not know about is preserved per
797 + * site instead of overwritten. Adding keys here is safe and helps it;
798 + * renaming or removing the method, or changing the shape of what it returns,
799 + * silently changes what that plugin replicates across a whole network.
800 + *
801 + * @since 2.9.8
802 + *
803 + * @return array<string,string[]>
804 + */
805 + public static function get_user_data_keys() {
806 + return array(
807 + 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header', 'trusted_proxies' ),
808 + 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
809 + 'user_security' => array( 'insecure_usernames' ),
810 + 'file_integrity' => array( 'excluded_paths', 'excluded_extensions' ),
811 + 'email' => array( 'additional_recipients' ),
812 + );
813 + }
814 +
815 + /**
816 + * Settings whose only effect is written to a file the network shares
817 + *
818 + * true for a whole section, or the list of keys inside it. Used to keep a
819 + * subsite from resetting settings it does not control: the file is written
820 + * from the main site, so resetting the local copy would only make the two
821 + * disagree.
822 + *
823 + * Note this is not every setting that reaches .htaccess. Blocking bad bots
824 + * or empty user agents also runs in PHP, per site, so those stay editable on
825 + * a subsite: the PHP half protects that site and the .htaccess half is
826 + * refused, leaving the main site's rules standing. On the main site they
827 + * are locked too, see get_main_site_file_settings().
828 + *
829 + * The PHP blocks for plugins and themes have no field on the settings
830 + * screen, but an imported file carries them, and readme.html and
831 + * license.txt are removed from the root the whole network shares.
832 + *
833 + * @since 2.9.8
834 + *
835 + * @return array<string,true|string[]>
836 + */
837 + public static function get_shared_file_settings() {
838 + return array(
839 + 'security_headers' => true,
840 + 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
841 + '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' ),
842 + 'advanced' => array( 'remove_readme', 'remove_license' ),
843 + );
844 + }
845 +
846 + /**
847 + * Settings the shared files are built from that also act on the site storing them
848 + *
849 + * get_shared_file_settings() lists what does nothing but end up in a shared
850 + * file. These do both: blocking bad bots and bad query strings, the visitor
851 + * IP detection and the two whitelists run in PHP for the site that stores
852 + * them, and on the main site of a network they are also what the .htaccess
853 + * rules of every site are generated from; the three writing module switches
854 + * (firewall, security_headers, wp_hardening) decide whether the .htaccess
855 + * blocks and the wp-config.php constants exist at all.
856 + *
857 + * Since 2.11.8 it also locks what decides whether the shared files are
858 + * WATCHED, not built: the File Integrity module and its scan_critical_config
859 + * switch. On the main site the critical-file scan is the network's canary
860 + * for a change to wp-config.php or the root .htaccess, which only a network
861 + * administrator can approve, so a main-site administrator without network
862 + * rights must not be able to silence it by turning either one off. Closing
863 + * the ignore list and the clear-results button in 2.11.8 left these two as
864 + * the remaining routes; found by the audit of the admin surface.
865 + *
866 + * Since 3.0.0 the self-check has no setting at all, so there is nothing to lock: Vigilant's own
867 + * files are shared by every site, and on the main site the self-check is
868 + * the one that reports a change to them for the whole network.
869 + *
870 + * On a subsite all of them only act on that site, so they stay editable
871 + * there (get_locked_file_settings() adds this set only when owns_shared_files()).
872 + *
873 + * Until 2.11.6 an administrator of the main site without network rights
874 + * could change any of them, and the file-only ones too: the write to the
875 + * file was refused at that moment, but the value stayed stored, and the
876 + * refresh after the next update, or the next save by a network
877 + * administrator, published it to the whole network.
878 + *
879 + * @since 2.11.6
880 + * @since 2.11.8 The file_integrity module and scan_critical_config.
881 + *
882 + * @return array<string,string[]>
883 + */
884 + /**
885 + * The two factor policy that governs this installation
886 + *
887 + * On a network the answer must not depend on which site the login happens to
888 + * arrive at, because the cookie WordPress issues does not: COOKIEHASH comes
889 + * from the network siteurl (wp-includes/default-constants.php) and
890 + * COOKIE_DOMAIN covers every host of the network
891 + * (wp-includes/ms-default-constants.php). Until 2.11.10 the settings, the
892 + * enforced roles and the TOTP table were all read per site, so somebody
893 + * holding the password of an administrator protected by 2FA on the main site
894 + * posted the login to a subsite where that account has no role, was never
895 + * asked for a code, and came out with a session valid across the network.
896 + * Measured on the Multisite install on 12 sep 2026 (user_requires_2fa true on
897 + * the main site, false on demo2 for the same account) and found by the
898 + * file-by-file review of 2.11.10.
899 + *
900 + * So the policy of the main site governs the whole network. On a single site
901 + * this is the site's own configuration and nothing changes.
902 + *
903 + * @since 2.11.10
904 + *
905 + * @return array The two_factor section that applies.
906 + */
907 + public static function two_factor_policy() {
908 + $options = is_multisite()
909 + ? get_blog_option( get_main_site_id(), self::OPTION_NAME, array() )
910 + : get_option( self::OPTION_NAME, array() );
911 +
912 + if ( ! is_array( $options ) ) {
913 + return array();
914 + }
915 +
916 + $login = isset( $options['login_security'] ) && is_array( $options['login_security'] )
917 + ? $options['login_security']
918 + : array();
919 +
920 + return ( isset( $login['two_factor'] ) && is_array( $login['two_factor'] ) ) ? $login['two_factor'] : array();
921 + }
922 +
923 + /**
924 + * Whether two factor is required for this account, anywhere in the network
925 + *
926 + * Union, and deliberately so. Reading the policy only from the main site,
927 + * which is what this release did at first, would have switched two factor off
928 + * for every network that has it configured per subsite, which until now was
929 + * the only way it could be configured at all: a silent downgrade of the very
930 + * protection being fixed. Found by the cross review of 2.11.10. So the
931 + * question is asked of every site the account belongs to, plus the main site,
932 + * each with its own enforced roles and exclusions, and one yes is enough.
933 + *
934 + * That also closes the bypass: the settings, the roles and the enrolment used
935 + * to be read from whichever site the login arrived at, while the cookie
936 + * WordPress issues is valid across the whole network (COOKIEHASH comes from
937 + * the network siteurl and COOKIE_DOMAIN covers every host of it), so an
938 + * account protected on one site could log in through another and come out
939 + * with a session valid everywhere.
940 + *
941 + * @since 2.11.10
942 + *
943 + * @param WP_User $user User being authenticated.
944 + * @return bool
945 + */
946 + public static function two_factor_required_for( $user ) {
947 + return array() !== self::two_factor_demands_for( $user );
948 + }
949 +
950 + /**
951 + * Which second factor methods this account is asked for, across the network
952 + *
953 + * Empty when nothing asks. On a network there can be more than one, because
954 + * each site keeps its own settings and the requirement is the union of them.
955 + *
956 + * @since 2.11.10
957 + *
958 + * @param WP_User $user User being authenticated.
959 + * @return string[] Methods asked for, without repeats.
960 + */
961 + public static function two_factor_methods_for( $user ) {
962 + $methods = array();
963 +
964 + foreach ( self::two_factor_demands_for( $user ) as $settings ) {
965 + $method = isset( $settings['method'] ) ? (string) $settings['method'] : 'email';
966 +
967 + // A method this version does not know is read as the default rather
968 + // than left to fall through. Registering nothing at all is how the
969 + // second factor of a whole network went quiet in silence: a saved
970 + // value of '' (which validate_section() lets through on an import,
971 + // since only the tab has an allowlist) matched neither class, so no
972 + // filter was registered anywhere while every screen still said two
973 + // factor was on. Found by the third cross review of 2.11.10.
974 + $methods[] = in_array( $method, array( 'email', 'totp' ), true ) ? $method : 'email';
975 + }
976 +
977 + return array_values( array_unique( $methods ) );
978 + }
979 +
980 + /**
981 + * Which of the two second factor classes handles this login
982 + *
983 + * The method cannot be read from one site's settings, and reading it from the
984 + * main site was the hole the third cross review of 2.11.10 found: with the
985 + * main site on totp and a subsite asking for a code by email, the class that
986 + * registered was TOTP, the account had no enrolment, and the "not set up yet"
987 + * branch let the login through. In 2.11.9 that same login was asked for its
988 + * emailed code. So the question is asked per account, not per site.
989 + *
990 + * The order is what keeps it closed at both ends:
991 + *
992 + * 1. An enrolment already made wins while some site asking for a second
993 + * factor asks for an authenticator app. It is the strongest factor the
994 + * account has and it is ready to use, wherever in the network it was set
995 + * up.
996 + * 2. Otherwise, if any site asking for a second factor asks for email, email
997 + * handles it. Email needs no enrolment, so it can never fall into the
998 + * branch that lets a login through for lack of one.
999 + * 3. Only when every site asking wants an authenticator app does TOTP handle
1000 + * it, which is the case the grace period was written for.
1001 + *
1002 + * The condition on the first step came in 2.11.11. Without it an enrolment
1003 + * left from a time when the site asked for an app outranked the method the
1004 + * site asks for now: a single site set to email asked those accounts for an
1005 + * authenticator code, which 2.11.9 never did and which locks out whoever
1006 + * removed the app after the switch. Dropping that enrolment opens nothing,
1007 + * because the account then goes to email, which needs no enrolment.
1008 + *
1009 + * @since 2.11.10
1010 + * @since 2.11.11 An enrolment only wins while an authenticator app is asked for.
1011 + *
1012 + * @param WP_User $user User being authenticated.
1013 + * @param bool $enrolled Whether the account has a TOTP enrolment anywhere.
1014 + * @return string 'email', 'totp', or '' when nothing asks.
1015 + */
1016 + public static function two_factor_handler_for( $user, $enrolled ) {
1017 + $methods = self::two_factor_methods_for( $user );
1018 +
1019 + if ( ! $methods ) {
1020 + return '';
1021 + }
1022 +
1023 + if ( $enrolled && in_array( 'totp', $methods, true ) ) {
1024 + return 'totp';
1025 + }
1026 +
1027 + return in_array( 'email', $methods, true ) ? 'email' : 'totp';
1028 + }
1029 +
1030 + /**
1031 + * The two factor settings of every site that asks this account for one
1032 + *
1033 + * @since 2.11.10
1034 + *
1035 + * @param WP_User $user User being authenticated.
1036 + * @return array[] The two_factor section of each site that asks.
1037 + */
1038 + private static function two_factor_demands_for( $user ) {
1039 + if ( empty( $user->ID ) ) {
1040 + return array();
1041 + }
1042 +
1043 + if ( ! is_multisite() ) {
1044 + $roles = ( isset( $user->roles ) && is_array( $user->roles ) ) ? $user->roles : array();
1045 + $policy = self::two_factor_policy();
1046 +
1047 + return self::two_factor_site_requires( $policy, $user, $roles ) ? array( $policy ) : array();
1048 + }
1049 +
1050 + $demands = array();
1051 + $blog_ids = array( (int) get_main_site_id() );
1052 +
1053 + /*
1054 + * A super administrator is a member of almost no site (measured on the
1055 + * Multisite install: of three sites, the network owner belongs to one),
1056 + * yet can log in through any of them and the cookie is valid everywhere.
1057 + * Asking only the sites they belong to left the account with the most
1058 + * power in the network outside the policy, which is the bypass upside
1059 + * down. So for them every site of the network is consulted. There are
1060 + * few super administrators. It does not only run at login, though, which
1061 + * this note claimed until 2.11.11: the dashboard hooks of the TOTP class
1062 + * ask it on every admin screen of every site of a network, at least once
1063 + * per hook.
1064 + */
1065 + if ( is_super_admin( $user->ID ) ) {
1066 + $blog_ids = array_merge( $blog_ids, get_sites( array( 'fields' => 'ids', 'number' => 200 ) ) );
1067 + }
1068 +
1069 + foreach ( get_blogs_of_user( $user->ID ) as $blog ) {
1070 + if ( ! empty( $blog->userblog_id ) ) {
1071 + $blog_ids[] = (int) $blog->userblog_id;
1072 + }
1073 + }
1074 +
1075 + foreach ( array_unique( $blog_ids ) as $blog_id ) {
1076 + $options = get_blog_option( $blog_id, self::OPTION_NAME, array() );
1077 +
1078 + if ( ! is_array( $options ) || empty( $options['login_security']['two_factor'] ) ) {
1079 + continue;
1080 + }
1081 +
1082 + /*
1083 + * A site whose Login Security module is off asks for nothing, and
1084 + * reading only the sub-setting made it ask anyway: a subsite that had
1085 + * switched the whole module off, leaving an orphan two_factor.enabled
1086 + * behind, imposed a second factor on every account of the network,
1087 + * with no screen anywhere explaining why. It is the two-level toggle
1088 + * trap of this plugin read upside down. Found by the third cross
1089 + * review of 2.11.10.
1090 + */
1091 + if ( empty( $options['modules']['login_security'] ) ) {
1092 + continue;
1093 + }
1094 +
1095 + $elsewhere = new WP_User( $user->ID );
1096 + $elsewhere->for_site( $blog_id );
1097 +
1098 + // A super administrator can hold no role row anywhere, and is judged
1099 + // as an administrator so the strictest policy of the network reaches
1100 + // the account with the most power in it.
1101 + $roles = ( isset( $elsewhere->roles ) && is_array( $elsewhere->roles ) && $elsewhere->roles )
1102 + ? $elsewhere->roles
1103 + : ( is_super_admin( $user->ID ) ? array( 'administrator' ) : array() );
1104 +
1105 + if ( self::two_factor_site_requires( $options['login_security']['two_factor'], $user, $roles ) ) {
1106 + $demands[] = $options['login_security']['two_factor'];
1107 + }
1108 + }
1109 +
1110 + return $demands;
1111 + }
1112 +
1113 + /**
1114 + * Whether one site's two factor settings cover this account
1115 + *
1116 + * @since 2.11.10
1117 + *
1118 + * @param array $settings The two_factor section of one site.
1119 + * @param WP_User $user User being authenticated.
1120 + * @param string[] $roles Roles the account holds on that site.
1121 + * @return bool
1122 + */
1123 + private static function two_factor_site_requires( $settings, $user, $roles ) {
1124 + if ( ! is_array( $settings ) || empty( $settings['enabled'] ) ) {
1125 + return false;
1126 + }
1127 +
1128 + $excluded = isset( $settings['excluded_users'] ) ? array_map( 'absint', (array) $settings['excluded_users'] ) : array();
1129 +
1130 + if ( in_array( (int) $user->ID, $excluded, true ) ) {
1131 + return false;
1132 + }
1133 +
1134 + $enforced = isset( $settings['enforced_roles'] ) ? (array) $settings['enforced_roles'] : array( 'administrator', 'editor' );
1135 +
1136 + return (bool) array_intersect( (array) $roles, $enforced );
1137 + }
1138 +
1139 + /**
1140 + * Settings that only a network administrator may change on the main site
1141 + *
1142 + * @return array<string,string[]>
1143 + */
1144 + public static function get_main_site_file_settings() {
1145 + return array(
1146 + 'modules' => array( 'firewall', 'security_headers', 'wp_hardening', 'file_integrity' ),
1147 + // trusted_proxies goes with trusted_proxy_header, and leaving it out
1148 + // was a hole: the header decides which address the firewall of the
1149 + // whole installation acts on, and this list decides which peers may
1150 + // set that header. An administrator of the main site without network
1151 + // rights who could edit only this half turned every visitor into a
1152 + // trusted proxy. Found by the file-by-file review of 2.11.10.
1153 + 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'trusted_proxies', 'ip_whitelist', 'ua_whitelist' ),
1154 + 'file_integrity' => array( 'scan_critical_config' ),
1155 + );
1156 + }
1157 +
1158 + /**
1159 + * Shared file settings the current user may not change on this site
1160 + *
1161 + * Empty when the user can write the shared files. Otherwise the file-only
1162 + * settings on every site, plus, on the main site, the ones it also builds
1163 + * the shared files from.
1164 + *
1165 + * @since 2.11.6
1166 + *
1167 + * @return array<string,true|string[]>
1168 + */
1169 + public static function get_locked_file_settings() {
1170 + if ( self::can_write_shared_files() ) {
1171 + return array();
1172 + }
1173 +
1174 + $locked = self::get_shared_file_settings();
1175 +
1176 + if ( self::owns_shared_files() ) {
1177 + foreach ( self::get_main_site_file_settings() as $section => $keys ) {
1178 + if ( ! isset( $locked[ $section ] ) ) {
1179 + $locked[ $section ] = $keys;
1180 + } elseif ( is_array( $locked[ $section ] ) ) {
1181 + $locked[ $section ] = array_values( array_unique( array_merge( $locked[ $section ], $keys ) ) );
1182 + }
1183 + }
1184 + }
1185 +
1186 + return $locked;
1187 + }
1188 +
1189 + /**
1190 + * Put back the stored value of every shared file setting the user may not change
1191 + *
1192 + * For every writer of the whole configuration: saving a tab, importing a
1193 + * file, applying a preset, restoring the defaults. Hiding a field on the
1194 + * screen decides nothing, because the request can carry the key anyway. A
1195 + * key that was not stored is dropped, so its default keeps applying.
1196 + *
1197 + * @since 2.11.6
1198 + *
1199 + * @param array $options Configuration about to be stored.
1200 + * @param array $stored Configuration stored now, as read from the option.
1201 + * @return array
1202 + */
1203 + public static function keep_locked_file_settings( $options, $stored ) {
1204 + $options = is_array( $options ) ? $options : array();
1205 + $stored = is_array( $stored ) ? $stored : array();
1206 + $locked = self::get_locked_file_settings();
1207 +
1208 + if ( ! $locked ) {
1209 + return $options;
1210 + }
1211 +
1212 + /*
1213 + * A key that was never stored takes its default, which is what it was
1214 + * worth before. Until 2.11.8 it was dropped instead, and the sanitize
1215 + * callback of the option filled it in again, but validate_options()
1216 + * fills a missing module switch with false, not with its default.
1217 + */
1218 + $instance = new self();
1219 + $defaults = $instance->get_default_options();
1220 +
1221 + foreach ( $locked as $section => $keys ) {
1222 + if ( true === $keys ) {
1223 + if ( array_key_exists( $section, $stored ) ) {
1224 + $options[ $section ] = $stored[ $section ];
1225 + } elseif ( isset( $defaults[ $section ] ) ) {
1226 + $options[ $section ] = $defaults[ $section ];
1227 + } else {
1228 + unset( $options[ $section ] );
1229 + }
1230 + continue;
1231 + }
1232 +
1233 + $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
1234 + $default_section = ( isset( $defaults[ $section ] ) && is_array( $defaults[ $section ] ) ) ? $defaults[ $section ] : array();
1235 +
1236 + foreach ( $keys as $key ) {
1237 + if ( array_key_exists( $key, $stored_section ) ) {
1238 + $value = $stored_section[ $key ];
1239 + } elseif ( array_key_exists( $key, $default_section ) ) {
1240 + $value = $default_section[ $key ];
1241 + } else {
1242 + if ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
1243 + unset( $options[ $section ][ $key ] );
1244 + }
1245 + continue;
1246 + }
1247 +
1248 + if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
1249 + $options[ $section ] = array();
1250 + }
1251 + $options[ $section ][ $key ] = $value;
1252 + }
1253 + }
1254 +
1255 + return $options;
1256 + }
1257 +
1258 + /**
1259 + * Take a lock kept as a row of the options table, or report that another request holds it
1260 + *
1261 + * add_option() cannot be a lock: it runs INSERT ... ON DUPLICATE KEY UPDATE
1262 + * (wp-includes/option.php:1142 in WP 7.1), so two requests that both find
1263 + * the option missing both "create" it and both believe they hold it. INSERT
1264 + * IGNORE creates the row for exactly one of them, which is what core does in
1265 + * WP_Upgrader::create_lock() (wp-admin/includes/class-wp-upgrader.php:1065).
1266 + * A lock older than the timeout counts as abandoned, by a fatal error between
1267 + * taking and releasing it, and only one request takes it over.
1268 + *
1269 + * @since 2.11.8
1270 + *
1271 + * @param string $name Option name of the lock, in the current site's table.
1272 + * @param int $timeout Seconds after which a held lock counts as abandoned.
1273 + * @return bool True if this request now holds the lock.
1274 + */
1275 + public static function acquire_option_lock( $name, $timeout ) {
1276 + global $wpdb;
1277 +
1278 + // 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().
1279 + if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) ) ) {
1280 + wp_cache_delete( $name, 'options' );
1281 + return true;
1282 + }
1283 +
1284 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock row as stored right now, not a cached copy.
1285 + $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1286 +
1287 + if ( null === $held || ( time() - (int) $held ) < $timeout ) {
1288 + return false;
1289 + }
1290 +
1291 + // Abandoned: the delete only matches the value that was read, and only one
1292 + // request wins the insert that follows.
1293 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes an abandoned lock row.
1294 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $held ) );
1295 +
1296 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same atomic insert as above.
1297 + return (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) );
1298 + }
1299 +
1300 + /**
1301 + * Release a lock taken with acquire_option_lock()
1302 + *
1303 + * @since 2.11.8
1304 + *
1305 + * @param string $name Option name of the lock.
1306 + */
1307 + public static function release_option_lock( $name ) {
1308 + global $wpdb;
1309 +
1310 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes the row acquire_option_lock() inserted.
1311 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1312 + wp_cache_delete( $name, 'options' );
1313 + }
1314 +
1315 + /**
1316 + * Put a configuration back to the defaults without deleting what the owner typed
1317 + *
1318 + * @since 2.9.8
1319 + *
1320 + * @param array $current Configuration being replaced.
1321 + * @return array
1322 + */
1323 + public static function get_defaults_preserving_user_data( $current ) {
1324 + $instance = new self();
1325 + $defaults = self::apply_install_tweaks( $instance->get_default_options() );
1326 +
1327 + foreach ( self::get_user_data_keys() as $section => $keys ) {
1328 + foreach ( $keys as $key ) {
1329 + if ( isset( $current[ $section ] ) && array_key_exists( $key, (array) $current[ $section ] ) ) {
1330 + $defaults[ $section ][ $key ] = $current[ $section ][ $key ];
1331 + }
1332 + }
1333 + }
1334 +
1335 + return $defaults;
1336 + }
1337 +
1338 + /**
1339 + * The values the Standard preset applies
1340 + *
1341 + * Standard is the configuration a new installation gets, with every module
1342 + * on. It is built from the defaults rather than written out by hand, because
1343 + * a hand-written copy drifts: until 2.9.8 Standard named a dozen fields and
1344 + * left everything else alone, so applying it after Maximum kept Maximum's
1345 + * password rules, its administrator alerts, its session limits and its
1346 + * password expiry, and the preset that says it applies sensible defaults
1347 + * applied almost none of them.
1348 + *
1349 + * The only thing it does not touch is what the site owner typed in: IP and
1350 + * user agent lists, the custom login address, two factor configuration, the
1351 + * integrity scan exclusions and the extra notification recipients. Putting
1352 + * the security posture back to the defaults is one thing, throwing away
1353 + * someone's whitelist is another, and "Reset to Defaults" is right there for
1354 + * that.
1355 + *
1356 + * @since 2.9.8
1357 + *
1358 + * @return array
1359 + */
1360 + private function get_standard_preset_values() {
1361 + $values = self::apply_install_tweaks( $this->get_default_options() );
1362 +
1363 + foreach ( array_keys( $values['modules'] ) as $module ) {
1364 + $values['modules'][ $module ] = true;
1365 + }
1366 +
1367 + foreach ( self::get_user_data_keys() as $section => $keys ) {
1368 + foreach ( $keys as $key ) {
1369 + unset( $values[ $section ][ $key ] );
1370 + }
1371 + }
1372 +
1373 + unset( $values['user_security']['password_expiration']['excluded_users'] );
1374 +
1375 + return $values;
1376 + }
1377 +
1378 + /**
1379 + * Merge a preset over a configuration
1380 + *
1381 + * Not array_replace_recursive(), which is wrong for this in two ways. A list
1382 + * of roles in the preset is merged position by position instead of replacing
1383 + * the stored one, so applying Standard over Maximum turned the two roles
1384 + * Standard expires passwords for into Maximum's five with the first two
1385 + * overwritten. And an empty list in the preset clears nothing at all,
1386 + * because there is no element to replace with.
1387 + *
1388 + * So: associative arrays are merged key by key, and lists and scalars are
1389 + * replaced outright.
1390 + *
1391 + * @since 2.9.8
1392 + *
1393 + * @param array $base Current configuration.
1394 + * @param array $overlay Preset values.
1395 + * @return array
1396 + */
1397 + public static function merge_preset( $base, $overlay ) {
1398 + foreach ( $overlay as $key => $value ) {
1399 + if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && ! self::is_list( $value ) ) {
1400 + $base[ $key ] = self::merge_preset( $base[ $key ], $value );
1401 + continue;
1402 + }
1403 +
1404 + $base[ $key ] = $value;
1405 + }
1406 +
1407 + return $base;
1408 + }
1409 +
1410 + /**
1411 + * Whether an array is a plain list (0..n-1 keys)
1412 + *
1413 + * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1414 + *
1415 + * @since 2.9.8
1416 + *
1417 + * @param array $value Array to inspect.
1418 + * @return bool
1419 + */
1420 + private static function is_list( $value ) {
1421 + if ( array() === $value ) {
1422 + return true;
1423 + }
1424 +
1425 + return array_keys( $value ) === range( 0, count( $value ) - 1 );
1426 + }
1427 +
1428 + /**
648 1429 * Get presets with descriptions
649 1430 *
650 1431 * @return array Presets configuration.
651 1432 */
@@ -650,46 +1431,14 @@
650 1431 * @return array Presets configuration.
651 1432 */
652 1433 public function get_presets() {
653 1434 return array(
654 - 'standard' => array(
655 - 'name' => __( 'Standard', 'vigilante' ),
656 - 'description' => __( 'Balanced security suitable for most websites. Enables all modules with sensible defaults.', 'vigilante' ),
657 - 'modules' => array(
658 - 'firewall' => true,
659 - 'security_headers' => true,
660 - 'login_security' => true,
661 - 'rest_api_security'=> true,
662 - 'user_security' => true,
663 - 'wp_hardening' => true,
664 - 'file_integrity' => true,
665 - 'activity_log' => true,
1435 + 'standard' => array_merge(
1436 + array(
1437 + 'name' => __( 'Standard', 'vigilante' ),
1438 + 'description' => __( 'Balanced security suitable for most websites. Enables every module and puts every setting back to the value a new installation gets.', 'vigilante' ),
666 1439 ),
667 - 'firewall' => array(
668 - 'block_bad_query_strings' => true,
669 - 'block_sql_injection' => true,
670 - 'block_xss_attacks' => true,
671 - 'rate_limiting' => array(
672 - 'enabled' => true,
673 - 'requests_per_minute' => 120,
674 - ),
675 - ),
676 - 'login_security' => array(
677 - 'max_attempts' => 5,
678 - 'lockout_duration' => 1800,
679 - ),
680 - 'rest_api_security' => array(
681 - 'mode' => 'selective',
682 - ),
683 - 'user_security' => array(
684 - 'prevent_display_name_login_match' => true,
685 - ),
686 - 'file_integrity' => array(
687 - 'notify_level' => 'suspicious_only',
688 - ),
689 - 'wp_hardening' => array(
690 - 'xmlrpc_mode' => 'full',
691 - ),
1440 + $this->get_standard_preset_values()
692 1441 ),
693 1442
694 1443 'maximum' => array(
695 1444 'name' => __( 'Maximum Security', 'vigilante' ),
@@ -817,8 +1566,23 @@
817 1566 'scan_frequency' => 'daily',
818 1567 'notify_level' => 'all',
819 1568 'instant_alert' => true,
820 1569 ),
1570 + // A configuration called Maximum Security that never tells you
1571 + // anything happened is half a product, so the audit alerts ship
1572 + // on with it. The shared cooldown keeps a sustained attack from
1573 + // turning into a flood. Under Attack mode builds on this preset,
1574 + // so it inherits them for as long as it is on and gives them back
1575 + // when it is switched off.
1576 + 'audit_alerts' => array(
1577 + 'immediate' => array(
1578 + 'enabled' => true,
1579 + 'min_severity' => 'critical',
1580 + ),
1581 + 'threshold' => array(
1582 + 'enabled' => true,
1583 + ),
1584 + ),
821 1585 'activity_log' => array(
822 1586 'log_logins' => true,
823 1587 'log_failed_logins' => true,
824 1588 'log_user_changes' => true,
@@ -895,17 +1659,102 @@
895 1659 }
896 1660 } elseif ( isset( $defaults[ $section ] ) ) {
897 1661 // Validate other sections using generic validator
898 1662 $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] );
1663 +
1664 + // The few keys that live outside get_default_options() on
1665 + // purpose (see apply_install_tweaks()) survive with their own
1666 + // validation, or an import would silently lose them and the
1667 + // XML-RPC resolver would fall back to blocking everything.
1668 + foreach ( self::undeclared_keys( $section ) as $key => $type ) {
1669 + if ( ! array_key_exists( $key, $data ) ) {
1670 + continue;
1671 + }
1672 + if ( 'bool' === $type ) {
1673 + $validated[ $section ][ $key ] = (bool) $data[ $key ];
1674 + } elseif ( is_array( $type ) && in_array( $data[ $key ], $type, true ) ) {
1675 + $validated[ $section ][ $key ] = $data[ $key ];
1676 + }
1677 + }
899 1678 }
900 1679 }
901 1680
1681 + /*
1682 + * The lock on the settings the shared files are built from is NOT applied
1683 + * here, and that is a decision, not an oversight. The second cross review
1684 + * of 2.11.10 raised that register_setting( 'vigilante_options', ... )
1685 + * declares this as its sanitize callback with no lock in it, so anything
1686 + * reaching options.php with that option group would write the whole
1687 + * option. The chain does not close: the plugin prints no settings_fields()
1688 + * for that group anywhere, so the nonce it would need is not obtainable,
1689 + * and the five places that do save (saving a tab, importing, a preset,
1690 + * restoring the defaults, resetting a section) all apply
1691 + * keep_locked_file_settings() themselves.
1692 + *
1693 + * Putting it here instead would be worse than the door it closes. A
1694 + * register_setting() callback runs on EVERY update_option() of this
1695 + * option, so it would also lock the writes with no user behind them: the
1696 + * expiry of Under Attack restoring what it hardened, WP-CLI and cron.
1697 + * Those are already covered by matriz-red-ajustes-compartidos.sh, which
1698 + * is where such a change would show up as a row that stopped passing.
1699 + */
1700 +
902 1701 return apply_filters( 'vigilante_validate_options', $validated, $input );
903 1702 }
904 1703
905 1704 /**
1705 + * Keys deliberately absent from get_default_options(), with how to validate them
1706 + *
1707 + * Declaring them as defaults would break the fallback they exist for (see
1708 + * apply_install_tweaks()), but the validator still has to know them, or a
1709 + * settings import drops them (found in the 2.11.0 cross review).
1710 + *
1711 + * @since 2.11.0
1712 + *
1713 + * @param string $section Section name.
1714 + * @return array key => 'bool' or list of allowed values.
1715 + */
1716 + private static function undeclared_keys( $section ) {
1717 + $keys = array(
1718 + 'wp_hardening' => array( 'xmlrpc_mode' => array( 'full', 'pingback', 'none' ) ),
1719 + 'login_security' => array(
1720 + 'disable_xmlrpc' => 'bool',
1721 + 'disable_xmlrpc_pingback' => 'bool',
1722 + ),
1723 + );
1724 +
1725 + return isset( $keys[ $section ] ) ? $keys[ $section ] : array();
1726 + }
1727 +
1728 + /**
1729 + * Whether a default value describes a free list rather than a schema
1730 + *
1731 + * An empty array or sequential numeric keys (an IP whitelist, a list of
1732 + * roles) is a list: every entry the user typed is kept. Anything else is a
1733 + * schema: only its keys survive validation.
1734 + *
1735 + * @since 2.11.0
1736 + *
1737 + * @param array $defaults Default value of a setting.
1738 + * @return bool
1739 + */
1740 + private function is_list_default( $defaults ) {
1741 + if ( array() === $defaults ) {
1742 + return true;
1743 + }
1744 +
1745 + return array_keys( $defaults ) === range( 0, count( $defaults ) - 1 );
1746 + }
1747 +
1748 + /**
906 1749 * Validate a section based on defaults
907 1750 *
1751 + * Since 2.11.0 the result only holds keys the defaults know. The loop that
1752 + * used to reincorporate unknown keys "sanitized" meant a settings import
1753 + * could merge any key it liked into vigilante_options (S7 of the 28 Aug
1754 + * 2026 audit). Lists are the exception, handled first: their entries are
1755 + * data, not keys.
1756 + *
908 1757 * @param array $input Input values.
909 1758 * @param array $defaults Default values.
910 1759 * @return array Validated values.
911 1760 */
@@ -911,8 +1760,25 @@
911 1760 */
912 1761 private function validate_section( $input, $defaults ) {
913 1762 $validated = array();
914 1763
1764 + if ( $this->is_list_default( $defaults ) ) {
1765 + if ( ! is_array( $input ) ) {
1766 + return array();
1767 + }
1768 +
1769 + $list = array();
1770 + foreach ( $input as $value ) {
1771 + if ( is_scalar( $value ) ) {
1772 + $list[] = sanitize_text_field( (string) $value );
1773 + } elseif ( is_array( $value ) ) {
1774 + $list[] = map_deep( $value, 'sanitize_text_field' );
1775 + }
1776 + }
1777 +
1778 + return $list;
1779 + }
1780 +
915 1781 foreach ( $defaults as $key => $default_value ) {
916 1782 if ( ! isset( $input[ $key ] ) ) {
917 1783 $validated[ $key ] = $default_value;
918 1784 continue;
@@ -934,18 +1800,9 @@
934 1800 $validated[ $key ] = sanitize_text_field( $value );
935 1801 }
936 1802 }
937 1803
938 - // Include any extra keys from input
939 - foreach ( $input as $key => $value ) {
940 - if ( ! isset( $validated[ $key ] ) ) {
941 - if ( is_array( $value ) ) {
942 - $validated[ $key ] = array_map( 'sanitize_text_field', $value );
943 - } else {
944 - $validated[ $key ] = sanitize_text_field( $value );
945 - }
946 - }
947 - }
1804 + // Keys the defaults do not declare are dropped on purpose (S7).
948 1805
949 1806 return $validated;
950 1807 }
951 1808 }