PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
vigilante / includes / class-settings.php

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

2,053 lines 90.3 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 // 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
103 // User-Agent management
104 'ua_whitelist' => array(),
105 'ua_blacklist' => array(),
106
107 // File protection (htaccess-based)
108 'disable_directory_browsing' => true,
109 'protect_wp_config' => true,
110 'protect_wp_includes' => true,
111 'protect_uploads_php' => true,
112 'protect_sensitive_files' => true,
113 // Off by default — only safe when host has a real server-side cron job
114 // calling wp-cron.php; otherwise scheduled tasks stop running silently.
115 'protect_wp_cron' => false,
116 'block_php_in_plugins' => false,
117 'block_php_in_themes' => false,
118 'limit_http_methods' => true,
119 // All methods needed for WordPress core, Gutenberg, REST API, and page builders
120 'allowed_http_methods' => array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' ),
121 ),
122
123 // Security Headers settings (includes HTTPS enforcer)
124 'security_headers' => array(
125 'enabled' => true,
126
127 // Basic headers
128 'x_frame_options' => 'SAMEORIGIN',
129 'x_content_type_options' => true,
130 'referrer_policy' => 'strict-origin-when-cross-origin',
131
132 // HSTS
133 'hsts' => array(
134 'enabled' => false,
135 'max_age' => 31536000,
136 'include_subdomains' => false,
137 'preload' => false,
138 ),
139
140 // Permissions Policy
141 'permissions_policy' => array(
142 'enabled' => true,
143 'geolocation' => '()',
144 'microphone' => '()',
145 'camera' => '()',
146 'payment' => '(self)',
147 'usb' => '()',
148 ),
149
150 // CSP - WordPress/Gutenberg compatible defaults
151 // Note: blob: is required in frame-src and worker-src for the block editor,
152 // and in connect-src for the client-side media processing WordPress 7.1
153 // introduced: @wordpress/vips puts its WebAssembly binary in a blob: URL and
154 // fetches it, and fetch() is governed by connect-src, where 'self' does not
155 // cover blob:. Without it the editor cannot process images before upload.
156 'csp' => array(
157 'enabled' => true,
158 'report_only' => false,
159 'report_uri' => '',
160 'directives' => array(
161 'default-src' => "'self'",
162 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval' https:",
163 'style-src' => "'self' 'unsafe-inline' https:",
164 'img-src' => "'self' data: https: blob:",
165 'font-src' => "'self' data: https:",
166 'connect-src' => "'self' https: wss: blob:",
167 'media-src' => "'self' https: blob:",
168 'frame-src' => "'self' https: blob:",
169 'frame-ancestors' => "'self'",
170 'base-uri' => "'self'",
171 'form-action' => "'self' https:",
172 'object-src' => "'none'",
173 'worker-src' => "'self' blob:",
174 'upgrade-insecure-requests'=> true,
175 ),
176 ),
177
178 // Cross-origin policies
179 'cross_origin_policies' => array(
180 'embedder_policy' => 'unsafe-none',
181 'opener_policy' => 'same-origin-allow-popups',
182 'resource_policy' => 'cross-origin',
183 ),
184
185 // HTTPS Enforcer (moved from separate module)
186 //
187 // force_https rewrites siteurl/home to https on activation, so it
188 // ships off: a site without working HTTPS would end up pointing at
189 // an address that does not answer. It is an opt-in decision per
190 // site, made from the Security Headers tab. Sites whose URLs a
191 // previous version already rewrote keep them; nothing reverts them.
192 'force_https' => false,
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,
218
219 // Server Protection (moved from firewall in v2.0.0)
220 'hide_server_signature' => true,
221 'remove_fingerprinting_headers' => true,
222 ),
223
224 // Login Security settings
225 'login_security' => array(
226 'enabled' => true,
227 'max_attempts' => 5,
228 'lockout_duration' => 1800,
229 'lockout_increment' => true,
230 'max_lockout_duration' => 86400,
231 'hide_login_errors' => true,
232 // XML-RPC se movio a wp_hardening en la 2.9.7; lo resuelve
233 // Vigilante_Comment_Security::resolve_xmlrpc_mode(), que
234 // sustituye a las dos casillas anteriores (disable_xmlrpc y
235 // disable_xmlrpc_pingback), que podian estar activas a la vez y
236 // contradecirse. A proposito NO se declara aqui ningun default: si se
237 // declarara, el merge con los defaults lo rellenaria siempre y taparia el
238 // respaldo que lee el ajuste antiguo de los sitios que aun no han vuelto a
239 // guardar la pestana. Sin nada guardado, el resolutor devuelve 'full', que
240 // es lo que hacia el default anterior.
241 'disable_application_passwords' => false,
242 'notify_on_lockout' => false,
243 'notify_on_admin_login' => false,
244 'ip_whitelist' => array(),
245 'custom_login_url' => '',
246 'notify_on_login_url_change' => true,
247 // Two-Factor Authentication
248 'two_factor' => array(
249 'enabled' => false,
250 'method' => 'email',
251 'enforced_roles' => array( 'administrator', 'editor' ),
252 'excluded_users' => array(),
253 'remember_device_days' => 30,
254 'allow_remember_device' => false,
255 'code_expiry_minutes' => 10,
256 'max_attempts' => 3,
257 'email_from_name' => '',
258 'notify_on_enable' => true,
259 'grace_period_days' => 3,
260 ),
261 ),
262
263 // REST API Security settings
264 'rest_api_security' => array(
265 'enabled' => true,
266 'mode' => 'selective',
267 'block_user_enumeration' => true,
268 'disable_jsonp' => true,
269 // Empty by default: /wp/v2/users used to live here, but that
270 // duplicated the dedicated "Block user enumeration" toggle.
271 // Now there is one knob = one behaviour. If you want to
272 // protect additional endpoints in selective mode, add them
273 // explicitly via this setting (or via a filter).
274 'protected_endpoints' => array(),
275 'allowed_public_endpoints' => array(
276 '/wp/v2/posts',
277 '/wp/v2/pages',
278 '/wp/v2/categories',
279 '/wp/v2/tags',
280 '/oembed/',
281 ),
282 'plugin_compatibility' => array(
283 'woocommerce' => true,
284 'contact_form_7' => true,
285 'elementor' => true,
286 ),
287 ),
288
289 // User Security settings
290 'user_security' => array(
291 'enabled' => true,
292 'block_insecure_usernames'=> true,
293 'insecure_usernames' => array(
294 'admin', 'administrator', 'user', 'test', 'guest',
295 'info', 'root', 'adm', 'sysadmin', 'support',
296 'webmaster', 'master', 'owner', 'manager', 'demo',
297 ),
298 'block_author_scanning' => true,
299 'force_strong_passwords' => true,
300 'min_password_length' => 12,
301
302 // Granular password policy. Applies only while
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.
312 'password_policy' => array(
313 'require_uppercase' => false,
314 'require_lowercase' => false,
315 'require_number' => false,
316 'require_special' => false,
317 'block_common' => true,
318 'block_username' => true,
319 'affected_roles' => array(),
320 ),
321
322 'prevent_display_name_login_match' => true,
323
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.
329 'admin_monitoring' => array(
330 'alert_new_admin' => true,
331 'alert_admin_email_change' => false,
332 'alert_permission_elevation' => true,
333 'alert_admin_password_change' => false,
334 ),
335
336 // Force password reset (no options, uses native WordPress flow)
337
338 // Registration approval
339 'registration_approval' => array(
340 'enabled' => false,
341 'notify_admin' => false,
342 'auto_reject_days' => 0,
343 'affected_roles' => array( 'subscriber' ),
344 ),
345
346 // Session management
347 'session_management' => array(
348 'enabled' => true,
349 'show_in_profile' => true,
350 ),
351
352 // Session limits
353 'session_limits' => array(
354 'enabled' => true,
355 'max_sessions' => 3,
356 'behavior' => 'close_oldest',
357 'exclude_admins' => false,
358 ),
359
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.
367 'password_expiration' => array(
368 'enabled' => false,
369 'expire_days' => 90,
370 'warning_days' => 14,
371 'affected_roles' => array( 'administrator', 'editor' ),
372 'excluded_users' => array(),
373 'password_history' => 3,
374 'send_reminder' => false,
375 ),
376
377 // Email verification
378 'email_verification' => array(
379 'enabled' => false,
380 'token_expiry_hours' => 24,
381 'allow_resend' => true,
382 'auto_delete_days' => 7,
383 ),
384 ),
385
386 // WordPress Hardening (combines wp-config, comments, feeds, head cleaner)
387 'wp_hardening' => array(
388 'enabled' => true,
389
390 // wp-config security
391 'disallow_file_edit' => true,
392 'disallow_file_mods' => false,
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,
400 'wp_debug' => true,
401 // Off by default — only safe when host has a real server-side cron job;
402 // pairs with firewall.protect_wp_cron to block both internal triggering
403 // (this constant) and external HTTP abuse (the .htaccess rule).
404 'disable_wp_cron' => false,
405
406 // Comment security
407 'disable_pingbacks' => true,
408 'disable_trackbacks' => true,
409 'require_comment_moderation' => true,
410 'close_old_comments' => false,
411 'close_comments_after_days' => 30,
412 'honeypot_comments' => true,
413
414 // Head cleaner
415 'remove_wp_generator' => true,
416 'remove_wp_version_assets' => false,
417 'remove_rsd_link' => true,
418 'remove_wlw_manifest' => true,
419 'remove_shortlink' => true,
420 'remove_rest_api_link' => false,
421
422 // Feed manager
423 'disable_feeds' => false,
424 'disable_if_no_content' => true,
425 'remove_feed_version' => true,
426 ),
427
428 // File Integrity settings
429 'file_integrity' => array(
430 'enabled' => true,
431 'scan_core' => true,
432 'scan_plugins' => true,
433 'scan_themes' => true,
434 'scan_uploads' => true,
435 'scan_critical_config' => true,
436 'check_closed_plugins' => true,
437 'auto_scan' => true,
438 'scan_frequency' => 'daily',
439 'notify_level' => 'suspicious_only',
440 'instant_alert' => false,
441 // Empty for a new installation. It used to start with
442 // wp-content/cache, from when nothing in the scan looked in
443 // that folder anyway; the scan now searches it for hidden
444 // code. A site that has the entry saved keeps it: what is in
445 // this list is the site's own, and an update does not edit it.
446 'excluded_paths' => array(),
447 'excluded_extensions' => array(
448 // Translations (regenerated per-locale, never in checksums).
449 '.po', '.mo', '.pot',
450 // Binary images (cosmetic, not executable; often rewritten by image-optimizer plugins).
451 '.jpg', '.jpeg', '.png', '.gif', '.ico', '.webp', '.avif',
452 // Stylesheets: frequently rewritten by themes and optimizer
453 // plugins, a common source of post-update false positives.
454 // Strict-mode users can remove it (CSS injection is still a
455 // vector, defended primarily by CSP in the headers module).
456 '.css',
457 ),
458 ),
459
460 // Activity Log settings
461 'activity_log' => array(
462 'retention_days' => 30,
463 'max_entries' => 10000,
464 'log_logins' => true,
465 'log_failed_logins' => true,
466 'log_user_changes' => true,
467 'log_post_changes' => true,
468 'log_plugin_changes' => true,
469 'log_theme_changes' => true,
470 'log_option_changes' => false,
471 'log_file_changes' => true,
472 'log_comments' => true,
473 'log_media' => true,
474 'excluded_users' => array(),
475 'excluded_ips' => array(),
476 'tracked_options' => array(),
477 ),
478
479 // Backup settings
480 'backup' => array(
481 'keep_backups' => 5,
482 ),
483
484 // Notification settings (centralized recipients for all admin emails)
485 'email' => array(
486 'send_to_admin_email' => true,
487 'additional_recipients' => array(),
488 'send_deactivation_email' => true,
489 ),
490
491 // Advanced settings
492 'advanced' => array(
493 'remove_readme' => true,
494 'remove_license' => true,
495 ),
496
497 // Security Analyzer (v2.1.0) — on-demand + weekly Security Check
498 'security_analyzer' => array(
499 'weekly_scan_enabled' => true,
500 'email_on_regression' => false,
501 ),
502
503 // Audit Alerts (v2.8.0) — alerting layer on top of Security Audit.
504 // The engine subscribes to logged events and only runs when the
505 // Security Audit (activity_log) module is enabled. Opt-in: both
506 // legs start OFF so it never duplicates the per-module emails that
507 // already exist (User Security admin monitoring, Plugin Status...).
508 'audit_alerts' => array(
509 // Shared anti-repeat cooldown (minutes). After an alert, do not
510 // send another about the same thing (same event type for
511 // immediate, same category for threshold) until this passes.
512 // Prevents a flood during a sustained attack.
513 'cooldown_minutes' => 60,
514 // #38 Immediate alerts: selected event types email right away.
515 'immediate' => array(
516 'enabled' => false,
517 // Alert on any logged event at or above this severity. A new
518 // admin, a closed plugin or a privilege escalation are all
519 // logged as "critical", so "critical" already covers them.
520 'min_severity' => 'critical', // 'critical' | 'warning'
521 ),
522 // #10 Threshold alerts: N events of a category within a window.
523 'threshold' => array(
524 'enabled' => false,
525 'window' => '1h', // 30m | 1h | 6h | 24h
526 // Per-category trigger counts (warning/critical events only);
527 // 0 disables that category. Covers every event type that can
528 // log a warning or critical. Keep in sync with
529 // Vigilante_Audit_Alerts::category_labels().
530 'categories' => array(
531 'firewall' => 50,
532 'login' => 20,
533 'user' => 5,
534 'plugin' => 0,
535 'file' => 0,
536 'security' => 0,
537 'system' => 0,
538 'settings' => 0,
539 'theme' => 0,
540 'content' => 0,
541 'comment' => 0,
542 'media' => 0,
543 ),
544 ),
545 ),
546 );
547 }
548
549 /**
550 * Get all options (merged with defaults)
551 *
552 * @return array All options
553 */
554 public function get_all_options() {
555 if ( null === $this->options ) {
556 $saved = get_option( self::OPTION_NAME, array() );
557 $this->options = $this->array_merge_deep( $this->get_default_options(), $saved );
558 }
559 return $this->options;
560 }
561
562 /**
563 * Deep merge arrays
564 *
565 * Associative arrays are merged key by key, because that is what lets a
566 * saved option keep working when a release adds a setting. Lists are
567 * replaced whole, and that part is not cosmetic: merging a list key by key
568 * means index by index, so a stored list shorter than the default came back
569 * with the default's tail glued to it. Saving "administrator" alone into
570 * enforced_roles (default administrator, editor) was read back as
571 * administrator, editor, and unchecking every role was read back as the two
572 * defaults, so three role pickers could not be narrowed at all. It is the
573 * same bug merge_preset() was given its own merge for in 2.9.8; the fix
574 * never reached this one or the one in the admin save.
575 *
576 * An empty saved list therefore means empty, not "fall back to the default".
577 * That is the honest reading of what was saved, and the save path is what
578 * makes sure a list only becomes empty when somebody emptied it: a list the
579 * form did not carry is left alone rather than blanked.
580 *
581 * Which of the two happens is decided by the DEFAULT, never by what was
582 * saved. Deciding by what was saved looks equivalent and is not: an empty
583 * array is a list by any definition, so a section stored as array() would
584 * have replaced a whole group of settings instead of merging into it, and
585 * every key of that group would have come back missing. Found by the third
586 * cross review of this release, measured on rest_api_security (7 keys to 0)
587 * and on registration_approval (4 to 0, including its enabled flag).
588 *
589 * @since 3.0.1 Lists replace instead of merging index by index.
590 *
591 * @param array $defaults Default values.
592 * @param array $saved Saved values.
593 * @return array Merged array.
594 */
595 private function array_merge_deep( $defaults, $saved ) {
596 $result = $defaults;
597
598 foreach ( $saved as $key => $value ) {
599 if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) && ! self::is_list( $result[ $key ] ) ) {
600 $result[ $key ] = $this->array_merge_deep( $result[ $key ], $value );
601 } else {
602 $result[ $key ] = $value;
603 }
604 }
605
606 return $result;
607 }
608
609 /**
610 * Get a specific section
611 *
612 * @param string $section Section name.
613 * @return array Section options.
614 */
615 public function get_section( $section ) {
616 $options = $this->get_all_options();
617 return isset( $options[ $section ] ) ? $options[ $section ] : array();
618 }
619
620 /**
621 * Get a specific option
622 *
623 * @param string $section Section name.
624 * @param string $key Option key.
625 * @param mixed $default Default value.
626 * @return mixed Option value.
627 */
628 public function get_option( $section, $key, $default = null ) {
629 $options = $this->get_all_options();
630
631 if ( isset( $options[ $section ][ $key ] ) ) {
632 return $options[ $section ][ $key ];
633 }
634
635 return $default;
636 }
637
638 /**
639 * Check if a module is enabled
640 *
641 * @param string $module Module name.
642 * @return bool Whether module is enabled.
643 */
644 public function is_module_enabled( $module ) {
645 $options = $this->get_all_options();
646 return ! empty( $options['modules'][ $module ] );
647 }
648
649 /**
650 * Save options
651 *
652 * @param array $options Options to save.
653 * @return bool Success status.
654 */
655 public function save_options( $options ) {
656 $this->options = null;
657 return update_option( self::OPTION_NAME, $options );
658 }
659
660 /**
661 * Update a section
662 *
663 * @param string $section Section name.
664 * @param array $data Section data.
665 * @return bool Success status.
666 */
667 public function update_section( $section, $data ) {
668 $options = get_option( self::OPTION_NAME, array() );
669 $options[ $section ] = $data;
670 $this->options = null;
671 return update_option( self::OPTION_NAME, $options );
672 }
673
674 /**
675 * Update multiple sections at once
676 *
677 * @param array $sections Associative array of section => data.
678 * @return bool Success status.
679 */
680 public function update_options( $sections ) {
681 $options = get_option( self::OPTION_NAME, array() );
682
683 foreach ( $sections as $section => $data ) {
684 $options[ $section ] = $data;
685 }
686
687 $this->options = null;
688 return update_option( self::OPTION_NAME, $options );
689 }
690
691 /**
692 * Clear the options cache
693 */
694 public function clear_cache() {
695 $this->options = null;
696 wp_cache_delete( self::OPTION_NAME, 'options' );
697 }
698
699 /**
700 * Whether this context is allowed to write the files a network shares
701 *
702 * wp-config.php and the root .htaccess are single files for the whole
703 * network, while Vigilant's settings are per site. Without a gate, every
704 * save, activation and deactivation from any site rewrites those files from
705 * that site's own options, so the last one to save wins and silently undoes
706 * the rest. Measured on a real network: the main site enables "disable file
707 * editing", a subsite admin presses Save on their own screen without
708 * touching it, and the constant disappears from wp-config.php while the main
709 * site's screen keeps showing the box ticked.
710 *
711 * So on a network only the main site decides, and only a network
712 * administrator. WP-CLI on the main site counts too: there is no user to ask
713 * there, but the site is the right one, and a network admin running
714 * `wp plugin activate --network` expects the files to be written.
715 *
716 * On a single site this is always true and nothing changes.
717 *
718 * @since 2.9.8
719 *
720 * @return bool
721 */
722 /**
723 * Whether this site is the one that owns the files a network shares.
724 *
725 * Pure site identity, with no capability in it, and that is the point. A
726 * refresh that Vigilant performs by itself, such as rewriting its own
727 * .htaccess block after an update, decides nothing: the content comes from
728 * this site's own options whoever happens to be visiting. What must not
729 * happen is a *different* site writing the shared file, and that is exactly
730 * what this answers.
731 *
732 * can_write_shared_files() below adds the capability on top, and is the
733 * right question for anything a person initiates from a settings screen.
734 *
735 * @since 2.10.1
736 * @return bool
737 */
738 public static function owns_shared_files() {
739 // One wp-config.php and one root .htaccess per installation, even with
740 // several networks in it: is_main_site() alone is true on the main site
741 // of every network. Since 2.11.8, found by the audit of the network.
742 return ! is_multisite() || ( is_main_site() && is_main_network() );
743 }
744
745 public static function can_write_shared_files() {
746 if ( ! is_multisite() ) {
747 return true;
748 }
749
750 // See owns_shared_files(): the main site of a secondary network, and
751 // its network administrator, do not own the installation's files.
752 if ( ! is_main_site() || ! is_main_network() ) {
753 return false;
754 }
755
756 // WP-CLI with nobody logged in: there is no user to ask, and the site is
757 // the right one, so a network admin running `wp plugin activate --network`
758 // gets the files written. With a user set (wp --user=...) the capability
759 // is checked like anywhere else, so the gate cannot be side-stepped by
760 // running as a subsite administrator.
761 if ( defined( 'WP_CLI' ) && WP_CLI && ! get_current_user_id() ) {
762 return true;
763 }
764
765 return current_user_can( 'manage_network_options' );
766 }
767
768 /**
769 * The one message shown wherever a shared-file setting is out of reach
770 *
771 * Deliberately a single string reused by every section, instead of one per
772 * section: it says the same thing everywhere and there is no reason to make
773 * translators write it four times.
774 *
775 * @since 2.9.8
776 *
777 * @return string
778 */
779 public static function get_shared_files_notice() {
780 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' );
781 }
782
783 /**
784 * Apply the tweaks a brand new installation gets on top of the raw defaults
785 *
786 * A few keys are deliberately missing from get_default_options() because
787 * declaring them would break something else. XML-RPC is the one case today:
788 * declaring wp_hardening.xmlrpc_mode would make the defaults merge fill it in
789 * always and hide the fallback that reads the old pair of settings on sites
790 * that have not re-saved the tab. With nothing stored the resolver answers
791 * 'full', which blocks XML-RPC completely, and that is not what we want a new
792 * site to get.
793 *
794 * Everything that seeds a clean configuration has to run this: the activation
795 * hook, the per-section reset and the global reset to defaults. Otherwise the
796 * defaults you get by pressing a button are not the defaults you get by
797 * installing the plugin, which is exactly what happened until 2.9.8.
798 *
799 * @since 2.9.8
800 *
801 * @param array $options Options array to adjust.
802 * @return array
803 */
804 public static function apply_install_tweaks( $options ) {
805 if ( ! isset( $options['wp_hardening'] ) || ! is_array( $options['wp_hardening'] ) ) {
806 $options['wp_hardening'] = array();
807 }
808
809 $options['wp_hardening']['xmlrpc_mode'] = 'pingback';
810
811 return $options;
812 }
813
814 /**
815 * Keys that hold what the site owner typed in, never wiped by a restore
816 *
817 * Putting the security posture back to its defaults is one thing; deleting
818 * an IP whitelist, the secret login address, the two factor setup or the
819 * addresses that receive the alerts is another, and nobody presses a button
820 * called "restore defaults" expecting that. Both the Standard preset and the
821 * two reset buttons leave these alone.
822 *
823 * Read from outside the plugin: the third-party network plugin Vigilante
824 * Network Sync calls this from its 2.0.3 to decide what NOT to copy between
825 * the sites of a network, so a key it does not know about is preserved per
826 * site instead of overwritten. Adding keys here is safe and helps it;
827 * renaming or removing the method, or changing the shape of what it returns,
828 * silently changes what that plugin replicates across a whole network.
829 *
830 * @since 2.9.8
831 *
832 * @return array<string,string[]>
833 */
834 public static function get_user_data_keys() {
835 return array(
836 'firewall' => array( 'ip_whitelist', 'ip_blacklist', 'ua_whitelist', 'ua_blacklist', 'trusted_proxy_header', 'trusted_proxies' ),
837 'login_security' => array( 'ip_whitelist', 'custom_login_url', 'two_factor' ),
838 'user_security' => array( 'insecure_usernames' ),
839 'file_integrity' => array( 'excluded_paths', 'excluded_extensions' ),
840 'email' => array( 'additional_recipients' ),
841 );
842 }
843
844 /**
845 * Settings whose only effect is written to a file the network shares
846 *
847 * true for a whole section, or the list of keys inside it. Used to keep a
848 * subsite from resetting settings it does not control: the file is written
849 * from the main site, so resetting the local copy would only make the two
850 * disagree.
851 *
852 * Note this is not every setting that reaches .htaccess. Blocking bad bots
853 * or empty user agents also runs in PHP, per site, so those stay editable on
854 * a subsite: the PHP half protects that site and the .htaccess half is
855 * refused, leaving the main site's rules standing. On the main site they
856 * are locked too, see get_main_site_file_settings().
857 *
858 * The PHP blocks for plugins and themes have no field on the settings
859 * screen, but an imported file carries them, and readme.html and
860 * license.txt are removed from the root the whole network shares.
861 *
862 * @since 2.9.8
863 *
864 * @return array<string,true|string[]>
865 */
866 public static function get_shared_file_settings() {
867 return array(
868 'security_headers' => true,
869 'wp_hardening' => array( 'disallow_file_edit', 'disallow_file_mods', 'force_ssl_admin', 'force_ssl_login', 'wp_debug', 'disable_wp_cron' ),
870 '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' ),
871 'advanced' => array( 'remove_readme', 'remove_license' ),
872 );
873 }
874
875 /**
876 * Settings the shared files are built from that also act on the site storing them
877 *
878 * get_shared_file_settings() lists what does nothing but end up in a shared
879 * file. These do both: blocking bad bots and bad query strings, the visitor
880 * IP detection and the two whitelists run in PHP for the site that stores
881 * them, and on the main site of a network they are also what the .htaccess
882 * rules of every site are generated from; the three writing module switches
883 * (firewall, security_headers, wp_hardening) decide whether the .htaccess
884 * blocks and the wp-config.php constants exist at all.
885 *
886 * Since 2.11.8 it also locks what decides whether the shared files are
887 * WATCHED, not built: the File Integrity module and its scan_critical_config
888 * switch. On the main site the critical-file scan is the network's canary
889 * for a change to wp-config.php or the root .htaccess, which only a network
890 * administrator can approve, so a main-site administrator without network
891 * rights must not be able to silence it by turning either one off. Closing
892 * the ignore list and the clear-results button in 2.11.8 left these two as
893 * the remaining routes; found by the audit of the admin surface.
894 *
895 * Since 3.0.0 the self-check has no setting at all, so there is nothing to lock: Vigilant's own
896 * files are shared by every site, and on the main site the self-check is
897 * the one that reports a change to them for the whole network.
898 *
899 * On a subsite all of them only act on that site, so they stay editable
900 * there (get_locked_file_settings() adds this set only when owns_shared_files()).
901 *
902 * Until 2.11.6 an administrator of the main site without network rights
903 * could change any of them, and the file-only ones too: the write to the
904 * file was refused at that moment, but the value stayed stored, and the
905 * refresh after the next update, or the next save by a network
906 * administrator, published it to the whole network.
907 *
908 * @since 2.11.6
909 * @since 2.11.8 The file_integrity module and scan_critical_config.
910 *
911 * @return array<string,string[]>
912 */
913 /**
914 * The two factor policy that governs this installation
915 *
916 * On a network the answer must not depend on which site the login happens to
917 * arrive at, because the cookie WordPress issues does not: COOKIEHASH comes
918 * from the network siteurl (wp-includes/default-constants.php) and
919 * COOKIE_DOMAIN covers every host of the network
920 * (wp-includes/ms-default-constants.php). Until 2.11.10 the settings, the
921 * enforced roles and the TOTP table were all read per site, so somebody
922 * holding the password of an administrator protected by 2FA on the main site
923 * posted the login to a subsite where that account has no role, was never
924 * asked for a code, and came out with a session valid across the network.
925 * Measured on the Multisite install on 12 sep 2026 (user_requires_2fa true on
926 * the main site, false on demo2 for the same account) and found by the
927 * file-by-file review of 2.11.10.
928 *
929 * So the policy of the main site governs the whole network. On a single site
930 * this is the site's own configuration and nothing changes.
931 *
932 * @since 2.11.10
933 *
934 * @return array The two_factor section that applies.
935 */
936 public static function two_factor_policy() {
937 $options = is_multisite()
938 ? get_blog_option( get_main_site_id(), self::OPTION_NAME, array() )
939 : get_option( self::OPTION_NAME, array() );
940
941 if ( ! is_array( $options ) ) {
942 return array();
943 }
944
945 $login = isset( $options['login_security'] ) && is_array( $options['login_security'] )
946 ? $options['login_security']
947 : array();
948
949 return ( isset( $login['two_factor'] ) && is_array( $login['two_factor'] ) ) ? $login['two_factor'] : array();
950 }
951
952 /**
953 * Whether two factor is required for this account, anywhere in the network
954 *
955 * Union, and deliberately so. Reading the policy only from the main site,
956 * which is what this release did at first, would have switched two factor off
957 * for every network that has it configured per subsite, which until now was
958 * the only way it could be configured at all: a silent downgrade of the very
959 * protection being fixed. Found by the cross review of 2.11.10. So the
960 * question is asked of every site the account belongs to, plus the main site,
961 * each with its own enforced roles and exclusions, and one yes is enough.
962 *
963 * That also closes the bypass: the settings, the roles and the enrolment used
964 * to be read from whichever site the login arrived at, while the cookie
965 * WordPress issues is valid across the whole network (COOKIEHASH comes from
966 * the network siteurl and COOKIE_DOMAIN covers every host of it), so an
967 * account protected on one site could log in through another and come out
968 * with a session valid everywhere.
969 *
970 * @since 2.11.10
971 *
972 * @param WP_User $user User being authenticated.
973 * @return bool
974 */
975 public static function two_factor_required_for( $user ) {
976 return array() !== self::two_factor_demands_for( $user );
977 }
978
979 /**
980 * Which second factor methods this account is asked for, across the network
981 *
982 * Empty when nothing asks. On a network there can be more than one, because
983 * each site keeps its own settings and the requirement is the union of them.
984 *
985 * @since 2.11.10
986 *
987 * @param WP_User $user User being authenticated.
988 * @return string[] Methods asked for, without repeats.
989 */
990 public static function two_factor_methods_for( $user ) {
991 $methods = array();
992
993 foreach ( self::two_factor_demands_for( $user ) as $settings ) {
994 $method = isset( $settings['method'] ) ? (string) $settings['method'] : 'email';
995
996 // A method this version does not know is read as the default rather
997 // than left to fall through. Registering nothing at all is how the
998 // second factor of a whole network went quiet in silence: a saved
999 // value of '' (which validate_section() lets through on an import,
1000 // since only the tab has an allowlist) matched neither class, so no
1001 // filter was registered anywhere while every screen still said two
1002 // factor was on. Found by the third cross review of 2.11.10.
1003 $methods[] = in_array( $method, array( 'email', 'totp' ), true ) ? $method : 'email';
1004 }
1005
1006 return array_values( array_unique( $methods ) );
1007 }
1008
1009 /**
1010 * Which of the two second factor classes handles this login
1011 *
1012 * The method cannot be read from one site's settings, and reading it from the
1013 * main site was the hole the third cross review of 2.11.10 found: with the
1014 * main site on totp and a subsite asking for a code by email, the class that
1015 * registered was TOTP, the account had no enrolment, and the "not set up yet"
1016 * branch let the login through. In 2.11.9 that same login was asked for its
1017 * emailed code. So the question is asked per account, not per site.
1018 *
1019 * The order is what keeps it closed at both ends:
1020 *
1021 * 1. An enrolment already made wins while some site asking for a second
1022 * factor asks for an authenticator app. It is the strongest factor the
1023 * account has and it is ready to use, wherever in the network it was set
1024 * up.
1025 * 2. Otherwise, if any site asking for a second factor asks for email, email
1026 * handles it. Email needs no enrolment, so it can never fall into the
1027 * branch that lets a login through for lack of one.
1028 * 3. Only when every site asking wants an authenticator app does TOTP handle
1029 * it, which is the case the grace period was written for.
1030 *
1031 * The condition on the first step came in 2.11.11. Without it an enrolment
1032 * left from a time when the site asked for an app outranked the method the
1033 * site asks for now: a single site set to email asked those accounts for an
1034 * authenticator code, which 2.11.9 never did and which locks out whoever
1035 * removed the app after the switch. Dropping that enrolment opens nothing,
1036 * because the account then goes to email, which needs no enrolment.
1037 *
1038 * @since 2.11.10
1039 * @since 2.11.11 An enrolment only wins while an authenticator app is asked for.
1040 *
1041 * @param WP_User $user User being authenticated.
1042 * @param bool $enrolled Whether the account has a TOTP enrolment anywhere.
1043 * @return string 'email', 'totp', or '' when nothing asks.
1044 */
1045 public static function two_factor_handler_for( $user, $enrolled ) {
1046 $methods = self::two_factor_methods_for( $user );
1047
1048 if ( ! $methods ) {
1049 return '';
1050 }
1051
1052 if ( $enrolled && in_array( 'totp', $methods, true ) ) {
1053 return 'totp';
1054 }
1055
1056 return in_array( 'email', $methods, true ) ? 'email' : 'totp';
1057 }
1058
1059 /**
1060 * The two factor settings of every site that asks this account for one
1061 *
1062 * @since 2.11.10
1063 *
1064 * @param WP_User $user User being authenticated.
1065 * @return array[] The two_factor section of each site that asks.
1066 */
1067 private static function two_factor_demands_for( $user ) {
1068 if ( empty( $user->ID ) ) {
1069 return array();
1070 }
1071
1072 if ( ! is_multisite() ) {
1073 $roles = ( isset( $user->roles ) && is_array( $user->roles ) ) ? $user->roles : array();
1074 $policy = self::two_factor_policy();
1075
1076 return self::two_factor_site_requires( $policy, $user, $roles ) ? array( $policy ) : array();
1077 }
1078
1079 $demands = array();
1080 $blog_ids = array( (int) get_main_site_id() );
1081
1082 /*
1083 * A super administrator is a member of almost no site (measured on the
1084 * Multisite install: of three sites, the network owner belongs to one),
1085 * yet can log in through any of them and the cookie is valid everywhere.
1086 * Asking only the sites they belong to left the account with the most
1087 * power in the network outside the policy, which is the bypass upside
1088 * down. So for them every site of the network is consulted. There are
1089 * few super administrators. It does not only run at login, though, which
1090 * this note claimed until 2.11.11: the dashboard hooks of the TOTP class
1091 * ask it on every admin screen of every site of a network, at least once
1092 * per hook.
1093 */
1094 if ( is_super_admin( $user->ID ) ) {
1095 $blog_ids = array_merge( $blog_ids, get_sites( array( 'fields' => 'ids', 'number' => 200 ) ) );
1096 }
1097
1098 foreach ( get_blogs_of_user( $user->ID ) as $blog ) {
1099 if ( ! empty( $blog->userblog_id ) ) {
1100 $blog_ids[] = (int) $blog->userblog_id;
1101 }
1102 }
1103
1104 foreach ( array_unique( $blog_ids ) as $blog_id ) {
1105 $options = get_blog_option( $blog_id, self::OPTION_NAME, array() );
1106
1107 if ( ! is_array( $options ) || empty( $options['login_security']['two_factor'] ) ) {
1108 continue;
1109 }
1110
1111 /*
1112 * A site whose Login Security module is off asks for nothing, and
1113 * reading only the sub-setting made it ask anyway: a subsite that had
1114 * switched the whole module off, leaving an orphan two_factor.enabled
1115 * behind, imposed a second factor on every account of the network,
1116 * with no screen anywhere explaining why. It is the two-level toggle
1117 * trap of this plugin read upside down. Found by the third cross
1118 * review of 2.11.10.
1119 */
1120 if ( empty( $options['modules']['login_security'] ) ) {
1121 continue;
1122 }
1123
1124 $elsewhere = new WP_User( $user->ID );
1125 $elsewhere->for_site( $blog_id );
1126
1127 // A super administrator can hold no role row anywhere, and is judged
1128 // as an administrator so the strictest policy of the network reaches
1129 // the account with the most power in it.
1130 $roles = ( isset( $elsewhere->roles ) && is_array( $elsewhere->roles ) && $elsewhere->roles )
1131 ? $elsewhere->roles
1132 : ( is_super_admin( $user->ID ) ? array( 'administrator' ) : array() );
1133
1134 if ( self::two_factor_site_requires( $options['login_security']['two_factor'], $user, $roles ) ) {
1135 $demands[] = $options['login_security']['two_factor'];
1136 }
1137 }
1138
1139 return $demands;
1140 }
1141
1142 /**
1143 * Whether one site's two factor settings cover this account
1144 *
1145 * @since 2.11.10
1146 *
1147 * @param array $settings The two_factor section of one site.
1148 * @param WP_User $user User being authenticated.
1149 * @param string[] $roles Roles the account holds on that site.
1150 * @return bool
1151 */
1152 private static function two_factor_site_requires( $settings, $user, $roles ) {
1153 if ( ! is_array( $settings ) || empty( $settings['enabled'] ) ) {
1154 return false;
1155 }
1156
1157 $excluded = isset( $settings['excluded_users'] ) ? array_map( 'absint', (array) $settings['excluded_users'] ) : array();
1158
1159 if ( in_array( (int) $user->ID, $excluded, true ) ) {
1160 return false;
1161 }
1162
1163 $enforced = isset( $settings['enforced_roles'] ) ? (array) $settings['enforced_roles'] : array( 'administrator', 'editor' );
1164
1165 return (bool) array_intersect( (array) $roles, $enforced );
1166 }
1167
1168 /**
1169 * Settings that only a network administrator may change on the main site
1170 *
1171 * @return array<string,string[]>
1172 */
1173 public static function get_main_site_file_settings() {
1174 return array(
1175 'modules' => array( 'firewall', 'security_headers', 'wp_hardening', 'file_integrity' ),
1176 // trusted_proxies goes with trusted_proxy_header, and leaving it out
1177 // was a hole: the header decides which address the firewall of the
1178 // whole installation acts on, and this list decides which peers may
1179 // set that header. An administrator of the main site without network
1180 // rights who could edit only this half turned every visitor into a
1181 // trusted proxy. Found by the file-by-file review of 2.11.10.
1182 'firewall' => array( 'block_bad_bots', 'block_bad_query_strings', 'trusted_proxy_header', 'trusted_proxies', 'ip_whitelist', 'ua_whitelist' ),
1183 'file_integrity' => array( 'scan_critical_config' ),
1184 );
1185 }
1186
1187 /**
1188 * Shared file settings the current user may not change on this site
1189 *
1190 * Empty when the user can write the shared files. Otherwise the file-only
1191 * settings on every site, plus, on the main site, the ones it also builds
1192 * the shared files from.
1193 *
1194 * @since 2.11.6
1195 *
1196 * @return array<string,true|string[]>
1197 */
1198 public static function get_locked_file_settings() {
1199 if ( self::can_write_shared_files() ) {
1200 return array();
1201 }
1202
1203 $locked = self::get_shared_file_settings();
1204
1205 if ( self::owns_shared_files() ) {
1206 foreach ( self::get_main_site_file_settings() as $section => $keys ) {
1207 if ( ! isset( $locked[ $section ] ) ) {
1208 $locked[ $section ] = $keys;
1209 } elseif ( is_array( $locked[ $section ] ) ) {
1210 $locked[ $section ] = array_values( array_unique( array_merge( $locked[ $section ], $keys ) ) );
1211 }
1212 }
1213 }
1214
1215 return $locked;
1216 }
1217
1218 /**
1219 * Put back the stored value of every shared file setting the user may not change
1220 *
1221 * For every writer of the whole configuration: saving a tab, importing a
1222 * file, applying a preset, restoring the defaults. Hiding a field on the
1223 * screen decides nothing, because the request can carry the key anyway. A
1224 * key that was not stored is dropped, so its default keeps applying.
1225 *
1226 * @since 2.11.6
1227 *
1228 * @param array $options Configuration about to be stored.
1229 * @param array $stored Configuration stored now, as read from the option.
1230 * @return array
1231 */
1232 public static function keep_locked_file_settings( $options, $stored ) {
1233 $options = is_array( $options ) ? $options : array();
1234 $stored = is_array( $stored ) ? $stored : array();
1235 $locked = self::get_locked_file_settings();
1236
1237 if ( ! $locked ) {
1238 return $options;
1239 }
1240
1241 /*
1242 * A key that was never stored takes its default, which is what it was
1243 * worth before. Until 2.11.8 it was dropped instead, and the sanitize
1244 * callback of the option filled it in again, but validate_options()
1245 * fills a missing module switch with false, not with its default.
1246 */
1247 $instance = new self();
1248 $defaults = $instance->get_default_options();
1249
1250 foreach ( $locked as $section => $keys ) {
1251 if ( true === $keys ) {
1252 if ( array_key_exists( $section, $stored ) ) {
1253 $options[ $section ] = $stored[ $section ];
1254 } elseif ( isset( $defaults[ $section ] ) ) {
1255 $options[ $section ] = $defaults[ $section ];
1256 } else {
1257 unset( $options[ $section ] );
1258 }
1259 continue;
1260 }
1261
1262 $stored_section = ( isset( $stored[ $section ] ) && is_array( $stored[ $section ] ) ) ? $stored[ $section ] : array();
1263 $default_section = ( isset( $defaults[ $section ] ) && is_array( $defaults[ $section ] ) ) ? $defaults[ $section ] : array();
1264
1265 foreach ( $keys as $key ) {
1266 if ( array_key_exists( $key, $stored_section ) ) {
1267 $value = $stored_section[ $key ];
1268 } elseif ( array_key_exists( $key, $default_section ) ) {
1269 $value = $default_section[ $key ];
1270 } else {
1271 if ( isset( $options[ $section ] ) && is_array( $options[ $section ] ) ) {
1272 unset( $options[ $section ][ $key ] );
1273 }
1274 continue;
1275 }
1276
1277 if ( ! isset( $options[ $section ] ) || ! is_array( $options[ $section ] ) ) {
1278 $options[ $section ] = array();
1279 }
1280 $options[ $section ][ $key ] = $value;
1281 }
1282 }
1283
1284 return $options;
1285 }
1286
1287 /**
1288 * Take a lock kept as a row of the options table, or report that another request holds it
1289 *
1290 * add_option() cannot be a lock: it runs INSERT ... ON DUPLICATE KEY UPDATE
1291 * (wp-includes/option.php:1142 in WP 7.1), so two requests that both find
1292 * the option missing both "create" it and both believe they hold it. INSERT
1293 * IGNORE creates the row for exactly one of them, which is what core does in
1294 * WP_Upgrader::create_lock() (wp-admin/includes/class-wp-upgrader.php:1065).
1295 * A lock older than the timeout counts as abandoned, by a fatal error between
1296 * taking and releasing it, and only one request takes it over.
1297 *
1298 * @since 2.11.8
1299 *
1300 * @param string $name Option name of the lock, in the current site's table.
1301 * @param int $timeout Seconds after which a held lock counts as abandoned.
1302 * @return bool True if this request now holds the lock.
1303 */
1304 public static function acquire_option_lock( $name, $timeout ) {
1305 global $wpdb;
1306
1307 // 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().
1308 if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) ) ) {
1309 wp_cache_delete( $name, 'options' );
1310 return true;
1311 }
1312
1313 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock row as stored right now, not a cached copy.
1314 $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1315
1316 if ( null === $held || ( time() - (int) $held ) < $timeout ) {
1317 return false;
1318 }
1319
1320 // Abandoned: the delete only matches the value that was read, and only one
1321 // request wins the insert that follows.
1322 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes an abandoned lock row.
1323 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $held ) );
1324
1325 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- same atomic insert as above.
1326 return (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", $name, (string) time() ) );
1327 }
1328
1329 /**
1330 * Release a lock taken with acquire_option_lock()
1331 *
1332 * @since 2.11.8
1333 *
1334 * @param string $name Option name of the lock.
1335 */
1336 public static function release_option_lock( $name ) {
1337 global $wpdb;
1338
1339 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- removes the row acquire_option_lock() inserted.
1340 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
1341 wp_cache_delete( $name, 'options' );
1342 }
1343
1344 /**
1345 * Put a configuration back to the defaults without deleting what the owner typed
1346 *
1347 * @since 2.9.8
1348 *
1349 * @param array $current Configuration being replaced.
1350 * @return array
1351 */
1352 public static function get_defaults_preserving_user_data( $current ) {
1353 $instance = new self();
1354 $defaults = self::apply_install_tweaks( $instance->get_default_options() );
1355
1356 foreach ( self::get_user_data_keys() as $section => $keys ) {
1357 foreach ( $keys as $key ) {
1358 if ( isset( $current[ $section ] ) && array_key_exists( $key, (array) $current[ $section ] ) ) {
1359 $defaults[ $section ][ $key ] = $current[ $section ][ $key ];
1360 }
1361 }
1362 }
1363
1364 return $defaults;
1365 }
1366
1367 /**
1368 * The values the Standard preset applies
1369 *
1370 * Standard is the configuration a new installation gets, with every module
1371 * on. It is built from the defaults rather than written out by hand, because
1372 * a hand-written copy drifts: until 2.9.8 Standard named a dozen fields and
1373 * left everything else alone, so applying it after Maximum kept Maximum's
1374 * password rules, its administrator alerts, its session limits and its
1375 * password expiry, and the preset that says it applies sensible defaults
1376 * applied almost none of them.
1377 *
1378 * The only thing it does not touch is what the site owner typed in: IP and
1379 * user agent lists, the custom login address, two factor configuration, the
1380 * integrity scan exclusions and the extra notification recipients. Putting
1381 * the security posture back to the defaults is one thing, throwing away
1382 * someone's whitelist is another, and "Reset to Defaults" is right there for
1383 * that.
1384 *
1385 * @since 2.9.8
1386 *
1387 * @return array
1388 */
1389 private function get_standard_preset_values() {
1390 $values = self::apply_install_tweaks( $this->get_default_options() );
1391
1392 foreach ( array_keys( $values['modules'] ) as $module ) {
1393 $values['modules'][ $module ] = true;
1394 }
1395
1396 foreach ( self::get_user_data_keys() as $section => $keys ) {
1397 foreach ( $keys as $key ) {
1398 unset( $values[ $section ][ $key ] );
1399 }
1400 }
1401
1402 unset( $values['user_security']['password_expiration']['excluded_users'] );
1403
1404 return $values;
1405 }
1406
1407 /**
1408 * Merge a preset over a configuration
1409 *
1410 * Not array_replace_recursive(), which is wrong for this in two ways. A list
1411 * of roles in the preset is merged position by position instead of replacing
1412 * the stored one, so applying Standard over Maximum turned the two roles
1413 * Standard expires passwords for into Maximum's five with the first two
1414 * overwritten. And an empty list in the preset clears nothing at all,
1415 * because there is no element to replace with.
1416 *
1417 * So: associative arrays are merged key by key, and lists and scalars are
1418 * replaced outright.
1419 *
1420 * @since 2.9.8
1421 *
1422 * @param array $base Current configuration.
1423 * @param array $overlay Preset values.
1424 * @return array
1425 */
1426 public static function merge_preset( $base, $overlay ) {
1427 foreach ( $overlay as $key => $value ) {
1428 if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && ! self::is_list( $value ) ) {
1429 $base[ $key ] = self::merge_preset( $base[ $key ], $value );
1430 continue;
1431 }
1432
1433 $base[ $key ] = $value;
1434 }
1435
1436 return $base;
1437 }
1438
1439 /**
1440 * A stored list, or the shipped one when it arrives empty
1441 *
1442 * For the four lists that have NO field on any screen: the allowed HTTP
1443 * methods, the insecure usernames, the public REST endpoints and the roles
1444 * of registration approval. Nobody can empty those from the interface, so
1445 * an empty one is a leftover, and every version up to 3.0.0 produced them:
1446 * saving any tab wrote array() over every list of that section whose key the
1447 * form did not carry, which for these four was always. Reading the settings
1448 * hid it by merging the shipped values on top, and 3.0.1 stops doing that,
1449 * because a stored empty list is what makes unticking every role work.
1450 *
1451 * The migration in vigilante.php removes those keys once. This is the line
1452 * of defence for every other route, including Under Attack, which
1453 * photographs the raw option when it is switched on and puts it back whole
1454 * when it expires, after the migration has already marked itself done.
1455 *
1456 * It goes through every reader of those four lists, the ones that enforce
1457 * and the ones that only report, because a screen that says "no insecure
1458 * user" while the check is blocking them is its own kind of wrong. The
1459 * shipped value is read from get_default_options() rather than written out
1460 * again here: a second copy of a list drifts from the first.
1461 *
1462 * @since 3.0.1
1463 *
1464 * @param mixed $value Stored value, as the caller already has it.
1465 * @param string[] $path Path of the setting in the options array.
1466 * @return array
1467 */
1468 public static function list_or_shipped( $value, $path ) {
1469 if ( is_array( $value ) && array() !== $value ) {
1470 return $value;
1471 }
1472
1473 $instance = new self();
1474 $shipped = $instance->get_default_options();
1475
1476 foreach ( (array) $path as $key ) {
1477 if ( ! is_array( $shipped ) || ! array_key_exists( $key, $shipped ) ) {
1478 return array();
1479 }
1480 $shipped = $shipped[ $key ];
1481 }
1482
1483 return is_array( $shipped ) ? $shipped : array();
1484 }
1485
1486 /**
1487 * Whether an array is a plain list (0..n-1 keys)
1488 *
1489 * array_is_list() is PHP 8.1 and this plugin supports 7.4.
1490 *
1491 * @since 2.9.8
1492 *
1493 * Public since 3.0.1 because the admin save path needs the same answer: the
1494 * two places that used to merge lists index by index have to agree on what
1495 * a list is, or they disagree on a different subset of the settings.
1496 *
1497 * @param array $value Array to inspect.
1498 * @return bool
1499 */
1500 public static function is_list( $value ) {
1501 if ( array() === $value ) {
1502 return true;
1503 }
1504
1505 return array_keys( $value ) === range( 0, count( $value ) - 1 );
1506 }
1507
1508 /**
1509 * Get presets with descriptions
1510 *
1511 * @return array Presets configuration.
1512 */
1513 public function get_presets() {
1514 return array(
1515 'standard' => array_merge(
1516 array(
1517 'name' => __( 'Standard', 'vigilante' ),
1518 'description' => __( 'Balanced security suitable for most websites. Enables every module and puts every setting back to the value a new installation gets.', 'vigilante' ),
1519 ),
1520 $this->get_standard_preset_values()
1521 ),
1522
1523 'maximum' => array(
1524 'name' => __( 'Maximum Security', 'vigilante' ),
1525 'description' => __( 'Strictest settings for high-security sites. CSP is set to report-only mode to prevent breaking the admin interface.', 'vigilante' ),
1526 'modules' => array(
1527 'firewall' => true,
1528 'security_headers' => true,
1529 'login_security' => true,
1530 'rest_api_security'=> true,
1531 'user_security' => true,
1532 'wp_hardening' => true,
1533 'file_integrity' => true,
1534 'activity_log' => true,
1535 ),
1536 'firewall' => array(
1537 'block_bad_query_strings' => true,
1538 'block_sql_injection' => true,
1539 'block_xss_attacks' => true,
1540 'block_file_inclusion' => true,
1541 'block_directory_traversal' => true,
1542 'block_bad_bots' => true,
1543 'block_empty_user_agent' => true,
1544 'rate_limiting' => array(
1545 'enabled' => true,
1546 'requests_per_minute' => 60,
1547 'block_duration' => 600,
1548 'progressive' => true,
1549 'max_block_duration' => 86400,
1550 ),
1551 ),
1552 'security_headers' => array(
1553 'x_frame_options' => 'DENY',
1554 // HSTS is intentionally NOT enabled by Maximum: forcing HSTS on a site
1555 // that doesn't have a healthy HTTPS setup (or temporarily falls back to
1556 // HTTP) locks visitors out for the full max_age. Leaving HSTS off keeps
1557 // it as an explicit opt-in decision per site.
1558 'csp' => array(
1559 'enabled' => true,
1560 'report_only' => false,
1561 'directives' => array(
1562 'default-src' => "'self'",
1563 'script-src' => "'self' 'unsafe-inline' 'unsafe-eval'",
1564 'style-src' => "'self' 'unsafe-inline'",
1565 'img-src' => "'self' data: https: blob:",
1566 'font-src' => "'self' data:",
1567 'connect-src' => "'self' https: blob:",
1568 'frame-src' => "'self' blob:",
1569 'frame-ancestors' => "'none'",
1570 'worker-src' => "'self' blob:",
1571 'object-src' => "'none'",
1572 'base-uri' => "'self'",
1573 ),
1574 ),
1575 ),
1576 'rest_api_security' => array(
1577 'mode' => 'authenticated_only',
1578 ),
1579 'login_security' => array(
1580 'max_attempts' => 3,
1581 'lockout_duration' => 3600,
1582 'lockout_increment' => true,
1583 'notify_on_lockout' => true,
1584 'notify_on_admin_login' => true,
1585 ),
1586 'wp_hardening' => array(
1587 'xmlrpc_mode' => 'full',
1588 'disallow_file_edit' => true,
1589 'disallow_file_mods' => true,
1590 // close_old_comments is intentionally NOT touched by Maximum:
1591 // it would unilaterally close discussion on every old post,
1592 // which is a content decision, not a security one.
1593 ),
1594 'user_security' => array(
1595 'prevent_display_name_login_match' => true,
1596 'min_password_length' => 16,
1597 'password_policy' => array(
1598 'require_uppercase' => true,
1599 'require_lowercase' => true,
1600 'require_number' => true,
1601 'require_special' => true,
1602 'block_common' => true,
1603 'block_username' => true,
1604 'affected_roles' => array(),
1605 ),
1606 'admin_monitoring' => array(
1607 'alert_new_admin' => true,
1608 'alert_admin_email_change' => true,
1609 'alert_permission_elevation' => true,
1610 'alert_admin_password_change' => true,
1611 ),
1612 'registration_approval' => array(
1613 'enabled' => true,
1614 'notify_admin' => true,
1615 'auto_reject_days' => 7,
1616 'affected_roles' => array( 'subscriber', 'contributor', 'author', 'editor' ),
1617 ),
1618 'session_limits' => array(
1619 'enabled' => true,
1620 'max_sessions' => 1,
1621 'behavior' => 'close_oldest',
1622 'exclude_admins' => false,
1623 ),
1624 'password_expiration' => array(
1625 'enabled' => true,
1626 'expire_days' => 30,
1627 'warning_days' => 7,
1628 'affected_roles' => array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ),
1629 'password_history' => 5,
1630 'send_reminder' => true,
1631 ),
1632 'email_verification' => array(
1633 'enabled' => true,
1634 'token_expiry_hours' => 24,
1635 'allow_resend' => true,
1636 'auto_delete_days' => 3,
1637 ),
1638 ),
1639 'file_integrity' => array(
1640 'scan_core' => true,
1641 'scan_plugins' => true,
1642 'scan_themes' => true,
1643 'scan_uploads' => true,
1644 'scan_critical_config' => true,
1645 'auto_scan' => true,
1646 'scan_frequency' => 'daily',
1647 'notify_level' => 'all',
1648 'instant_alert' => true,
1649 ),
1650 // A configuration called Maximum Security that never tells you
1651 // anything happened is half a product, so the audit alerts ship
1652 // on with it. The shared cooldown keeps a sustained attack from
1653 // turning into a flood. Under Attack mode builds on this preset,
1654 // so it inherits them for as long as it is on and gives them back
1655 // when it is switched off.
1656 'audit_alerts' => array(
1657 'immediate' => array(
1658 'enabled' => true,
1659 'min_severity' => 'critical',
1660 ),
1661 'threshold' => array(
1662 'enabled' => true,
1663 ),
1664 ),
1665 'activity_log' => array(
1666 'log_logins' => true,
1667 'log_failed_logins' => true,
1668 'log_user_changes' => true,
1669 'log_post_changes' => true,
1670 'log_plugin_changes' => true,
1671 'log_theme_changes' => true,
1672 'log_option_changes' => true,
1673 'log_file_changes' => true,
1674 'log_comments' => true,
1675 'log_media' => true,
1676 ),
1677 ),
1678 );
1679 }
1680
1681 /**
1682 * Get module labels for display
1683 *
1684 * @return array Module labels.
1685 */
1686 public function get_module_labels() {
1687 return array(
1688 'firewall' => __( 'Firewall', 'vigilante' ),
1689 'security_headers' => __( 'Security Headers', 'vigilante' ),
1690 'login_security' => __( 'Login Security', 'vigilante' ),
1691 'rest_api_security'=> __( 'REST API Security', 'vigilante' ),
1692 'user_security' => __( 'User Security', 'vigilante' ),
1693 'wp_hardening' => __( 'WordPress Hardening', 'vigilante' ),
1694 'file_integrity' => __( 'File Integrity', 'vigilante' ),
1695 'activity_log' => __( 'Security Audit', 'vigilante' ),
1696 );
1697 }
1698
1699 /**
1700 * Get module descriptions for display
1701 *
1702 * @return array Module descriptions.
1703 */
1704 public function get_module_descriptions() {
1705 return array(
1706 'firewall' => __( 'Blocks malicious requests, SQL injection, XSS attacks, and bad bots. Includes rate limiting and file protection.', 'vigilante' ),
1707 'security_headers' => __( 'Adds HTTP security headers like CSP, HSTS, X-Frame-Options. Forces HTTPS and fixes mixed content.', 'vigilante' ),
1708 'login_security' => __( 'Brute force protection, 2FA, login attempt limits, XML-RPC control, and notifications.', 'vigilante' ),
1709 'rest_api_security'=> __( 'Controls REST API access, blocks user enumeration, and protects sensitive endpoints.', 'vigilante' ),
1710 'user_security' => __( 'Blocks insecure usernames, enforces strong passwords, and prevents author scanning.', 'vigilante' ),
1711 'wp_hardening' => __( 'Hardens wp-config.php, manages comments, cleans header output, and controls feeds.', 'vigilante' ),
1712 'file_integrity' => __( 'Scans WordPress core, plugins, and themes for unauthorized changes and suspicious code.', 'vigilante' ),
1713 'activity_log' => __( 'Records user actions, logins, content changes, and security events for security auditing.', 'vigilante' ),
1714 );
1715 }
1716
1717 /**
1718 * The settings that hold a list of file paths, by section
1719 *
1720 * A path is read by what it is. sanitize_text_field() deletes every %XX octet
1721 * and, when it finds one, collapses repeated spaces, so an exclusion written
1722 * for a folder called foto%20x was saved as fotox and excluded nothing: the
1723 * scan compares what is stored with the paths it finds, one by one (measured
1724 * on 1 oct 2026 against 3.0.1). Every other list in the options is a list of
1725 * addresses, names or extensions, where nothing meaningful is lost, and keeps
1726 * its reader.
1727 *
1728 * @since 3.0.2
1729 *
1730 * @return array<string,string[]>
1731 */
1732 public static function path_list_keys() {
1733 return array(
1734 'file_integrity' => array( 'excluded_paths' ),
1735 );
1736 }
1737
1738 /**
1739 * One path as it was typed: invalid UTF-8 out, the ends trimmed, and nothing
1740 * else touched. Invalid UTF-8 has to go because the option would not be
1741 * written with it.
1742 *
1743 * A line that has tags (or a stray <) is dropped whole, not cut. A path has
1744 * none, and wp_strip_all_tags() would cut it at the < and leave a shorter path
1745 * than the one written, which excludes more than anyone asked: a folder
1746 * called a<b would exclude a. Until 3.0.1 the < was kept as an entity, which
1747 * matched nothing, and dropping the line has the same effect.
1748 *
1749 * A line with a line break inside is dropped too. The textarea is read line by
1750 * line, so only an element of a list can have one (an export edited by hand),
1751 * and the next save of the tab would show it as two lines and store two paths:
1752 * a, a line break and wp-content would end up excluding all of wp-content.
1753 * 3.0.1 turned the break into a space, which matched nothing, and dropping the
1754 * line has the same effect (fourth cross review, 1 oct 2026).
1755 *
1756 * @since 3.0.2
1757 *
1758 * @param mixed $line Entry.
1759 * @return string
1760 */
1761 public static function sanitize_path_line( $line ) {
1762 if ( ! is_scalar( $line ) ) {
1763 return '';
1764 }
1765
1766 $line = trim( wp_check_invalid_utf8( (string) $line, true ) );
1767
1768 if ( false !== strpbrk( $line, "\r\n" ) ) {
1769 return '';
1770 }
1771
1772 return wp_strip_all_tags( $line ) === $line ? $line : '';
1773 }
1774
1775 /**
1776 * The text of a textarea of paths, one per line, each line read as one path.
1777 *
1778 * Line by line, because a stray < in one line would take the lines after it
1779 * with it if the whole text went through wp_strip_all_tags().
1780 *
1781 * @since 3.0.2
1782 *
1783 * @param mixed $text Text.
1784 * @return string
1785 */
1786 public static function sanitize_path_text( $text ) {
1787 $lines = preg_split( '/\r\n|\r|\n/', (string) $text );
1788
1789 return implode( "\n", array_map( array( __CLASS__, 'sanitize_path_line' ), (array) $lines ) );
1790 }
1791
1792 /**
1793 * Take the lists of paths out of a settings tree before the generic sanitizer reads it
1794 *
1795 * Known limits (fourth cross review, 1 oct 2026), none reachable from the
1796 * settings screen and all on the side of watching more: process_section_data()
1797 * reads the entries of a list posted as an array (excluded_paths[]=) with
1798 * sanitize_text_field() and applies sanitize_key() to the keys, so a key
1799 * written with another spelling is not found here, and a form posted as an
1800 * array instead of a string goes through a generic sanitizer. In those three
1801 * the %XX are lost, as they were in 3.0.1.
1802 *
1803 * @since 3.0.2
1804 *
1805 * @param mixed $data Settings tree: section => key => value.
1806 * @return array Only the path lists that are present, in the same shape, each already read as paths.
1807 */
1808 public static function take_path_lists( $data ) {
1809 $taken = array();
1810
1811 if ( ! is_array( $data ) ) {
1812 return $taken;
1813 }
1814
1815 foreach ( self::path_list_keys() as $section => $keys ) {
1816 if ( ! isset( $data[ $section ] ) || ! is_array( $data[ $section ] ) ) {
1817 continue;
1818 }
1819
1820 foreach ( $keys as $key ) {
1821 if ( ! isset( $data[ $section ][ $key ] ) ) {
1822 continue;
1823 }
1824
1825 $value = $data[ $section ][ $key ];
1826
1827 if ( is_array( $value ) ) {
1828 $taken[ $section ][ $key ] = array_map( array( __CLASS__, 'sanitize_path_line' ), array_values( array_filter( $value, 'is_scalar' ) ) );
1829 } elseif ( is_scalar( $value ) ) {
1830 $taken[ $section ][ $key ] = self::sanitize_path_text( $value );
1831 }
1832 }
1833 }
1834
1835 return $taken;
1836 }
1837
1838 /**
1839 * Put back the lists of paths take_path_lists() took out, over what the generic sanitizer left
1840 *
1841 * @since 3.0.2
1842 *
1843 * @param mixed $data Sanitized settings tree.
1844 * @param array $taken What take_path_lists() returned for the same tree.
1845 * @return mixed The tree, with the path lists as they were read.
1846 */
1847 public static function restore_path_lists( $data, $taken ) {
1848 if ( ! is_array( $data ) ) {
1849 return $data;
1850 }
1851
1852 foreach ( $taken as $section => $values ) {
1853 if ( ! isset( $data[ $section ] ) || ! is_array( $data[ $section ] ) ) {
1854 continue;
1855 }
1856
1857 foreach ( $values as $key => $value ) {
1858 $data[ $section ][ $key ] = $value;
1859 }
1860 }
1861
1862 return $data;
1863 }
1864
1865 /**
1866 * Whether a position in the options, as section.key, is a list of paths
1867 *
1868 * @since 3.0.2
1869 *
1870 * @param string $trail Section and key joined by a dot.
1871 * @return bool
1872 */
1873 private static function is_path_list( $trail ) {
1874 $parts = explode( '.', (string) $trail, 2 );
1875 $paths = self::path_list_keys();
1876
1877 return 2 === count( $parts ) && isset( $paths[ $parts[0] ] ) && in_array( $parts[1], $paths[ $parts[0] ], true );
1878 }
1879
1880 /**
1881 * Validate options before saving
1882 *
1883 * @param array $input Raw input to validate.
1884 * @return array Validated options.
1885 */
1886 public function validate_options( $input ) {
1887 $validated = array();
1888 $defaults = $this->get_default_options();
1889
1890 // Validate each section that exists in input
1891 foreach ( $input as $section => $data ) {
1892 if ( ! is_array( $data ) ) {
1893 continue;
1894 }
1895
1896 if ( 'modules' === $section ) {
1897 // Validate modules (booleans)
1898 foreach ( $defaults['modules'] as $module => $default_value ) {
1899 $validated['modules'][ $module ] = isset( $data[ $module ] )
1900 ? (bool) $data[ $module ]
1901 : false;
1902 }
1903 } elseif ( isset( $defaults[ $section ] ) ) {
1904 // Validate other sections using generic validator
1905 $validated[ $section ] = $this->validate_section( $data, $defaults[ $section ], $section );
1906
1907 // The few keys that live outside get_default_options() on
1908 // purpose (see apply_install_tweaks()) survive with their own
1909 // validation, or an import would silently lose them and the
1910 // XML-RPC resolver would fall back to blocking everything.
1911 foreach ( self::undeclared_keys( $section ) as $key => $type ) {
1912 if ( ! array_key_exists( $key, $data ) ) {
1913 continue;
1914 }
1915 if ( 'bool' === $type ) {
1916 $validated[ $section ][ $key ] = (bool) $data[ $key ];
1917 } elseif ( is_array( $type ) && in_array( $data[ $key ], $type, true ) ) {
1918 $validated[ $section ][ $key ] = $data[ $key ];
1919 }
1920 }
1921 }
1922 }
1923
1924 /*
1925 * The lock on the settings the shared files are built from is NOT applied
1926 * here, and that is a decision, not an oversight. The second cross review
1927 * of 2.11.10 raised that register_setting( 'vigilante_options', ... )
1928 * declares this as its sanitize callback with no lock in it, so anything
1929 * reaching options.php with that option group would write the whole
1930 * option. The chain does not close: the plugin prints no settings_fields()
1931 * for that group anywhere, so the nonce it would need is not obtainable,
1932 * and the five places that do save (saving a tab, importing, a preset,
1933 * restoring the defaults, resetting a section) all apply
1934 * keep_locked_file_settings() themselves.
1935 *
1936 * Putting it here instead would be worse than the door it closes. A
1937 * register_setting() callback runs on EVERY update_option() of this
1938 * option, so it would also lock the writes with no user behind them: the
1939 * expiry of Under Attack restoring what it hardened, WP-CLI and cron.
1940 * Those are already covered by matriz-red-ajustes-compartidos.sh, which
1941 * is where such a change would show up as a row that stopped passing.
1942 */
1943
1944 return apply_filters( 'vigilante_validate_options', $validated, $input );
1945 }
1946
1947 /**
1948 * Keys deliberately absent from get_default_options(), with how to validate them
1949 *
1950 * Declaring them as defaults would break the fallback they exist for (see
1951 * apply_install_tweaks()), but the validator still has to know them, or a
1952 * settings import drops them (found in the 2.11.0 cross review).
1953 *
1954 * @since 2.11.0
1955 *
1956 * @param string $section Section name.
1957 * @return array key => 'bool' or list of allowed values.
1958 */
1959 private static function undeclared_keys( $section ) {
1960 $keys = array(
1961 'wp_hardening' => array( 'xmlrpc_mode' => array( 'full', 'pingback', 'none' ) ),
1962 'login_security' => array(
1963 'disable_xmlrpc' => 'bool',
1964 'disable_xmlrpc_pingback' => 'bool',
1965 ),
1966 );
1967
1968 return isset( $keys[ $section ] ) ? $keys[ $section ] : array();
1969 }
1970
1971 /**
1972 * Whether a default value describes a free list rather than a schema
1973 *
1974 * An empty array or sequential numeric keys (an IP whitelist, a list of
1975 * roles) is a list: every entry the user typed is kept. Anything else is a
1976 * schema: only its keys survive validation.
1977 *
1978 * @since 2.11.0
1979 *
1980 * @param array $defaults Default value of a setting.
1981 * @return bool
1982 */
1983 private function is_list_default( $defaults ) {
1984 if ( array() === $defaults ) {
1985 return true;
1986 }
1987
1988 return array_keys( $defaults ) === range( 0, count( $defaults ) - 1 );
1989 }
1990
1991 /**
1992 * Validate a section based on defaults
1993 *
1994 * Since 2.11.0 the result only holds keys the defaults know. The loop that
1995 * used to reincorporate unknown keys "sanitized" meant a settings import
1996 * could merge any key it liked into vigilante_options (S7 of the 28 Aug
1997 * 2026 audit). Lists are the exception, handled first: their entries are
1998 * data, not keys.
1999 *
2000 * @param array $input Input values.
2001 * @param array $defaults Default values.
2002 * @param string $trail Where this is in the options, as section.key, so a list of paths is read as paths.
2003 * @return array Validated values.
2004 */
2005 private function validate_section( $input, $defaults, $trail = '' ) {
2006 $validated = array();
2007
2008 if ( $this->is_list_default( $defaults ) ) {
2009 if ( ! is_array( $input ) ) {
2010 return array();
2011 }
2012
2013 $is_paths = self::is_path_list( $trail );
2014 $list = array();
2015 foreach ( $input as $value ) {
2016 if ( is_scalar( $value ) ) {
2017 $list[] = $is_paths ? self::sanitize_path_line( $value ) : sanitize_text_field( (string) $value );
2018 } elseif ( is_array( $value ) ) {
2019 $list[] = map_deep( $value, 'sanitize_text_field' );
2020 }
2021 }
2022
2023 return $list;
2024 }
2025
2026 foreach ( $defaults as $key => $default_value ) {
2027 if ( ! isset( $input[ $key ] ) ) {
2028 $validated[ $key ] = $default_value;
2029 continue;
2030 }
2031
2032 $value = $input[ $key ];
2033
2034 if ( is_bool( $default_value ) ) {
2035 $validated[ $key ] = (bool) $value;
2036 } elseif ( is_int( $default_value ) ) {
2037 $validated[ $key ] = intval( $value );
2038 } elseif ( is_array( $default_value ) ) {
2039 if ( is_array( $value ) ) {
2040 $validated[ $key ] = $this->validate_section( $value, $default_value, '' === $trail ? (string) $key : $trail . '.' . $key );
2041 } else {
2042 $validated[ $key ] = $default_value;
2043 }
2044 } else {
2045 $validated[ $key ] = sanitize_text_field( $value );
2046 }
2047 }
2048
2049 // Keys the defaults do not declare are dropped on purpose (S7).
2050
2051 return $validated;
2052 }
2053 }