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 +670 -100 2.9.52.11.8 View file →
@@ -75,10 +75,9 @@
75 75
76 76 // Bot protection
77 77 'block_bad_bots' => true,
78 78 'block_empty_user_agent' => false,
79 - 'block_http_1_0' => false,
80 -
79 +
81 80 // Rate limiting
82 81 'rate_limiting' => array(
83 82 'enabled' => true,
84 83 'requests_per_minute' => 120,
@@ -97,18 +96,12 @@
97 96
98 97 // User-Agent management
99 98 'ua_whitelist' => array(),
100 99 'ua_blacklist' => array(),
101 - 'country_blocking' => array(
102 - 'enabled' => false,
103 - 'mode' => 'blacklist',
104 - 'countries' => array(),
105 - ),
106 100
107 101 // File protection (htaccess-based)
108 102 'disable_directory_browsing' => true,
109 103 'protect_wp_config' => true,
110 - 'protect_htaccess' => true,
111 104 'protect_wp_includes' => true,
112 105 'protect_uploads_php' => true,
113 106 'protect_sensitive_files' => true,
114 107 // Off by default — only safe when host has a real server-side cron job
@@ -118,12 +111,8 @@
118 111 'block_php_in_themes' => false,
119 112 'limit_http_methods' => true,
120 113 // All methods needed for WordPress core, Gutenberg, REST API, and page builders
121 114 'allowed_http_methods' => array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' ),
122 - 'protected_file_extensions' => array(
123 - 'htaccess', 'htpasswd', 'ini', 'log', 'sql',
124 - 'bak', 'old', 'tmp', 'swp', 'save', 'backup'
125 - ),
126 115 ),
127 116
128 117 // Security Headers settings (includes HTTPS enforcer)
129 118 'security_headers' => array(
@@ -152,9 +141,13 @@
152 141 'usb' => '()',
153 142 ),
154 143
155 144 // CSP - WordPress/Gutenberg compatible defaults
156 - // Note: blob: is required in frame-src and worker-src for the block editor
145 + // Note: blob: is required in frame-src and worker-src for the block editor,
146 + // and in connect-src for the client-side media processing WordPress 7.1
147 + // introduced: @wordpress/vips puts its WebAssembly binary in a blob: URL and
148 + // fetches it, and fetch() is governed by connect-src, where 'self' does not
149 + // cover blob:. Without it the editor cannot process images before upload.
157 150 'csp' => array(
158 151 'enabled' => true,
159 152 'report_only' => false,
160 153 'report_uri' => '',
@@ -163,9 +156,9 @@
163 156 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval' https:",
164 157 'style-src' => "'self' 'unsafe-inline' https:",
165 158 'img-src' => "'self' data: https: blob:",
166 159 'font-src' => "'self' data: https:",
167 - 'connect-src' => "'self' https: wss:",
160 + 'connect-src' => "'self' https: wss: blob:",
168 161 'media-src' => "'self' https: blob:",
169 162 'frame-src' => "'self' https: blob:",
170 163 'frame-ancestors' => "'self'",
171 164 'base-uri' => "'self'",
@@ -183,11 +176,40 @@
183 176 'resource_policy' => 'cross-origin',
184 177 ),
185 178
186 179 // HTTPS Enforcer (moved from separate module)
187 - 'force_https' => true,
188 - 'redirect_http_to_https' => true,
189 - 'fix_mixed_content' => true,
180 + //
181 + // force_https rewrites siteurl/home to https on activation, so it
182 + // ships off: a site without working HTTPS would end up pointing at
183 + // an address that does not answer. It is an opt-in decision per
184 + // site, made from the Security Headers tab. Sites whose URLs a
185 + // previous version already rewrote keep them; nothing reverts them.
186 + 'force_https' => false,
187 + // Off by default for the same reason as force_https above: the
188 + // plugin does not decide that a site is on HTTPS. It only
189 + // redirects when the site's own home URL already says https, so
190 + // shipping it on was harmless in practice, but it is still a
191 + // decision that belongs to the site owner, not to us. Sites that
192 + // already have it on keep it.
193 + 'redirect_http_to_https' => false,
194 + // Rewrites http:// URLs of this same site to https://, and only
195 + // on a site already served over HTTPS, so it cannot reach a third
196 + // party and cannot make a resource fail. It still ships off: a
197 + // setting whose own description says it rewrites http to https
198 + // does not belong in the factory configuration of a plugin that
199 + // deliberately does not decide whether a site is on HTTPS. Every
200 + // https-related setting here is the owner's call, and this one is
201 + // one click away for anyone who has just migrated and wants their
202 + // old content rewritten.
203 + 'fix_mixed_content' => false,
204 + // The Content-Security-Policy directive that tells the browser to
205 + // upgrade every http:// request, including the ones pointing at
206 + // other people's servers. If any of those has no HTTPS the
207 + // resource simply stops loading, so this is the one piece of
208 + // mixed content handling that can break a page, and it is off by
209 + // default like everything else here that forces HTTPS. It used to
210 + // ride along with fix_mixed_content with no way to separate them.
211 + 'upgrade_insecure_requests' => false,
190 212
191 213 // Server Protection (moved from firewall in v2.0.0)
192 214 'hide_server_signature' => true,
193 215 'remove_fingerprinting_headers' => true,
@@ -200,10 +222,17 @@
200 222 'lockout_duration' => 1800,
201 223 'lockout_increment' => true,
202 224 'max_lockout_duration' => 86400,
203 225 'hide_login_errors' => true,
204 - 'disable_xmlrpc' => true,
205 - 'disable_xmlrpc_pingback' => true,
226 + // XML-RPC se movio a wp_hardening en la 2.9.7; lo resuelve
227 + // Vigilante_Comment_Security::resolve_xmlrpc_mode(), que
228 + // sustituye a las dos casillas anteriores (disable_xmlrpc y
229 + // disable_xmlrpc_pingback), que podian estar activas a la vez y
230 + // contradecirse. A proposito NO se declara aqui ningun default: si se
231 + // declarara, el merge con los defaults lo rellenaria siempre y taparia el
232 + // respaldo que lee el ajuste antiguo de los sitios que aun no han vuelto a
233 + // guardar la pestana. Sin nada guardado, el resolutor devuelve 'full', que
234 + // es lo que hacia el default anterior.
206 235 'disable_application_passwords' => false,
207 236 'notify_on_lockout' => false,
208 237 'notify_on_admin_login' => false,
209 238 'ip_whitelist' => array(),
@@ -259,26 +288,29 @@
259 288 'admin', 'administrator', 'user', 'test', 'guest',
260 289 'info', 'root', 'adm', 'sysadmin', 'support',
261 290 'webmaster', 'master', 'owner', 'manager', 'demo',
262 291 ),
263 - 'warn_existing_insecure' => true,
264 292 'block_author_scanning' => true,
265 293 'force_strong_passwords' => true,
266 294 'min_password_length' => 12,
267 295
268 296 // Granular password policy. Applies only while
269 - // force_strong_passwords is on. Defaults reproduce the previous
270 - // all-requirements behaviour so existing sites keep the same
271 - // rules until the admin relaxes them. block_username is the only
272 - // new opt-in rule (off by default to avoid rejecting passwords
273 - // that were valid before). affected_roles empty = all roles.
297 + // force_strong_passwords is on. The defaults follow current
298 + // guidance (NIST SP 800-63B): what makes a password weak is being
299 + // guessable, not lacking a symbol, and composition rules push
300 + // people towards predictable substitutions and towards writing
301 + // the password down. So out of the box only the two rules that
302 + // block genuinely guessable passwords are on, and the four
303 + // character-class requirements ship off for anyone to turn on.
304 + // Existing sites keep whatever they have stored.
305 + // affected_roles empty = all roles.
274 306 'password_policy' => array(
275 - 'require_uppercase' => true,
276 - 'require_lowercase' => true,
277 - 'require_number' => true,
278 - 'require_special' => true,
307 + 'require_uppercase' => false,
308 + 'require_lowercase' => false,
309 + 'require_number' => false,
310 + 'require_special' => false,
279 311 'block_common' => true,
280 - 'block_username' => false,
312 + 'block_username' => true,
281 313 'affected_roles' => array(),
282 314 ),
283 315
284 316 'prevent_display_name_login_match' => true,
@@ -283,12 +315,16 @@
283 315
284 316 'prevent_display_name_login_match' => true,
285 317
286 318 // Admin monitoring
319 + // The two alerts that report someone gaining power ship on: a
320 + // new administrator and a role being raised are the signature of
321 + // an account takeover, and they are rare enough not to be noise.
322 + // The other two are ordinary admin housekeeping and stay opt-in.
287 323 'admin_monitoring' => array(
288 - 'alert_new_admin' => false,
324 + 'alert_new_admin' => true,
289 325 'alert_admin_email_change' => false,
290 - 'alert_permission_elevation' => false,
326 + 'alert_permission_elevation' => true,
291 327 'alert_admin_password_change' => false,
292 328 ),
293 329
294 330 // Force password reset (no options, uses native WordPress flow)
@@ -308,17 +344,23 @@
308 344 ),
309 345
310 346 // Session limits
311 347 'session_limits' => array(
312 - 'enabled' => false,
348 + 'enabled' => true,
313 349 'max_sessions' => 3,
314 350 'behavior' => 'close_oldest',
315 351 'exclude_admins' => false,
316 352 ),
317 353
318 - // Password expiration
354 + // Password expiration ships OFF. Forced rotation is no longer
355 + // recommended (NIST SP 800-63B advises against it) and it is by
356 + // far the biggest source of support here: people locked out mid
357 + // task, cron reminders that never arrive, roles nobody meant to
358 + // include. The feature stays for anyone who has to comply with a
359 + // policy that still demands it, and the Configuration Score keeps
360 + // pointing at it, which is what that score is for.
319 361 'password_expiration' => array(
320 - 'enabled' => true,
362 + 'enabled' => false,
321 363 'expire_days' => 90,
322 364 'warning_days' => 14,
323 365 'affected_roles' => array( 'administrator', 'editor' ),
324 366 'excluded_users' => array(),
@@ -341,9 +383,15 @@
341 383
342 384 // wp-config security
343 385 'disallow_file_edit' => true,
344 386 'disallow_file_mods' => false,
345 - 'force_ssl_admin' => true,
387 + // Off by default. This one writes FORCE_SSL_ADMIN into
388 + // wp-config.php, and it used to do so on activation with no
389 + // check that the site answers over HTTPS at all, which locks the
390 + // owner out of their own admin. Forcing HTTPS is an opt-in
391 + // decision per site, consistent with force_https and with HSTS,
392 + // both of which already ship off.
393 + 'force_ssl_admin' => false,
346 394 'wp_debug' => true,
347 395 // Off by default — only safe when host has a real server-side cron job;
348 396 // pairs with firewall.protect_wp_cron to block both internal triggering
349 397 // (this constant) and external HTTP abuse (the .htaccess rule).
@@ -397,19 +445,8 @@
397 445 // Strict-mode users can remove it (CSS injection is still a
398 446 // vector, defended primarily by CSP in the headers module).
399 447 '.css',
400 448 ),
401 - 'suspicious_patterns' => array(
402 - 'eval(',
403 - 'base64_decode(',
404 - 'gzinflate(',
405 - 'str_rot13(',
406 - 'exec(',
407 - 'shell_exec(',
408 - 'system(',
409 - 'passthru(',
410 - 'assert(',
411 - ),
412 449 ),
413 450
414 451 // Activity Log settings
415 452 'activity_log' => array(
@@ -431,10 +468,8 @@
431 468 ),
432 469
433 470 // Backup settings
434 471 'backup' => array(
435 - 'auto_backup' => true,
436 - 'backup_before_update' => true,
437 472 'keep_backups' => 5,
438 473 ),
439 474
440 475 // Notification settings (centralized recipients for all admin emails)
@@ -447,12 +482,8 @@
447 482 // Advanced settings
448 483 'advanced' => array(
449 484 'remove_readme' => true,
450 485 'remove_license' => true,
451 - 'block_author_archives' => false,
452 - 'disable_embeds' => false,
453 - 'uninstall_cleanup' => true,
454 - 'debug_mode' => false,
455 486 ),
456 487
457 488 // Security Analyzer (v2.1.0) — on-demand + weekly Security Check
458 489 'security_analyzer' => array(
@@ -630,8 +661,489 @@
630 661 wp_cache_delete( self::OPTION_NAME, 'options' );
631 662 }
632 663
633 664 /**
665 + * Whether this context is allowed to write the files a network shares
666 + *
667 + * wp-config.php and the root .htaccess are single files for the whole
668 + * network, while Vigilant's settings are per site. Without a gate, every
669 + * save, activation and deactivation from any site rewrites those files from
670 + * that site's own options, so the last one to save wins and silently undoes
671 + * the rest. Measured on a real network: the main site enables "disable file
672 + * editing", a subsite admin presses Save on their own screen without
673 + * touching it, and the constant disappears from wp-config.php while the main
674 + * site's screen keeps showing the box ticked.
675 + *
676 + * So on a network only the main site decides, and only a network
677 + * administrator. WP-CLI on the main site counts too: there is no user to ask
678 + * there, but the site is the right one, and a network admin running
679 + * `wp plugin activate --network` expects the files to be written.
680 + *
681 + * On a single site this is always true and nothing changes.
682 + *
683 + * @since 2.9.8
684 + *
685 + * @return bool
686 + */
687 + /**
688 + * Whether this site is the one that owns the files a network shares.
689 + *
690 + * Pure site identity, with no capability in it, and that is the point. A
691 + * refresh that Vigilant performs by itself, such as rewriting its own
692 + * .htaccess block after an update, decides nothing: the content comes from
693 + * this site's own options whoever happens to be visiting. What must not
694 + * happen is a *different* site writing the shared file, and that is exactly
695 + * what this answers.
696 + *
697 + * can_write_shared_files() below adds the capability on top, and is the
698 + * right question for anything a person initiates from a settings screen.
699 + *
700 + * @since 2.10.1
701 + * @return bool
702 + */
703 + public static function owns_shared_files() {
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() );
708 + }
709 +
710 + public static function can_write_shared_files() {
711 + if ( ! is_multisite() ) {
712 + return true;
713 + }
714 +
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() ) {
718 + return false;
719 + }
720 +
721 + // WP-CLI with nobody logged in: there is no user to ask, and the site is
722 + // the right one, so a network admin running `wp plugin activate --network`
723 + // gets the files written. With a user set (wp --user=...) the capability
724 + // is checked like anywhere else, so the gate cannot be side-stepped by
725 + // running as a subsite administrator.
726 + if ( defined( 'WP_CLI' ) && WP_CLI && ! get_current_user_id() ) {
727 + return true;
728 + }
729 +
730 + return current_user_can( 'manage_network_options' );
731 + }
732 +
733 + /**
734 + * The one message shown wherever a shared-file setting is out of reach
735 + *
736 + * Deliberately a single string reused by every section, instead of one per
737 + * section: it says the same thing everywhere and there is no reason to make
738 + * translators write it four times.
739 + *
740 + * @since 2.9.8
741 + *
742 + * @return string
743 + */
744 + public static function get_shared_files_notice() {
745 + 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' );
746 + }
747 +
748 + /**
749 + * Apply the tweaks a brand new installation gets on top of the raw defaults
750 + *
751 + * A few keys are deliberately missing from get_default_options() because
752 + * declaring them would break something else. XML-RPC is the one case today:
753 + * declaring wp_hardening.xmlrpc_mode would make the defaults merge fill it in
754 + * always and hide the fallback that reads the old pair of settings on sites
755 + * that have not re-saved the tab. With nothing stored the resolver answers
756 + * 'full', which blocks XML-RPC completely, and that is not what we want a new
757 + * site to get.
758 + *
759 + * Everything that seeds a clean configuration has to run this: the activation
760 + * hook, the per-section reset and the global reset to defaults. Otherwise the
761 + * defaults you get by pressing a button are not the defaults you get by
762 + * installing the plugin, which is exactly what happened until 2.9.8.
763 + *
764 + * @since 2.9.8
765 + *
766 + * @param array $options Options array to adjust.
767 + * @return array
768 + */
769 + public static function apply_install_tweaks( $options ) {
770 + if ( ! isset( $options['wp_hardening'] ) || ! is_array( $options['wp_hardening'] ) ) {
771 + $options['wp_hardening'] = array();
772 + }
773 +
774 + $options['wp_hardening']['xmlrpc_mode'] = 'pingback';
775 +
776 + return $options;
777 + }
778 +
779 + /**
780 + * Keys that hold what the site owner typed in, never wiped by a restore
781 + *
782 + * Putting the security posture back to its defaults is one thing; deleting
783 + * an IP whitelist, the secret login address, the two factor setup or the
784 + * addresses that receive the alerts is another, and nobody presses a button
785 + * called "restore defaults" expecting that. Both the Standard preset and the
786 + * two reset buttons leave these alone.
787 + *
788 + * @since 2.9.8
789 + *
790 + * @return array<string,string[]>
791 + */
792 + public static function get_user_data_keys() {
793 + return array(
794 + 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header' ),
795 + 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
796 + 'user_security' => array( 'insecure_usernames' ),
797 + 'file_integrity' => array( 'excluded_paths', 'excluded_extensions' ),
798 + 'email' => array( 'additional_recipients' ),
799 + );
800 + }
801 +
802 + /**
803 + * Settings whose only effect is written to a file the network shares
804 + *
805 + * true for a whole section, or the list of keys inside it. Used to keep a
806 + * subsite from resetting settings it does not control: the file is written
807 + * from the main site, so resetting the local copy would only make the two
808 + * disagree.
809 + *
810 + * Note this is not every setting that reaches .htaccess. Blocking bad bots
811 + * or empty user agents also runs in PHP, per site, so those stay editable on
812 + * a subsite: the PHP half protects that site and the .htaccess half is
813 + * refused, leaving the main site's rules standing. On the main site they
814 + * are locked too, see get_main_site_file_settings().
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 + *
820 + * @since 2.9.8
821 + *
822 + * @return array<string,true|string[]>
823 + */
824 + public static function get_shared_file_settings() {
825 + return array(
826 + 'security_headers' => true,
827 + 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
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' ),
830 + );
831 + }
832 +
833 + /**
834 + * Settings the shared files are built from that also act on the site storing them
835 + *
836 + * get_shared_file_settings() lists what does nothing but end up in a shared
837 + * file. These do both: blocking bad bots and bad query strings, the visitor
838 + * IP detection and the two whitelists run in PHP for the site that stores
839 + * them, and on the main site of a network they are also what the .htaccess
840 + * rules of every site are generated from; the three writing module switches
841 + * (firewall, security_headers, wp_hardening) decide whether the .htaccess
842 + * blocks and the wp-config.php constants exist at all.
843 + *
844 + * Since 2.11.8 it also locks what decides whether the shared files are
845 + * WATCHED, not built: the File Integrity module and its scan_critical_config
846 + * switch. On the main site the critical-file scan is the network's canary
847 + * for a change to wp-config.php or the root .htaccess, which only a network
848 + * administrator can approve, so a main-site administrator without network
849 + * rights must not be able to silence it by turning either one off. Closing
850 + * the ignore list and the clear-results button in 2.11.8 left these two as
851 + * the remaining routes; found by the audit of the admin surface.
852 + *
853 + * On a subsite all of them only act on that site, so they stay editable
854 + * there (get_locked_file_settings() adds this set only when owns_shared_files()).
855 + *
856 + * Until 2.11.6 an administrator of the main site without network rights
857 + * could change any of them, and the file-only ones too: the write to the
858 + * file was refused at that moment, but the value stayed stored, and the
859 + * refresh after the next update, or the next save by a network
860 + * administrator, published it to the whole network.
861 + *
862 + * @since 2.11.6
863 + * @since 2.11.8 The file_integrity module and scan_critical_config.
864 + *
865 + * @return array<string,string[]>
866 + */
867 + public static function get_main_site_file_settings() {
868 + return array(
869 + 'modules' => array( 'firewall', 'security_headers', 'wp_hardening', 'file_integrity' ),
870 + 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'ip_whitelist', 'ua_whitelist' ),
871 + 'file_integrity' => array( 'scan_critical_config' ),
872 + );
873 + }
874 +
875 + /**
876 + * Shared file settings the current user may not change on this site
877 + *
878 + * Empty when the user can write the shared files. Otherwise the file-only
879 + * settings on every site, plus, on the main site, the ones it also builds
880 + * the shared files from.
881 + *
882 + * @since 2.11.6
883 + *
884 + * @return array<string,true|string[]>
885 + */
886 + public static function get_locked_file_settings() {
887 + if ( self::can_write_shared_files() ) {
888 + return array();
889 + }
890 +
891 + $locked = self::get_shared_file_settings();
892 +
893 + if ( self::owns_shared_files() ) {
894 + foreach ( self::get_main_site_file_settings() as $section => $keys ) {
895 + if ( ! isset( $locked[ $section ] ) ) {
896 + $locked[ $section ] = $keys;
897 + } elseif ( is_array( $locked[ $section ] ) ) {
898 + $locked[ $section ] = array_values( array_unique( array_merge( $locked[ $section ], $keys ) ) );
899 + }
900 + }
901 + }
902 +
903 + return $locked;
904 + }
905 +
906 + /**
907 + * Put back the stored value of every shared file setting the user may not change
908 + *
909 + * For every writer of the whole configuration: saving a tab, importing a
910 + * file, applying a preset, restoring the defaults. Hiding a field on the
911 + * screen decides nothing, because the request can carry the key anyway. A
912 + * key that was not stored is dropped, so its default keeps applying.
913 + *
914 + * @since 2.11.6
915 + *
916 + * @param array $options Configuration about to be stored.
917 + * @param array $stored Configuration stored now, as read from the option.
918 + * @return array
919 + */
920 + public static function keep_locked_file_settings( $options, $stored ) {
921 + $options = is_array( $options ) ? $options : array();
922 + $stored = is_array( $stored ) ? $stored : array();
923 + $locked = self::get_locked_file_settings();
924 +
925 + if ( ! $locked ) {
926 + return $options;
927 + }
928 +
929 + /*
930 + * A key that was never stored takes its default, which is what it was
931 + * worth before. Until 2.11.8 it was dropped instead, and the sanitize
932 + * callback of the option filled it in again, but validate_options()
933 + * fills a missing module switch with false, not with its default.
934 + */
935 + $instance = new self();
936 + $defaults = $instance->get_default_options();
937 +
938 + foreach ( $locked as $section => $keys ) {
939 + if ( true === $keys ) {
940 + if ( array_key_exists( $section, $stored ) ) {
941 + $options[ $section ] = $stored[ $section ];
942 + } elseif ( isset( $defaults[ $section ] ) ) {
943 + $options[ $section ] = $defaults[ $section ];
944 + } else {
945 + unset( $options[ $section ] );
946 + }
947 + continue;
948 + }
949 +
950 + $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
951 + $default_section = ( isset( $defaults[ $section ] ) && is_array( $defaults[ $section ] ) ) ? $defaults[ $section ] : array();
952 +
953 + foreach ( $keys as $key ) {
954 + if ( array_key_exists( $key, $stored_section ) ) {
955 + $value = $stored_section[ $key ];
956 + } elseif ( array_key_exists( $key, $default_section ) ) {
957 + $value = $default_section[ $key ];
958 + } else {
959 + if ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
960 + unset( $options[ $section ][ $key ] );
961 + }
962 + continue;
963 + }
964 +
965 + if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
966 + $options[ $section ] = array();
967 + }
968 + $options[ $section ][ $key ] = $value;
969 + }
970 + }
971 +
972 + return $options;
973 + }
974 +
975 + /**
976 + * Take a lock kept as a row of the options table, or report that another request holds it
977 + *
978 + * add_option() cannot be a lock: it runs INSERT ... ON DUPLICATE KEY UPDATE
979 + * (wp-includes/option.php:1142 in WP 7.1), so two requests that both find
980 + * the option missing both "create" it and both believe they hold it. INSERT
981 + * IGNORE creates the row for exactly one of them, which is what core does in
982 + * WP_Upgrader::create_lock() (wp-admin/includes/class-wp-upgrader.php:1065).
983 + * A lock older than the timeout counts as abandoned, by a fatal error between
984 + * taking and releasing it, and only one request takes it over.
985 + *
986 + * @since 2.11.8
987 + *
988 + * @param string $name Option name of the lock, in the current site's table.
989 + * @param int $timeout Seconds after which a held lock counts as abandoned.
990 + * @return bool True if this request now holds the lock.
991 + */
992 + public static function acquire_option_lock( $name, $timeout ) {
993 + global $wpdb;
994 +
995 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic lock needs INSERT IGNORE, which the options API does not offer; same query as WP_Upgrader::create_lock().
996 + if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) ) ) {
997 + wp_cache_delete( $name, 'options' );
998 + return true;
999 + }
1000 +
1001 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock row as stored right now, not a cached copy.
1002 + $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1003 +
1004 + if ( null === $held || ( time() - (int) $held ) < $timeout ) {
1005 + return false;
1006 + }
1007 +
1008 + // Abandoned: the delete only matches the value that was read, and only one
1009 + // request wins the insert that follows.
1010 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes an abandoned lock row.
1011 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $held ) );
1012 +
1013 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same atomic insert as above.
1014 + return (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) );
1015 + }
1016 +
1017 + /**
1018 + * Release a lock taken with acquire_option_lock()
1019 + *
1020 + * @since 2.11.8
1021 + *
1022 + * @param string $name Option name of the lock.
1023 + */
1024 + public static function release_option_lock( $name ) {
1025 + global $wpdb;
1026 +
1027 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes the row acquire_option_lock() inserted.
1028 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1029 + wp_cache_delete( $name, 'options' );
1030 + }
1031 +
1032 + /**
1033 + * Put a configuration back to the defaults without deleting what the owner typed
1034 + *
1035 + * @since 2.9.8
1036 + *
1037 + * @param array $current Configuration being replaced.
1038 + * @return array
1039 + */
1040 + public static function get_defaults_preserving_user_data( $current ) {
1041 + $instance = new self();
1042 + $defaults = self::apply_install_tweaks( $instance->get_default_options() );
1043 +
1044 + foreach ( self::get_user_data_keys() as $section => $keys ) {
1045 + foreach ( $keys as $key ) {
1046 + if ( isset( $current[ $section ] ) && array_key_exists( $key, (array) $current[ $section ] ) ) {
1047 + $defaults[ $section ][ $key ] = $current[ $section ][ $key ];
1048 + }
1049 + }
1050 + }
1051 +
1052 + return $defaults;
1053 + }
1054 +
1055 + /**
1056 + * The values the Standard preset applies
1057 + *
1058 + * Standard is the configuration a new installation gets, with every module
1059 + * on. It is built from the defaults rather than written out by hand, because
1060 + * a hand-written copy drifts: until 2.9.8 Standard named a dozen fields and
1061 + * left everything else alone, so applying it after Maximum kept Maximum's
1062 + * password rules, its administrator alerts, its session limits and its
1063 + * password expiry, and the preset that says it applies sensible defaults
1064 + * applied almost none of them.
1065 + *
1066 + * The only thing it does not touch is what the site owner typed in: IP and
1067 + * user agent lists, the custom login address, two factor configuration, the
1068 + * integrity scan exclusions and the extra notification recipients. Putting
1069 + * the security posture back to the defaults is one thing, throwing away
1070 + * someone's whitelist is another, and "Reset to Defaults" is right there for
1071 + * that.
1072 + *
1073 + * @since 2.9.8
1074 + *
1075 + * @return array
1076 + */
1077 + private function get_standard_preset_values() {
1078 + $values = self::apply_install_tweaks( $this->get_default_options() );
1079 +
1080 + foreach ( array_keys( $values['modules'] ) as $module ) {
1081 + $values['modules'][ $module ] = true;
1082 + }
1083 +
1084 + foreach ( self::get_user_data_keys() as $section => $keys ) {
1085 + foreach ( $keys as $key ) {
1086 + unset( $values[ $section ][ $key ] );
1087 + }
1088 + }
1089 +
1090 + unset( $values['user_security']['password_expiration']['excluded_users'] );
1091 +
1092 + return $values;
1093 + }
1094 +
1095 + /**
1096 + * Merge a preset over a configuration
1097 + *
1098 + * Not array_replace_recursive(), which is wrong for this in two ways. A list
1099 + * of roles in the preset is merged position by position instead of replacing
1100 + * the stored one, so applying Standard over Maximum turned the two roles
1101 + * Standard expires passwords for into Maximum's five with the first two
1102 + * overwritten. And an empty list in the preset clears nothing at all,
1103 + * because there is no element to replace with.
1104 + *
1105 + * So: associative arrays are merged key by key, and lists and scalars are
1106 + * replaced outright.
1107 + *
1108 + * @since 2.9.8
1109 + *
1110 + * @param array $base Current configuration.
1111 + * @param array $overlay Preset values.
1112 + * @return array
1113 + */
1114 + public static function merge_preset( $base, $overlay ) {
1115 + foreach ( $overlay as $key => $value ) {
1116 + if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && ! self::is_list( $value ) ) {
1117 + $base[ $key ] = self::merge_preset( $base[ $key ], $value );
1118 + continue;
1119 + }
1120 +
1121 + $base[ $key ] = $value;
1122 + }
1123 +
1124 + return $base;
1125 + }
1126 +
1127 + /**
1128 + * Whether an array is a plain list (0..n-1 keys)
1129 + *
1130 + * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1131 + *
1132 + * @since 2.9.8
1133 + *
1134 + * @param array $value Array to inspect.
1135 + * @return bool
1136 + */
1137 + private static function is_list( $value ) {
1138 + if ( array() === $value ) {
1139 + return true;
1140 + }
1141 +
1142 + return array_keys( $value ) === range( 0, count( $value ) - 1 );
1143 + }
1144 +
1145 + /**
634 1146 * Get presets with descriptions
635 1147 *
636 1148 * @return array Presets configuration.
637 1149 */
@@ -636,44 +1148,14 @@
636 1148 * @return array Presets configuration.
637 1149 */
638 1150 public function get_presets() {
639 1151 return array(
640 - 'standard' => array(
641 - 'name' => __( 'Standard', 'vigilante' ),
642 - 'description' => __( 'Balanced security suitable for most websites. Enables all modules with sensible defaults.', 'vigilante' ),
643 - 'modules' => array(
644 - 'firewall' => true,
645 - 'security_headers' => true,
646 - 'login_security' => true,
647 - 'rest_api_security'=> true,
648 - 'user_security' => true,
649 - 'wp_hardening' => true,
650 - 'file_integrity' => true,
651 - 'activity_log' => true,
1152 + 'standard' => array_merge(
1153 + array(
1154 + 'name' => __( 'Standard', 'vigilante' ),
1155 + 'description' => __( 'Balanced security suitable for most websites. Enables every module and puts every setting back to the value a new installation gets.', 'vigilante' ),
652 1156 ),
653 - 'firewall' => array(
654 - 'block_bad_query_strings' => true,
655 - 'block_sql_injection' => true,
656 - 'block_xss_attacks' => true,
657 - 'rate_limiting' => array(
658 - 'enabled' => true,
659 - 'requests_per_minute' => 120,
660 - ),
661 - ),
662 - 'login_security' => array(
663 - 'max_attempts' => 5,
664 - 'lockout_duration' => 1800,
665 - 'disable_xmlrpc' => true,
666 - ),
667 - 'rest_api_security' => array(
668 - 'mode' => 'selective',
669 - ),
670 - 'user_security' => array(
671 - 'prevent_display_name_login_match' => true,
672 - ),
673 - 'file_integrity' => array(
674 - 'notify_level' => 'suspicious_only',
675 - ),
1157 + $this->get_standard_preset_values()
676 1158 ),
677 1159
678 1160 'maximum' => array(
679 1161 'name' => __( 'Maximum Security', 'vigilante' ),
@@ -718,9 +1200,9 @@
718 1200 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval'",
719 1201 'style-src' => "'self' 'unsafe-inline'",
720 1202 'img-src' => "'self' data: https: blob:",
721 1203 'font-src' => "'self' data:",
722 - 'connect-src' => "'self' https:",
1204 + 'connect-src' => "'self' https: blob:",
723 1205 'frame-src' => "'self' blob:",
724 1206 'frame-ancestors' => "'none'",
725 1207 'worker-src' => "'self' blob:",
726 1208 'object-src' => "'none'",
@@ -734,13 +1216,13 @@
734 1216 'login_security' => array(
735 1217 'max_attempts' => 3,
736 1218 'lockout_duration' => 3600,
737 1219 'lockout_increment' => true,
738 - 'disable_xmlrpc' => true,
739 1220 'notify_on_lockout' => true,
740 1221 'notify_on_admin_login' => true,
741 1222 ),
742 1223 'wp_hardening' => array(
1224 + 'xmlrpc_mode' => 'full',
743 1225 'disallow_file_edit' => true,
744 1226 'disallow_file_mods' => true,
745 1227 // close_old_comments is intentionally NOT touched by Maximum:
746 1228 // it would unilaterally close discussion on every old post,
@@ -801,8 +1283,23 @@
801 1283 'scan_frequency' => 'daily',
802 1284 'notify_level' => 'all',
803 1285 'instant_alert' => true,
804 1286 ),
1287 + // A configuration called Maximum Security that never tells you
1288 + // anything happened is half a product, so the audit alerts ship
1289 + // on with it. The shared cooldown keeps a sustained attack from
1290 + // turning into a flood. Under Attack mode builds on this preset,
1291 + // so it inherits them for as long as it is on and gives them back
1292 + // when it is switched off.
1293 + 'audit_alerts' => array(
1294 + 'immediate' => array(
1295 + 'enabled' => true,
1296 + 'min_severity' => 'critical',
1297 + ),
1298 + 'threshold' => array(
1299 + 'enabled' => true,
1300 + ),
1301 + ),
805 1302 'activity_log' => array(
806 1303 'log_logins' => true,
807 1304 'log_failed_logins' => true,
808 1305 'log_user_changes' => true,
@@ -879,8 +1376,23 @@
879 1376 }
880 1377 } elseif ( isset( $defaults[ $section ] ) ) {
881 1378 // Validate other sections using generic validator
882 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 + }
883 1395 }
884 1396 }
885 1397
886 1398 return apply_filters( 'vigilante_validate_options', $validated, $input );
@@ -886,10 +1398,60 @@
886 1398 return apply_filters( 'vigilante_validate_options', $validated, $input );
887 1399 }
888 1400
889 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 + /**
890 1446 * Validate a section based on defaults
891 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 + *
892 1454 * @param array $input Input values.
893 1455 * @param array $defaults Default values.
894 1456 * @return array Validated values.
895 1457 */
@@ -895,8 +1457,25 @@
895 1457 */
896 1458 private function validate_section( $input, $defaults ) {
897 1459 $validated = array();
898 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 +
899 1478 foreach ( $defaults as $key => $default_value ) {
900 1479 if ( ! isset( $input[ $key ] ) ) {
901 1480 $validated[ $key ] = $default_value;
902 1481 continue;
@@ -918,18 +1497,9 @@
918 1497 $validated[ $key ] = sanitize_text_field( $value );
919 1498 }
920 1499 }
921 1500
922 - // Include any extra keys from input
923 - foreach ( $input as $key => $value ) {
924 - if ( ! isset( $validated[ $key ] ) ) {
925 - if ( is_array( $value ) ) {
926 - $validated[ $key ] = array_map( 'sanitize_text_field', $value );
927 - } else {
928 - $validated[ $key ] = sanitize_text_field( $value );
929 - }
930 - }
931 - }
1501 + // Keys the defaults do not declare are dropped on purpose (S7).
932 1502
933 1503 return $validated;
934 1504 }
935 1505 }