PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.6
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.6
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-settings.php

class-settings.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.6, at includes/class-settings.php

1,405 lines 60.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings Class
4 *
5 * Centralized settings management with default values
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Settings
17 *
18 * Handles all plugin settings with defaults, getters and setters
19 */
20 class Vigilante_Settings {
21
22 /**
23 * Option name in database
24 */
25 const OPTION_NAME = 'vigilante_options';
26
27 /**
28 * Cached options
29 *
30 * @var array|null
31 */
32 private $options = null;
33
34 /**
35 * Default options structure
36 *
37 * @var array
38 */
39 private $defaults;
40
41 /**
42 * Constructor
43 */
44 public function __construct() {
45 $this->defaults = $this->get_default_options();
46 }
47
48 /**
49 * Get all default options
50 *
51 * @return array Complete default options array
52 */
53 public function get_default_options() {
54 return array(
55 // Module toggles - 8 modules that match tabs
56 'modules' => array(
57 'firewall' => true,
58 'security_headers' => true,
59 'login_security' => true,
60 'rest_api_security'=> true,
61 'user_security' => true,
62 'wp_hardening' => true,
63 'file_integrity' => true,
64 'activity_log' => true,
65 ),
66
67 // Firewall settings (includes htaccess, rate limiting, file protection)
68 'firewall' => array(
69 // Request filtering (PHP-based)
70 'block_bad_query_strings' => true,
71 'block_sql_injection' => true,
72 'block_xss_attacks' => true,
73 'block_file_inclusion' => true,
74 'block_directory_traversal' => true,
75
76 // Bot protection
77 'block_bad_bots' => true,
78 'block_empty_user_agent' => false,
79
80 // Rate limiting
81 'rate_limiting' => array(
82 'enabled' => true,
83 'requests_per_minute' => 120,
84 'block_duration' => 300,
85 'progressive' => false,
86 'max_block_duration' => 86400,
87 ),
88
89 // IP management
90 'ip_whitelist' => array(),
91 'ip_blacklist' => array(),
92
93 // Proxy / CDN: forwarded header to trust for the visitor IP.
94 // Empty = trust only REMOTE_ADDR (the real connection, unspoofable).
95 'trusted_proxy_header' => '',
96
97 // User-Agent management
98 'ua_whitelist' => array(),
99 'ua_blacklist' => array(),
100
101 // File protection (htaccess-based)
102 'disable_directory_browsing' => true,
103 'protect_wp_config' => true,
104 'protect_wp_includes' => true,
105 'protect_uploads_php' => true,
106 'protect_sensitive_files' => true,
107 // Off by default — only safe when host has a real server-side cron job
108 // calling wp-cron.php; otherwise scheduled tasks stop running silently.
109 'protect_wp_cron' => false,
110 'block_php_in_plugins' => false,
111 'block_php_in_themes' => false,
112 'limit_http_methods' => true,
113 // All methods needed for WordPress core, Gutenberg, REST API, and page builders
114 'allowed_http_methods' => array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' ),
115 ),
116
117 // Security Headers settings (includes HTTPS enforcer)
118 'security_headers' => array(
119 'enabled' => true,
120
121 // Basic headers
122 'x_frame_options' => 'SAMEORIGIN',
123 'x_content_type_options' => true,
124 'referrer_policy' => 'strict-origin-when-cross-origin',
125
126 // HSTS
127 'hsts' => array(
128 'enabled' => false,
129 'max_age' => 31536000,
130 'include_subdomains' => false,
131 'preload' => false,
132 ),
133
134 // Permissions Policy
135 'permissions_policy' => array(
136 'enabled' => true,
137 'geolocation' => '()',
138 'microphone' => '()',
139 'camera' => '()',
140 'payment' => '(self)',
141 'usb' => '()',
142 ),
143
144 // CSP - WordPress/Gutenberg compatible defaults
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.
150 'csp' => array(
151 'enabled' => true,
152 'report_only' => false,
153 'report_uri' => '',
154 'directives' => array(
155 'default-src' => "'self'",
156 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval' https:",
157 'style-src' => "'self' 'unsafe-inline' https:",
158 'img-src' => "'self' data: https: blob:",
159 'font-src' => "'self' data: https:",
160 'connect-src' => "'self' https: wss: blob:",
161 'media-src' => "'self' https: blob:",
162 'frame-src' => "'self' https: blob:",
163 'frame-ancestors' => "'self'",
164 'base-uri' => "'self'",
165 'form-action' => "'self' https:",
166 'object-src' => "'none'",
167 'worker-src' => "'self' blob:",
168 'upgrade-insecure-requests'=> true,
169 ),
170 ),
171
172 // Cross-origin policies
173 'cross_origin_policies' => array(
174 'embedder_policy' => 'unsafe-none',
175 'opener_policy' => 'same-origin-allow-popups',
176 'resource_policy' => 'cross-origin',
177 ),
178
179 // HTTPS Enforcer (moved from separate module)
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,
212
213 // Server Protection (moved from firewall in v2.0.0)
214 'hide_server_signature' => true,
215 'remove_fingerprinting_headers' => true,
216 ),
217
218 // Login Security settings
219 'login_security' => array(
220 'enabled' => true,
221 'max_attempts' => 5,
222 'lockout_duration' => 1800,
223 'lockout_increment' => true,
224 'max_lockout_duration' => 86400,
225 'hide_login_errors' => 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.
235 'disable_application_passwords' => false,
236 'notify_on_lockout' => false,
237 'notify_on_admin_login' => false,
238 'ip_whitelist' => array(),
239 'custom_login_url' => '',
240 'notify_on_login_url_change' => true,
241 // Two-Factor Authentication
242 'two_factor' => array(
243 'enabled' => false,
244 'method' => 'email',
245 'enforced_roles' => array( 'administrator', 'editor' ),
246 'excluded_users' => array(),
247 'remember_device_days' => 30,
248 'allow_remember_device' => false,
249 'code_expiry_minutes' => 10,
250 'max_attempts' => 3,
251 'email_from_name' => '',
252 'notify_on_enable' => true,
253 'grace_period_days' => 3,
254 ),
255 ),
256
257 // REST API Security settings
258 'rest_api_security' => array(
259 'enabled' => true,
260 'mode' => 'selective',
261 'block_user_enumeration' => true,
262 'disable_jsonp' => true,
263 // Empty by default: /wp/v2/users used to live here, but that
264 // duplicated the dedicated "Block user enumeration" toggle.
265 // Now there is one knob = one behaviour. If you want to
266 // protect additional endpoints in selective mode, add them
267 // explicitly via this setting (or via a filter).
268 'protected_endpoints' => array(),
269 'allowed_public_endpoints' => array(
270 '/wp/v2/posts',
271 '/wp/v2/pages',
272 '/wp/v2/categories',
273 '/wp/v2/tags',
274 '/oembed/',
275 ),
276 'plugin_compatibility' => array(
277 'woocommerce' => true,
278 'contact_form_7' => true,
279 'elementor' => true,
280 ),
281 ),
282
283 // User Security settings
284 'user_security' => array(
285 'enabled' => true,
286 'block_insecure_usernames'=> true,
287 'insecure_usernames' => array(
288 'admin', 'administrator', 'user', 'test', 'guest',
289 'info', 'root', 'adm', 'sysadmin', 'support',
290 'webmaster', 'master', 'owner', 'manager', 'demo',
291 ),
292 'block_author_scanning' => true,
293 'force_strong_passwords' => true,
294 'min_password_length' => 12,
295
296 // Granular password policy. Applies only while
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.
306 'password_policy' => array(
307 'require_uppercase' => false,
308 'require_lowercase' => false,
309 'require_number' => false,
310 'require_special' => false,
311 'block_common' => true,
312 'block_username' => true,
313 'affected_roles' => array(),
314 ),
315
316 'prevent_display_name_login_match' => true,
317
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.
323 'admin_monitoring' => array(
324 'alert_new_admin' => true,
325 'alert_admin_email_change' => false,
326 'alert_permission_elevation' => true,
327 'alert_admin_password_change' => false,
328 ),
329
330 // Force password reset (no options, uses native WordPress flow)
331
332 // Registration approval
333 'registration_approval' => array(
334 'enabled' => false,
335 'notify_admin' => false,
336 'auto_reject_days' => 0,
337 'affected_roles' => array( 'subscriber' ),
338 ),
339
340 // Session management
341 'session_management' => array(
342 'enabled' => true,
343 'show_in_profile' => true,
344 ),
345
346 // Session limits
347 'session_limits' => array(
348 'enabled' => true,
349 'max_sessions' => 3,
350 'behavior' => 'close_oldest',
351 'exclude_admins' => false,
352 ),
353
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.
361 'password_expiration' => array(
362 'enabled' => false,
363 'expire_days' => 90,
364 'warning_days' => 14,
365 'affected_roles' => array( 'administrator', 'editor' ),
366 'excluded_users' => array(),
367 'password_history' => 3,
368 'send_reminder' => false,
369 ),
370
371 // Email verification
372 'email_verification' => array(
373 'enabled' => false,
374 'token_expiry_hours' => 24,
375 'allow_resend' => true,
376 'auto_delete_days' => 7,
377 ),
378 ),
379
380 // WordPress Hardening (combines wp-config, comments, feeds, head cleaner)
381 'wp_hardening' => array(
382 'enabled' => true,
383
384 // wp-config security
385 'disallow_file_edit' => true,
386 'disallow_file_mods' => false,
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,
394 'wp_debug' => true,
395 // Off by default — only safe when host has a real server-side cron job;
396 // pairs with firewall.protect_wp_cron to block both internal triggering
397 // (this constant) and external HTTP abuse (the .htaccess rule).
398 'disable_wp_cron' => false,
399
400 // Comment security
401 'disable_pingbacks' => true,
402 'disable_trackbacks' => true,
403 'require_comment_moderation' => true,
404 'close_old_comments' => false,
405 'close_comments_after_days' => 30,
406 'honeypot_comments' => true,
407
408 // Head cleaner
409 'remove_wp_generator' => true,
410 'remove_wp_version_assets' => false,
411 'remove_rsd_link' => true,
412 'remove_wlw_manifest' => true,
413 'remove_shortlink' => true,
414 'remove_rest_api_link' => false,
415
416 // Feed manager
417 'disable_feeds' => false,
418 'disable_if_no_content' => true,
419 'remove_feed_version' => true,
420 ),
421
422 // File Integrity settings
423 'file_integrity' => array(
424 'enabled' => true,
425 'scan_core' => true,
426 'scan_plugins' => true,
427 'scan_themes' => true,
428 'scan_uploads' => true,
429 'scan_critical_config' => true,
430 'check_closed_plugins' => true,
431 'auto_scan' => true,
432 'scan_frequency' => 'daily',
433 'notify_level' => 'suspicious_only',
434 'instant_alert' => false,
435 'excluded_paths' => array(
436 'wp-content/cache',
437 ),
438 'excluded_extensions' => array(
439 // Translations (regenerated per-locale, never in checksums).
440 '.po', '.mo', '.pot',
441 // Binary images (cosmetic, not executable; often rewritten by image-optimizer plugins).
442 '.jpg', '.jpeg', '.png', '.gif', '.ico', '.webp', '.avif',
443 // Stylesheets: frequently rewritten by themes and optimizer
444 // plugins, a common source of post-update false positives.
445 // Strict-mode users can remove it (CSS injection is still a
446 // vector, defended primarily by CSP in the headers module).
447 '.css',
448 ),
449 ),
450
451 // Activity Log settings
452 'activity_log' => array(
453 'retention_days' => 30,
454 'max_entries' => 10000,
455 'log_logins' => true,
456 'log_failed_logins' => true,
457 'log_user_changes' => true,
458 'log_post_changes' => true,
459 'log_plugin_changes' => true,
460 'log_theme_changes' => true,
461 'log_option_changes' => false,
462 'log_file_changes' => true,
463 'log_comments' => true,
464 'log_media' => true,
465 'excluded_users' => array(),
466 'excluded_ips' => array(),
467 'tracked_options' => array(),
468 ),
469
470 // Backup settings
471 'backup' => array(
472 'keep_backups' => 5,
473 ),
474
475 // Notification settings (centralized recipients for all admin emails)
476 'email' => array(
477 'send_to_admin_email' => true,
478 'additional_recipients' => array(),
479 'send_deactivation_email' => true,
480 ),
481
482 // Advanced settings
483 'advanced' => array(
484 'remove_readme' => true,
485 'remove_license' => true,
486 ),
487
488 // Security Analyzer (v2.1.0) — on-demand + weekly Security Check
489 'security_analyzer' => array(
490 'weekly_scan_enabled' => true,
491 'email_on_regression' => false,
492 ),
493
494 // Audit Alerts (v2.8.0) — alerting layer on top of Security Audit.
495 // The engine subscribes to logged events and only runs when the
496 // Security Audit (activity_log) module is enabled. Opt-in: both
497 // legs start OFF so it never duplicates the per-module emails that
498 // already exist (User Security admin monitoring, Plugin Status...).
499 'audit_alerts' => array(
500 // Shared anti-repeat cooldown (minutes). After an alert, do not
501 // send another about the same thing (same event type for
502 // immediate, same category for threshold) until this passes.
503 // Prevents a flood during a sustained attack.
504 'cooldown_minutes' => 60,
505 // #38 Immediate alerts: selected event types email right away.
506 'immediate' => array(
507 'enabled' => false,
508 // Alert on any logged event at or above this severity. A new
509 // admin, a closed plugin or a privilege escalation are all
510 // logged as "critical", so "critical" already covers them.
511 'min_severity' => 'critical', // 'critical' | 'warning'
512 ),
513 // #10 Threshold alerts: N events of a category within a window.
514 'threshold' => array(
515 'enabled' => false,
516 'window' => '1h', // 30m | 1h | 6h | 24h
517 // Per-category trigger counts (warning/critical events only);
518 // 0 disables that category. Covers every event type that can
519 // log a warning or critical. Keep in sync with
520 // Vigilante_Audit_Alerts::category_labels().
521 'categories' => array(
522 'firewall' => 50,
523 'login' => 20,
524 'user' => 5,
525 'plugin' => 0,
526 'file' => 0,
527 'security' => 0,
528 'system' => 0,
529 'settings' => 0,
530 'theme' => 0,
531 'content' => 0,
532 'comment' => 0,
533 'media' => 0,
534 ),
535 ),
536 ),
537 );
538 }
539
540 /**
541 * Get all options (merged with defaults)
542 *
543 * @return array All options
544 */
545 public function get_all_options() {
546 if ( null === $this->options ) {
547 $saved = get_option( self::OPTION_NAME, array() );
548 $this->options = $this->array_merge_deep( $this->get_default_options(), $saved );
549 }
550 return $this->options;
551 }
552
553 /**
554 * Deep merge arrays
555 *
556 * @param array $defaults Default values.
557 * @param array $saved Saved values.
558 * @return array Merged array.
559 */
560 private function array_merge_deep( $defaults, $saved ) {
561 $result = $defaults;
562
563 foreach ( $saved as $key => $value ) {
564 if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
565 $result[ $key ] = $this->array_merge_deep( $result[ $key ], $value );
566 } else {
567 $result[ $key ] = $value;
568 }
569 }
570
571 return $result;
572 }
573
574 /**
575 * Get a specific section
576 *
577 * @param string $section Section name.
578 * @return array Section options.
579 */
580 public function get_section( $section ) {
581 $options = $this->get_all_options();
582 return isset( $options[ $section ] ) ? $options[ $section ] : array();
583 }
584
585 /**
586 * Get a specific option
587 *
588 * @param string $section Section name.
589 * @param string $key Option key.
590 * @param mixed $default Default value.
591 * @return mixed Option value.
592 */
593 public function get_option( $section, $key, $default = null ) {
594 $options = $this->get_all_options();
595
596 if ( isset( $options[ $section ][ $key ] ) ) {
597 return $options[ $section ][ $key ];
598 }
599
600 return $default;
601 }
602
603 /**
604 * Check if a module is enabled
605 *
606 * @param string $module Module name.
607 * @return bool Whether module is enabled.
608 */
609 public function is_module_enabled( $module ) {
610 $options = $this->get_all_options();
611 return ! empty( $options['modules'][ $module ] );
612 }
613
614 /**
615 * Save options
616 *
617 * @param array $options Options to save.
618 * @return bool Success status.
619 */
620 public function save_options( $options ) {
621 $this->options = null;
622 return update_option( self::OPTION_NAME, $options );
623 }
624
625 /**
626 * Update a section
627 *
628 * @param string $section Section name.
629 * @param array $data Section data.
630 * @return bool Success status.
631 */
632 public function update_section( $section, $data ) {
633 $options = get_option( self::OPTION_NAME, array() );
634 $options[ $section ] = $data;
635 $this->options = null;
636 return update_option( self::OPTION_NAME, $options );
637 }
638
639 /**
640 * Update multiple sections at once
641 *
642 * @param array $sections Associative array of section => data.
643 * @return bool Success status.
644 */
645 public function update_options( $sections ) {
646 $options = get_option( self::OPTION_NAME, array() );
647
648 foreach ( $sections as $section => $data ) {
649 $options[ $section ] = $data;
650 }
651
652 $this->options = null;
653 return update_option( self::OPTION_NAME, $options );
654 }
655
656 /**
657 * Clear the options cache
658 */
659 public function clear_cache() {
660 $this->options = null;
661 wp_cache_delete( self::OPTION_NAME, 'options' );
662 }
663
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 return ! is_multisite() || is_main_site();
705 }
706
707 public static function can_write_shared_files() {
708 if ( ! is_multisite() ) {
709 return true;
710 }
711
712 if ( ! is_main_site() ) {
713 return false;
714 }
715
716 // WP-CLI with nobody logged in: there is no user to ask, and the site is
717 // the right one, so a network admin running `wp plugin activate --network`
718 // gets the files written. With a user set (wp --user=...) the capability
719 // is checked like anywhere else, so the gate cannot be side-stepped by
720 // running as a subsite administrator.
721 if ( defined( 'WP_CLI' ) && WP_CLI && ! get_current_user_id() ) {
722 return true;
723 }
724
725 return current_user_can( 'manage_network_options' );
726 }
727
728 /**
729 * The one message shown wherever a shared-file setting is out of reach
730 *
731 * Deliberately a single string reused by every section, instead of one per
732 * section: it says the same thing everywhere and there is no reason to make
733 * translators write it four times.
734 *
735 * @since 2.9.8
736 *
737 * @return string
738 */
739 public static function get_shared_files_notice() {
740 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' );
741 }
742
743 /**
744 * Apply the tweaks a brand new installation gets on top of the raw defaults
745 *
746 * A few keys are deliberately missing from get_default_options() because
747 * declaring them would break something else. XML-RPC is the one case today:
748 * declaring wp_hardening.xmlrpc_mode would make the defaults merge fill it in
749 * always and hide the fallback that reads the old pair of settings on sites
750 * that have not re-saved the tab. With nothing stored the resolver answers
751 * 'full', which blocks XML-RPC completely, and that is not what we want a new
752 * site to get.
753 *
754 * Everything that seeds a clean configuration has to run this: the activation
755 * hook, the per-section reset and the global reset to defaults. Otherwise the
756 * defaults you get by pressing a button are not the defaults you get by
757 * installing the plugin, which is exactly what happened until 2.9.8.
758 *
759 * @since 2.9.8
760 *
761 * @param array $options Options array to adjust.
762 * @return array
763 */
764 public static function apply_install_tweaks( $options ) {
765 if ( ! isset( $options['wp_hardening'] ) || ! is_array( $options['wp_hardening'] ) ) {
766 $options['wp_hardening'] = array();
767 }
768
769 $options['wp_hardening']['xmlrpc_mode'] = 'pingback';
770
771 return $options;
772 }
773
774 /**
775 * Keys that hold what the site owner typed in, never wiped by a restore
776 *
777 * Putting the security posture back to its defaults is one thing; deleting
778 * an IP whitelist, the secret login address, the two factor setup or the
779 * addresses that receive the alerts is another, and nobody presses a button
780 * called "restore defaults" expecting that. Both the Standard preset and the
781 * two reset buttons leave these alone.
782 *
783 * @since 2.9.8
784 *
785 * @return array<string,string[]>
786 */
787 public static function get_user_data_keys() {
788 return array(
789 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header' ),
790 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
791 'user_security' => array( 'insecure_usernames' ),
792 'file_integrity' => array( 'excluded_paths', 'excluded_extensions' ),
793 'email' => array( 'additional_recipients' ),
794 );
795 }
796
797 /**
798 * Settings whose only effect is written to a file the network shares
799 *
800 * true for a whole section, or the list of keys inside it. Used to keep a
801 * subsite from resetting settings it does not control: the file is written
802 * from the main site, so resetting the local copy would only make the two
803 * disagree.
804 *
805 * Note this is not every setting that reaches .htaccess. Blocking bad bots
806 * or empty user agents also runs in PHP, per site, so those stay editable on
807 * a subsite: the PHP half protects that site and the .htaccess half is
808 * refused, leaving the main site's rules standing. On the main site they
809 * are locked too, see get_main_site_file_settings().
810 *
811 * The PHP blocks for plugins and themes have no field on the settings
812 * screen, but an imported file carries them, and readme.html and
813 * license.txt are removed from the root the whole network shares.
814 *
815 * @since 2.9.8
816 *
817 * @return array<string,true|string[]>
818 */
819 public static function get_shared_file_settings() {
820 return array(
821 'security_headers' => true,
822 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
823 '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' ),
824 'advanced' => array( 'remove_readme', 'remove_license' ),
825 );
826 }
827
828 /**
829 * Settings the shared files are built from that also act on the site storing them
830 *
831 * get_shared_file_settings() lists what does nothing but end up in a shared
832 * file. These do both: blocking bad bots and bad query strings, the visitor
833 * IP detection and the two whitelists run in PHP for the site that stores
834 * them, and on the main site of a network they are also what the .htaccess
835 * rules of every site are generated from; the three module switches decide
836 * whether the .htaccess blocks and the wp-config.php constants exist at all.
837 * On a subsite they only act on that site, so they stay editable there.
838 *
839 * Until 2.11.6 an administrator of the main site without network rights
840 * could change any of them, and the file-only ones too: the write to the
841 * file was refused at that moment, but the value stayed stored, and the
842 * refresh after the next update, or the next save by a network
843 * administrator, published it to the whole network.
844 *
845 * @since 2.11.6
846 *
847 * @return array<string,string[]>
848 */
849 public static function get_main_site_file_settings() {
850 return array(
851 'modules' => array( 'firewall', 'security_headers', 'wp_hardening' ),
852 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'ip_whitelist', 'ua_whitelist' ),
853 );
854 }
855
856 /**
857 * Shared file settings the current user may not change on this site
858 *
859 * Empty when the user can write the shared files. Otherwise the file-only
860 * settings on every site, plus, on the main site, the ones it also builds
861 * the shared files from.
862 *
863 * @since 2.11.6
864 *
865 * @return array<string,true|string[]>
866 */
867 public static function get_locked_file_settings() {
868 if ( self::can_write_shared_files() ) {
869 return array();
870 }
871
872 $locked = self::get_shared_file_settings();
873
874 if ( self::owns_shared_files() ) {
875 foreach ( self::get_main_site_file_settings() as $section => $keys ) {
876 if ( ! isset( $locked[ $section ] ) ) {
877 $locked[ $section ] = $keys;
878 } elseif ( is_array( $locked[ $section ] ) ) {
879 $locked[ $section ] = array_values( array_unique( array_merge( $locked[ $section ], $keys ) ) );
880 }
881 }
882 }
883
884 return $locked;
885 }
886
887 /**
888 * Put back the stored value of every shared file setting the user may not change
889 *
890 * For every writer of the whole configuration: saving a tab, importing a
891 * file, applying a preset, restoring the defaults. Hiding a field on the
892 * screen decides nothing, because the request can carry the key anyway. A
893 * key that was not stored is dropped, so its default keeps applying.
894 *
895 * @since 2.11.6
896 *
897 * @param array $options Configuration about to be stored.
898 * @param array $stored Configuration stored now, as read from the option.
899 * @return array
900 */
901 public static function keep_locked_file_settings( $options, $stored ) {
902 $options = is_array( $options ) ? $options : array();
903 $stored = is_array( $stored ) ? $stored : array();
904
905 foreach ( self::get_locked_file_settings() as $section => $keys ) {
906 if ( true === $keys ) {
907 if ( array_key_exists( $section, $stored ) ) {
908 $options[ $section ] = $stored[ $section ];
909 } else {
910 unset( $options[ $section ] );
911 }
912 continue;
913 }
914
915 $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
916
917 foreach ( $keys as $key ) {
918 if ( array_key_exists( $key, $stored_section ) ) {
919 if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
920 $options[ $section ] = array();
921 }
922 $options[ $section ][ $key ] = $stored_section[ $key ];
923 } elseif ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
924 unset( $options[ $section ][ $key ] );
925 }
926 }
927 }
928
929 return $options;
930 }
931
932 /**
933 * Put a configuration back to the defaults without deleting what the owner typed
934 *
935 * @since 2.9.8
936 *
937 * @param array $current Configuration being replaced.
938 * @return array
939 */
940 public static function get_defaults_preserving_user_data( $current ) {
941 $instance = new self();
942 $defaults = self::apply_install_tweaks( $instance->get_default_options() );
943
944 foreach ( self::get_user_data_keys() as $section => $keys ) {
945 foreach ( $keys as $key ) {
946 if ( isset( $current[ $section ] ) && array_key_exists( $key, (array) $current[ $section ] ) ) {
947 $defaults[ $section ][ $key ] = $current[ $section ][ $key ];
948 }
949 }
950 }
951
952 return $defaults;
953 }
954
955 /**
956 * The values the Standard preset applies
957 *
958 * Standard is the configuration a new installation gets, with every module
959 * on. It is built from the defaults rather than written out by hand, because
960 * a hand-written copy drifts: until 2.9.8 Standard named a dozen fields and
961 * left everything else alone, so applying it after Maximum kept Maximum's
962 * password rules, its administrator alerts, its session limits and its
963 * password expiry, and the preset that says it applies sensible defaults
964 * applied almost none of them.
965 *
966 * The only thing it does not touch is what the site owner typed in: IP and
967 * user agent lists, the custom login address, two factor configuration, the
968 * integrity scan exclusions and the extra notification recipients. Putting
969 * the security posture back to the defaults is one thing, throwing away
970 * someone's whitelist is another, and "Reset to Defaults" is right there for
971 * that.
972 *
973 * @since 2.9.8
974 *
975 * @return array
976 */
977 private function get_standard_preset_values() {
978 $values = self::apply_install_tweaks( $this->get_default_options() );
979
980 foreach ( array_keys( $values['modules'] ) as $module ) {
981 $values['modules'][ $module ] = true;
982 }
983
984 foreach ( self::get_user_data_keys() as $section => $keys ) {
985 foreach ( $keys as $key ) {
986 unset( $values[ $section ][ $key ] );
987 }
988 }
989
990 unset( $values['user_security']['password_expiration']['excluded_users'] );
991
992 return $values;
993 }
994
995 /**
996 * Merge a preset over a configuration
997 *
998 * Not array_replace_recursive(), which is wrong for this in two ways. A list
999 * of roles in the preset is merged position by position instead of replacing
1000 * the stored one, so applying Standard over Maximum turned the two roles
1001 * Standard expires passwords for into Maximum's five with the first two
1002 * overwritten. And an empty list in the preset clears nothing at all,
1003 * because there is no element to replace with.
1004 *
1005 * So: associative arrays are merged key by key, and lists and scalars are
1006 * replaced outright.
1007 *
1008 * @since 2.9.8
1009 *
1010 * @param array $base Current configuration.
1011 * @param array $overlay Preset values.
1012 * @return array
1013 */
1014 public static function merge_preset( $base, $overlay ) {
1015 foreach ( $overlay as $key => $value ) {
1016 if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && ! self::is_list( $value ) ) {
1017 $base[ $key ] = self::merge_preset( $base[ $key ], $value );
1018 continue;
1019 }
1020
1021 $base[ $key ] = $value;
1022 }
1023
1024 return $base;
1025 }
1026
1027 /**
1028 * Whether an array is a plain list (0..n-1 keys)
1029 *
1030 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1031 *
1032 * @since 2.9.8
1033 *
1034 * @param array $value Array to inspect.
1035 * @return bool
1036 */
1037 private static function is_list( $value ) {
1038 if ( array() === $value ) {
1039 return true;
1040 }
1041
1042 return array_keys( $value ) === range( 0, count( $value ) - 1 );
1043 }
1044
1045 /**
1046 * Get presets with descriptions
1047 *
1048 * @return array Presets configuration.
1049 */
1050 public function get_presets() {
1051 return array(
1052 'standard' => array_merge(
1053 array(
1054 'name' => __( 'Standard', 'vigilante' ),
1055 'description' => __( 'Balanced security suitable for most websites. Enables every module and puts every setting back to the value a new installation gets.', 'vigilante' ),
1056 ),
1057 $this->get_standard_preset_values()
1058 ),
1059
1060 'maximum' => array(
1061 'name' => __( 'Maximum Security', 'vigilante' ),
1062 'description' => __( 'Strictest settings for high-security sites. CSP is set to report-only mode to prevent breaking the admin interface.', 'vigilante' ),
1063 'modules' => array(
1064 'firewall' => true,
1065 'security_headers' => true,
1066 'login_security' => true,
1067 'rest_api_security'=> true,
1068 'user_security' => true,
1069 'wp_hardening' => true,
1070 'file_integrity' => true,
1071 'activity_log' => true,
1072 ),
1073 'firewall' => array(
1074 'block_bad_query_strings' => true,
1075 'block_sql_injection' => true,
1076 'block_xss_attacks' => true,
1077 'block_file_inclusion' => true,
1078 'block_directory_traversal' => true,
1079 'block_bad_bots' => true,
1080 'block_empty_user_agent' => true,
1081 'rate_limiting' => array(
1082 'enabled' => true,
1083 'requests_per_minute' => 60,
1084 'block_duration' => 600,
1085 'progressive' => true,
1086 'max_block_duration' => 86400,
1087 ),
1088 ),
1089 'security_headers' => array(
1090 'x_frame_options' => 'DENY',
1091 // HSTS is intentionally NOT enabled by Maximum: forcing HSTS on a site
1092 // that doesn't have a healthy HTTPS setup (or temporarily falls back to
1093 // HTTP) locks visitors out for the full max_age. Leaving HSTS off keeps
1094 // it as an explicit opt-in decision per site.
1095 'csp' => array(
1096 'enabled' => true,
1097 'report_only' => false,
1098 'directives' => array(
1099 'default-src' => "'self'",
1100 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval'",
1101 'style-src' => "'self' 'unsafe-inline'",
1102 'img-src' => "'self' data: https: blob:",
1103 'font-src' => "'self' data:",
1104 'connect-src' => "'self' https: blob:",
1105 'frame-src' => "'self' blob:",
1106 'frame-ancestors' => "'none'",
1107 'worker-src' => "'self' blob:",
1108 'object-src' => "'none'",
1109 'base-uri' => "'self'",
1110 ),
1111 ),
1112 ),
1113 'rest_api_security' => array(
1114 'mode' => 'authenticated_only',
1115 ),
1116 'login_security' => array(
1117 'max_attempts' => 3,
1118 'lockout_duration' => 3600,
1119 'lockout_increment' => true,
1120 'notify_on_lockout' => true,
1121 'notify_on_admin_login' => true,
1122 ),
1123 'wp_hardening' => array(
1124 'xmlrpc_mode' => 'full',
1125 'disallow_file_edit' => true,
1126 'disallow_file_mods' => true,
1127 // close_old_comments is intentionally NOT touched by Maximum:
1128 // it would unilaterally close discussion on every old post,
1129 // which is a content decision, not a security one.
1130 ),
1131 'user_security' => array(
1132 'prevent_display_name_login_match' => true,
1133 'min_password_length' => 16,
1134 'password_policy' => array(
1135 'require_uppercase' => true,
1136 'require_lowercase' => true,
1137 'require_number' => true,
1138 'require_special' => true,
1139 'block_common' => true,
1140 'block_username' => true,
1141 'affected_roles' => array(),
1142 ),
1143 'admin_monitoring' => array(
1144 'alert_new_admin' => true,
1145 'alert_admin_email_change' => true,
1146 'alert_permission_elevation' => true,
1147 'alert_admin_password_change' => true,
1148 ),
1149 'registration_approval' => array(
1150 'enabled' => true,
1151 'notify_admin' => true,
1152 'auto_reject_days' => 7,
1153 'affected_roles' => array( 'subscriber', 'contributor', 'author', 'editor' ),
1154 ),
1155 'session_limits' => array(
1156 'enabled' => true,
1157 'max_sessions' => 1,
1158 'behavior' => 'close_oldest',
1159 'exclude_admins' => false,
1160 ),
1161 'password_expiration' => array(
1162 'enabled' => true,
1163 'expire_days' => 30,
1164 'warning_days' => 7,
1165 'affected_roles' => array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ),
1166 'password_history' => 5,
1167 'send_reminder' => true,
1168 ),
1169 'email_verification' => array(
1170 'enabled' => true,
1171 'token_expiry_hours' => 24,
1172 'allow_resend' => true,
1173 'auto_delete_days' => 3,
1174 ),
1175 ),
1176 'file_integrity' => array(
1177 'scan_core' => true,
1178 'scan_plugins' => true,
1179 'scan_themes' => true,
1180 'scan_uploads' => true,
1181 'scan_critical_config' => true,
1182 'auto_scan' => true,
1183 'scan_frequency' => 'daily',
1184 'notify_level' => 'all',
1185 'instant_alert' => true,
1186 ),
1187 // A configuration called Maximum Security that never tells you
1188 // anything happened is half a product, so the audit alerts ship
1189 // on with it. The shared cooldown keeps a sustained attack from
1190 // turning into a flood. Under Attack mode builds on this preset,
1191 // so it inherits them for as long as it is on and gives them back
1192 // when it is switched off.
1193 'audit_alerts' => array(
1194 'immediate' => array(
1195 'enabled' => true,
1196 'min_severity' => 'critical',
1197 ),
1198 'threshold' => array(
1199 'enabled' => true,
1200 ),
1201 ),
1202 'activity_log' => array(
1203 'log_logins' => true,
1204 'log_failed_logins' => true,
1205 'log_user_changes' => true,
1206 'log_post_changes' => true,
1207 'log_plugin_changes' => true,
1208 'log_theme_changes' => true,
1209 'log_option_changes' => true,
1210 'log_file_changes' => true,
1211 'log_comments' => true,
1212 'log_media' => true,
1213 ),
1214 ),
1215 );
1216 }
1217
1218 /**
1219 * Get module labels for display
1220 *
1221 * @return array Module labels.
1222 */
1223 public function get_module_labels() {
1224 return array(
1225 'firewall' => __( 'Firewall', 'vigilante' ),
1226 'security_headers' => __( 'Security Headers', 'vigilante' ),
1227 'login_security' => __( 'Login Security', 'vigilante' ),
1228 'rest_api_security'=> __( 'REST API Security', 'vigilante' ),
1229 'user_security' => __( 'User Security', 'vigilante' ),
1230 'wp_hardening' => __( 'WordPress Hardening', 'vigilante' ),
1231 'file_integrity' => __( 'File Integrity', 'vigilante' ),
1232 'activity_log' => __( 'Security Audit', 'vigilante' ),
1233 );
1234 }
1235
1236 /**
1237 * Get module descriptions for display
1238 *
1239 * @return array Module descriptions.
1240 */
1241 public function get_module_descriptions() {
1242 return array(
1243 'firewall' => __( 'Blocks malicious requests, SQL injection, XSS attacks, and bad bots. Includes rate limiting and file protection.', 'vigilante' ),
1244 'security_headers' => __( 'Adds HTTP security headers like CSP, HSTS, X-Frame-Options. Forces HTTPS and fixes mixed content.', 'vigilante' ),
1245 'login_security' => __( 'Brute force protection, 2FA, login attempt limits, XML-RPC control, and notifications.', 'vigilante' ),
1246 'rest_api_security'=> __( 'Controls REST API access, blocks user enumeration, and protects sensitive endpoints.', 'vigilante' ),
1247 'user_security' => __( 'Blocks insecure usernames, enforces strong passwords, and prevents author scanning.', 'vigilante' ),
1248 'wp_hardening' => __( 'Hardens wp-config.php, manages comments, cleans header output, and controls feeds.', 'vigilante' ),
1249 'file_integrity' => __( 'Scans WordPress core, plugins, and themes for unauthorized changes and suspicious code.', 'vigilante' ),
1250 'activity_log' => __( 'Records user actions, logins, content changes, and security events for security auditing.', 'vigilante' ),
1251 );
1252 }
1253
1254 /**
1255 * Validate options before saving
1256 *
1257 * @param array $input Raw input to validate.
1258 * @return array Validated options.
1259 */
1260 public function validate_options( $input ) {
1261 $validated = array();
1262 $defaults = $this->get_default_options();
1263
1264 // Validate each section that exists in input
1265 foreach ( $input as $section => $data ) {
1266 if ( ! is_array( $data ) ) {
1267 continue;
1268 }
1269
1270 if ( 'modules' === $section ) {
1271 // Validate modules (booleans)
1272 foreach ( $defaults['modules'] as $module => $default_value ) {
1273 $validated['modules'][ $module ] = isset( $data[ $module ] )
1274 ? (bool) $data[ $module ]
1275 : false;
1276 }
1277 } elseif ( isset( $defaults[ $section ] ) ) {
1278 // Validate other sections using generic validator
1279 $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ] );
1280
1281 // The few keys that live outside get_default_options() on
1282 // purpose (see apply_install_tweaks()) survive with their own
1283 // validation, or an import would silently lose them and the
1284 // XML-RPC resolver would fall back to blocking everything.
1285 foreach ( self::undeclared_keys( $section ) as $key => $type ) {
1286 if ( ! array_key_exists( $key, $data ) ) {
1287 continue;
1288 }
1289 if ( 'bool' === $type ) {
1290 $validated[ $section ][ $key ] = (bool) $data[ $key ];
1291 } elseif ( is_array( $type ) && in_array( $data[ $key ], $type, true ) ) {
1292 $validated[ $section ][ $key ] = $data[ $key ];
1293 }
1294 }
1295 }
1296 }
1297
1298 return apply_filters( 'vigilante_validate_options', $validated, $input );
1299 }
1300
1301 /**
1302 * Keys deliberately absent from get_default_options(), with how to validate them
1303 *
1304 * Declaring them as defaults would break the fallback they exist for (see
1305 * apply_install_tweaks()), but the validator still has to know them, or a
1306 * settings import drops them (found in the 2.11.0 cross review).
1307 *
1308 * @since 2.11.0
1309 *
1310 * @param string $section Section name.
1311 * @return array key => 'bool' or list of allowed values.
1312 */
1313 private static function undeclared_keys( $section ) {
1314 $keys = array(
1315 'wp_hardening' => array( 'xmlrpc_mode' => array( 'full', 'pingback', 'none' ) ),
1316 'login_security' => array(
1317 'disable_xmlrpc' => 'bool',
1318 'disable_xmlrpc_pingback' => 'bool',
1319 ),
1320 );
1321
1322 return isset( $keys[ $section ] ) ? $keys[ $section ] : array();
1323 }
1324
1325 /**
1326 * Whether a default value describes a free list rather than a schema
1327 *
1328 * An empty array or sequential numeric keys (an IP whitelist, a list of
1329 * roles) is a list: every entry the user typed is kept. Anything else is a
1330 * schema: only its keys survive validation.
1331 *
1332 * @since 2.11.0
1333 *
1334 * @param array $defaults Default value of a setting.
1335 * @return bool
1336 */
1337 private function is_list_default( $defaults ) {
1338 if ( array() === $defaults ) {
1339 return true;
1340 }
1341
1342 return array_keys( $defaults ) === range( 0, count( $defaults ) - 1 );
1343 }
1344
1345 /**
1346 * Validate a section based on defaults
1347 *
1348 * Since 2.11.0 the result only holds keys the defaults know. The loop that
1349 * used to reincorporate unknown keys "sanitized" meant a settings import
1350 * could merge any key it liked into vigilante_options (S7 of the 28 Aug
1351 * 2026 audit). Lists are the exception, handled first: their entries are
1352 * data, not keys.
1353 *
1354 * @param array $input Input values.
1355 * @param array $defaults Default values.
1356 * @return array Validated values.
1357 */
1358 private function validate_section( $input, $defaults ) {
1359 $validated = array();
1360
1361 if ( $this->is_list_default( $defaults ) ) {
1362 if ( ! is_array( $input ) ) {
1363 return array();
1364 }
1365
1366 $list = array();
1367 foreach ( $input as $value ) {
1368 if ( is_scalar( $value ) ) {
1369 $list[] = sanitize_text_field( (string) $value );
1370 } elseif ( is_array( $value ) ) {
1371 $list[] = map_deep( $value, 'sanitize_text_field' );
1372 }
1373 }
1374
1375 return $list;
1376 }
1377
1378 foreach ( $defaults as $key => $default_value ) {
1379 if ( ! isset( $input[ $key ] ) ) {
1380 $validated[ $key ] = $default_value;
1381 continue;
1382 }
1383
1384 $value = $input[ $key ];
1385
1386 if ( is_bool( $default_value ) ) {
1387 $validated[ $key ] = (bool) $value;
1388 } elseif ( is_int( $default_value ) ) {
1389 $validated[ $key ] = intval( $value );
1390 } elseif ( is_array( $default_value ) ) {
1391 if ( is_array( $value ) ) {
1392 $validated[ $key ] = $this->validate_section( $value, $default_value );
1393 } else {
1394 $validated[ $key ] = $default_value;
1395 }
1396 } else {
1397 $validated[ $key ] = sanitize_text_field( $value );
1398 }
1399 }
1400
1401 // Keys the defaults do not declare are dropped on purpose (S7).
1402
1403 return $validated;
1404 }
1405 }