PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | admin/class-admin.php +840 -65 2.9.92.11.12 View file →
@@ -27,8 +27,9 @@
27 27
28 28 use Vigilante_Admin_Ajax;
29 29 use Vigilante_Admin_Analyzer_Ajax;
30 30 use Vigilante_Admin_Audit_Alerts_Ajax;
31 + use Vigilante_Admin_Recovery_Ajax;
31 32
32 33 /**
33 34 * Settings instance
34 35 *
@@ -182,8 +183,13 @@
182 183 add_action( 'wp_ajax_vigilante_analyzer_history', array( $this, 'ajax_analyzer_history' ) );
183 184 add_action( 'wp_ajax_vigilante_analyzer_dismiss_notice', array( $this, 'ajax_analyzer_dismiss_notice' ) );
184 185 add_action( 'wp_ajax_vigilante_analyzer_save_settings', array( $this, 'ajax_analyzer_save_settings' ) );
185 186
187 + // Security Headers settings recovery (2.10.0)
188 + add_action( 'wp_ajax_vigilante_headers_recovery_restore', array( $this, 'ajax_headers_recovery_restore' ) );
189 + add_action( 'wp_ajax_vigilante_headers_recovery_undo', array( $this, 'ajax_headers_recovery_undo' ) );
190 + add_action( 'wp_ajax_vigilante_headers_recovery_dismiss', array( $this, 'ajax_headers_recovery_dismiss' ) );
191 +
186 192 // Shared "Send test email" handler — Notification settings, File Integrity, Audit Alerts (v2.8.0)
187 193 add_action( 'wp_ajax_vigilante_send_test_email', array( $this, 'ajax_send_test_email' ) );
188 194
189 195 // Run migrations on admin load
@@ -193,8 +199,26 @@
193 199 /**
194 200 * Run database migrations based on stored version
195 201 */
196 202 public function run_migrations() {
203 + /*
204 + * admin-ajax.php fires admin_init before it decides who is asking
205 + * (wp-admin/admin-ajax.php:45), so until 2.11.10 an anonymous POST to
206 + * admin-ajax.php with any action ran every pending migration. That is
207 + * not a read: the migrations rewrite wp-config.php through
208 + * apply_security_constants(), rewrite the root .htaccess, move user meta
209 + * of the whole network and can rebuild the file integrity baseline,
210 + * taking whatever is on disk as approved. Reproduced on 12 sep 2026 with
211 + * curl and no cookies, and found by the file-by-file review of 2.11.10.
212 + *
213 + * Migrations are maintenance for whoever administers the site, so they
214 + * wait for an administrator to load a screen. Nothing is lost by
215 + * waiting: every migration is idempotent and version gated.
216 + */
217 + if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
218 + return;
219 + }
220 +
197 221 $db_version = get_option( 'vigilante_db_version', '0' );
198 222
199 223 // 1.2.3: Fix IP lists corrupted by sanitize_text_field stripping newlines
200 224 if ( version_compare( $db_version, '1.2.3', '<' ) ) {
@@ -267,9 +291,29 @@
267 291 if ( ! class_exists( 'Vigilante_File_Integrity' ) ) {
268 292 require_once VIGILANTE_INCLUDES_DIR . 'class-file-integrity.php';
269 293 }
270 294 $fi = new Vigilante_File_Integrity( $this->settings, $this->database, $this->activity_log );
271 - $fi->regenerate_all_baselines();
295 +
296 + /*
297 + * Only when there is nothing on record. This migration exists to
298 + * create the baseline that did not exist, never to discard the one
299 + * the owner approved: rebuilding it from the files takes whatever
300 + * is on disk right now as approved, so a wp-config.php modified and
301 + * awaiting review would be blessed in silence.
302 + *
303 + * And this is not theory. vigilante_db_version is written on two
304 + * different scales into the same option: this file counts in plugin
305 + * versions (2.11.0) and Vigilante_Database counts in schema
306 + * versions, currently 1.4.0 (class-database.php:322 and :380). For
307 + * version_compare, 1.4.0 is LOWER than 1.14.0, so any site whose
308 + * option was last written by the schema runs this migration again.
309 + * Measured on the Multisite install on 10 sep 2026: one of the three
310 + * sites was sitting on 1.4.0.
311 + */
312 + if ( ! $fi->get_critical_files_baseline() ) {
313 + $fi->regenerate_all_baselines();
314 + }
315 +
272 316 update_option( 'vigilante_db_version', '1.14.0' );
273 317 }
274 318
275 319 // 2.0.0: Move hide_server_signature and remove_fingerprinting_headers
@@ -361,13 +405,23 @@
361 405 $raw = get_option( Vigilante_Settings::OPTION_NAME, array() );
362 406 $stored = ( is_array( $raw ) && isset( $raw['security_headers'] ) && is_array( $raw['security_headers'] ) ) ? $raw['security_headers'] : array();
363 407 $had_fix = array_key_exists( 'fix_mixed_content', $stored ) ? ! empty( $stored['fix_mixed_content'] ) : true;
364 408
409 + /*
410 + * Merge, never replace. update_section() overwrites the whole
411 + * section, so passing just these two keys wiped every other header
412 + * setting the site had stored (HSTS, CSP, cross-origin policies,
413 + * the HTTPS switches, Server Identity) and left the screen showing
414 + * factory defaults while the .htaccess kept serving the old values.
415 + */
365 416 $this->settings->update_section(
366 417 'security_headers',
367 - array(
368 - 'fix_mixed_content' => $had_fix,
369 - 'upgrade_insecure_requests' => $had_fix,
418 + array_merge(
419 + $stored,
420 + array(
421 + 'fix_mixed_content' => $had_fix,
422 + 'upgrade_insecure_requests' => $had_fix,
423 + )
370 424 )
371 425 );
372 426
373 427 update_option( 'vigilante_db_version', '2.9.8' );
@@ -409,11 +463,172 @@
409 463 }
410 464
411 465 update_option( 'vigilante_db_version', '2.9.9' );
412 466 }
467 +
468 + /*
469 + * 2.11.0: security release (audit of 28 Aug 2026). Runs here and not
470 + * from Vigilante_Database::needs_update(): this option is shared with
471 + * that class, and on any updated site it already holds a plugin version
472 + * (2.9.9 or later), so a bump of DB_VERSION would never fire.
473 + * create_tables() widens the email code column through dbDelta (varchar
474 + * 6 to 64, the code is stored hashed since 2.11.0) and purge_for_2_11_0()
475 + * does what dbDelta cannot: it empties the trusted devices, which were
476 + * identified by User-Agent until now (S1), and the pending email codes,
477 + * stored in clear until now (S11). Every remembered device asks for the
478 + * second factor once more after this update, and the changelog says so.
479 + */
480 + if ( version_compare( $db_version, '2.11.0', '<' ) ) {
481 + $this->database->create_tables();
482 + $this->database->purge_for_2_11_0();
483 +
484 + update_option( 'vigilante_db_version', '2.11.0' );
485 + }
486 +
487 + /*
488 + * 2.11.9: clear the raw .htaccess copies that older versions left in
489 + * options, on the first admin load after the update. Uninstall already
490 + * removes them, but that only fires when the plugin is deleted, so a
491 + * site that keeps the plugin carried them until now. Three stores, each
492 + * a copy of a file that can hold secrets (a SetEnv token, an
493 + * Authorization header): the same exposure the wp.org review flagged as
494 + * 4.4, on the paths its fix did not reach.
495 + *
496 + * - vigilante_htaccess_history: up to five raw copies, by design, until
497 + * 2.11.8. The writer is gone, nothing reads it, so it is deleted.
498 + * - vigilante_htaccess_backup: the single rollback buffer, normally
499 + * cleared in the finally of each write; a copy only lingers if a write
500 + * crashed mid-operation. Nothing outside one write reads it, so a
501 + * leftover is deleted.
502 + * - vigilante_htaccess_pre_migration: still read by the header recovery,
503 + * but older versions stored the whole file where only our own block is
504 + * ever used. Truncated to that block, so the feature keeps working and
505 + * nothing outside our markers stays in the option.
506 + */
507 + if ( version_compare( $db_version, '2.11.9', '<' ) ) {
508 + delete_option( 'vigilante_htaccess_history' );
509 + delete_option( 'vigilante_htaccess_backup' );
510 +
511 + $snapshot = get_option( 'vigilante_htaccess_pre_migration' );
512 + if ( is_array( $snapshot ) && isset( $snapshot['content'] ) && '' !== (string) $snapshot['content'] ) {
513 + require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-recovery.php';
514 + $block = Vigilante_Htaccess_Recovery::get_raw_block();
515 +
516 + if ( '' === $block ) {
517 + delete_option( 'vigilante_htaccess_pre_migration' );
518 + } elseif ( $block !== $snapshot['content'] ) {
519 + $snapshot['content'] = $block;
520 + update_option( 'vigilante_htaccess_pre_migration', $snapshot, false );
521 + }
522 + }
523 +
524 + update_option( 'vigilante_db_version', '2.11.9' );
525 + }
526 +
527 + /*
528 + * 2.11.10: the pending-approval flag becomes one per site on a network.
529 + * Until 2.11.9 it was a single global user meta, so the queue was shared
530 + * across the whole network. Moving the key is not enough: the accounts
531 + * already waiting carry the old key, and reading only the new one would
532 + * let them log in. So they are moved here, each to the site it belongs
533 + * to, and the old key is removed only once the new one is written.
534 + */
535 + if ( version_compare( $db_version, '2.11.10', '<' ) ) {
536 + $this->migrate_pending_approval_per_site();
537 +
538 + update_option( 'vigilante_db_version', '2.11.10' );
539 + }
413 540 }
414 541
415 542 /**
543 + * Move the pending-approval flag of a network to a key per site
544 + *
545 + * Runs once for the whole network, not once per site: the data it moves is
546 + * global, so the guard is a network option and any site may be the one that
547 + * does it. On a single site the key does not change and there is nothing to
548 + * do.
549 + *
550 + * Each waiting account goes to its primary site, or to the only site it
551 + * belongs to; one that belongs to none goes to the main site rather than
552 + * nowhere, because losing the flag would silently approve it.
553 + *
554 + * @since 2.11.10
555 + */
556 + private function migrate_pending_approval_per_site() {
557 + global $wpdb;
558 +
559 + if ( ! is_multisite() ) {
560 + return;
561 + }
562 +
563 + if ( get_site_option( 'vigilante_pending_per_site_done' ) ) {
564 + return;
565 + }
566 +
567 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off migration of the plugin's own user meta; the meta API has no "list every user with this key".
568 + $user_ids = $wpdb->get_col(
569 + $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", 'vigilante_pending_approval' )
570 + );
571 +
572 + foreach ( (array) $user_ids as $user_id ) {
573 + $user_id = (int) $user_id;
574 + if ( ! $user_id ) {
575 + continue;
576 + }
577 +
578 + $pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
579 + $since = get_user_meta( $user_id, 'vigilante_pending_since', true );
580 +
581 + /*
582 + * Every site the account belongs to, not its primary one. The global
583 + * flag does not say where the registration happened, and the first
584 + * version of this guessed the primary blog: an account that
585 + * registered on B while its primary was A came out pending on A and
586 + * free to log in on B, which is the very site it had never been
587 + * approved on. Found by the cross review of 2.11.10.
588 + *
589 + * Marking every site it belongs to fails closed instead: the account
590 + * stays blocked wherever it can log in, and shows up in the queue of
591 + * each of those sites so somebody can actually act on it. An account
592 + * that belongs to no site goes to the main one rather than nowhere,
593 + * because losing the flag would silently approve it.
594 + */
595 + /*
596 + * With $all true, because the default leaves out archived, spam and
597 + * deleted sites (wp-includes/user.php:1113-1117): a site archived on
598 + * the day this runs would lose the flag, and the account would walk
599 + * in unapproved the moment it was brought back. Found by the second
600 + * cross review of 2.11.10.
601 + */
602 + $blog_ids = array();
603 +
604 + foreach ( get_blogs_of_user( $user_id, true ) as $blog ) {
605 + if ( ! empty( $blog->userblog_id ) ) {
606 + $blog_ids[] = (int) $blog->userblog_id;
607 + }
608 + }
609 +
610 + if ( empty( $blog_ids ) ) {
611 + $blog_ids[] = (int) get_main_site_id();
612 + }
613 +
614 + foreach ( array_unique( $blog_ids ) as $blog_id ) {
615 + $prefix = $wpdb->get_blog_prefix( $blog_id );
616 +
617 + update_user_meta( $user_id, $prefix . 'vigilante_pending_approval', $pending );
618 + if ( '' !== $since && false !== $since ) {
619 + update_user_meta( $user_id, $prefix . 'vigilante_pending_since', $since );
620 + }
621 + }
622 +
623 + delete_user_meta( $user_id, 'vigilante_pending_approval' );
624 + delete_user_meta( $user_id, 'vigilante_pending_since' );
625 + }
626 +
627 + update_site_option( 'vigilante_pending_per_site_done', 1 );
628 + }
629 +
630 + /**
416 631 * Migration: Remove orphaned email fields from saved options
417 632 *
418 633 * v1.10.0 centralized notification recipients into email section.
419 634 * Old per-module notify_email fields and dead email section fields
@@ -681,23 +896,32 @@
681 896 if ( ! did_action( 'plugins_loaded' ) ) {
682 897 return 0;
683 898 }
684 899
685 - $registration_approval = $this->settings->get_section( 'user_security' );
686 - $approval_settings = $registration_approval['registration_approval'] ?? array();
687 -
688 - if ( empty( $approval_settings['enabled'] ) ) {
689 - return 0;
690 - }
900 + /*
901 + * Counted whether the feature is on or off. An account already waiting
902 + * stays blocked when it is switched off (see init_enforcement_hooks()),
903 + * so reporting zero there hid people who cannot log in and whom nobody
904 + * could see to approve. Found by the cross review of 2.11.10.
905 + */
691 906
692 907 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
693 - $pending_users = get_users( array(
694 - 'meta_key' => 'vigilante_pending_approval',
908 + $args = array(
909 + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
695 910 'meta_value' => '1',
696 911 'fields' => 'ID',
697 - ) );
912 + );
698 913 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
699 914
915 + // Same query as Vigilante_User_Security::get_pending_users(), and for the
916 + // same reason: the meta key already scopes this to the site, and adding
917 + // core's membership filter on top hid the accounts that have no role yet.
918 + if ( is_multisite() ) {
919 + $args['blog_id'] = 0;
920 + }
921 +
922 + $pending_users = get_users( $args );
923 +
700 924 return count( $pending_users );
701 925 }
702 926
703 927 /**
@@ -1215,8 +1439,12 @@
1215 1439 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'HSTS', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'HSTS', 'vigilante' ), 'label_en' => 'HSTS', 'keywords' => _x( 'hsts strict transport security ssl tls https headers', 'settings search keywords', 'vigilante' ) ),
1216 1440 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Content Security Policy', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Content Security Policy', 'vigilante' ), 'label_en' => 'Content Security Policy', 'keywords' => _x( 'content security policy csp xss headers', 'settings search keywords', 'vigilante' ) ),
1217 1441 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Server Signature', 'vigilante' ), 'label_en' => 'Server Signature', 'keywords' => _x( 'server signature fingerprint banner', 'settings search keywords', 'vigilante' ) ),
1218 1442 array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Server Identity', 'vigilante' ), 'anchor' => 'vigilante-section-headers-main', 'label' => __( 'Remove Fingerprinting Headers', 'vigilante' ), 'label_en' => 'Remove Fingerprinting Headers', 'keywords' => _x( 'remove fingerprinting headers fingerprint banner header http', 'settings search keywords', 'vigilante' ) ),
1443 + // Security Headers - Cross-Origin Policies
1444 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Opener-Policy (COOP)', 'keywords' => _x( 'coop cross-origin opener policy popup popups window opener tag assistant google isolation browsing context headers', 'settings search keywords', 'vigilante' ) ),
1445 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Embedder-Policy (COEP)', 'keywords' => _x( 'coep cross-origin embedder policy require-corp credentialless embed embeds iframe fonts headers', 'settings search keywords', 'vigilante' ) ),
1446 + array( 'tab' => 'headers', 'tab_label' => __( 'Security Headers', 'vigilante' ), 'section' => __( 'Cross-Origin Policies', 'vigilante' ), 'anchor' => 'vigilante-section-headers-cross-origin', 'label' => __( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ), 'label_en' => 'Cross-Origin-Resource-Policy (CORP)', 'keywords' => _x( 'corp cross-origin resource policy hotlink hotlinking cdn images assets headers', 'settings search keywords', 'vigilante' ) ),
1219 1447 // Login Security
1220 1448 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Custom login URL', 'vigilante' ), 'label_en' => 'Custom login URL', 'keywords' => _x( 'custom login url signin log-in access slug', 'settings search keywords', 'vigilante' ) ),
1221 1449 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( 'Two-Factor Authentication', 'vigilante' ), 'label_en' => 'Two-Factor Authentication', 'keywords' => _x( 'two-factor authentication 2fa mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
1222 1450 array( 'tab' => 'login', 'tab_label' => __( 'Login Security', 'vigilante' ), 'section' => __( 'Login Protection', 'vigilante' ), 'anchor' => 'vigilante-section-login-main', 'label' => __( '2FA', 'vigilante' ), 'label_en' => '2FA', 'keywords' => _x( '2fa two-factor mfa otp totp authenticator', 'settings search keywords', 'vigilante' ) ),
@@ -1409,8 +1637,11 @@
1409 1637 'currentUserId' => get_current_user_id(),
1410 1638 'logoutUrl' => wp_logout_url( wp_login_url() ),
1411 1639 'adminUrl' => admin_url( 'admin.php?page=vigilante' ),
1412 1640 'searchIndex' => $this->get_search_index(),
1641 + // The scan repaints this table from JavaScript, so the same gate
1642 + // has to travel with it or half the screen keeps the dead button.
1643 + 'approvalLocked' => $this->critical_approval_locked(),
1413 1644 'underAttack' => array(
1414 1645 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(),
1415 1646 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(),
1416 1647 ),
@@ -1477,13 +1708,17 @@
1477 1708 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ),
1478 1709 'criticalConfigDesc' => __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate. Vigilant\'s own blocks are excluded from this check.', 'vigilante' ),
1479 1710 'approve' => __( 'Approve', 'vigilante' ),
1480 1711 'approving' => __( 'Approving...', 'vigilante' ),
1712 + 'approvalLockedNotice' => $this->critical_approval_notice(),
1481 1713 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ),
1482 1714 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1483 1715 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1484 1716 'changes' => __( 'Changes', 'vigilante' ),
1485 1717 'diffUnavailable' => __( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ),
1718 + 'diffNetwork' => __( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ),
1719 + 'diffRescan' => __( 'Run a new scan to see the line changes of this file.', 'vigilante' ),
1720 + 'diffRedaction' => __( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ),
1486 1721 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1487 1722 'diffLines' => __( 'lines', 'vigilante' ),
1488 1723 // Under Attack mode strings
1489 1724 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -1539,8 +1774,9 @@
1539 1774 'logType' => __( 'Type', 'vigilante' ),
1540 1775 'logAction' => __( 'Action', 'vigilante' ),
1541 1776 'logSeverity' => __( 'Severity', 'vigilante' ),
1542 1777 'logMessage' => __( 'Message', 'vigilante' ),
1778 + 'logRequestUri' => __( 'Address', 'vigilante' ),
1543 1779 'logClient' => __( 'Client', 'vigilante' ),
1544 1780 'logUser' => __( 'User', 'vigilante' ),
1545 1781 'logIpAddress' => __( 'IP Address', 'vigilante' ),
1546 1782 'logUserAgent' => __( 'User Agent', 'vigilante' ),
@@ -1745,8 +1981,21 @@
1745 1981 </p>
1746 1982 <p>
1747 1983 <em><?php esc_html_e( 'Vigilant has applied the Maximum preset plus extra hardening on top of your previous configuration. Any changes you make to Vigilant settings while this mode is active will be reverted when it ends.', 'vigilante' ); ?></em>
1748 1984 </p>
1985 + <?php
1986 + // The cache-bypass rules could not be written (a host where
1987 + // WordPress cannot write files by itself, a held lock, a
1988 + // failed read-back): show them, so they can be added by hand.
1989 + $ua_instance = new Vigilante_Under_Attack( $this->settings, $this->activity_log );
1990 + if ( $ua_instance->cache_rules_missing() ) :
1991 + ?>
1992 + <p>
1993 + <strong><?php esc_html_e( 'The cache-bypass rules could not be written to your .htaccess.', 'vigilante' ); ?></strong>
1994 + <?php esc_html_e( 'Without them a page cache may keep serving stored pages during the attack. Add this block at the top of the .htaccess in your site root (the activity log records why it was not written):', 'vigilante' ); ?>
1995 + </p>
1996 + <textarea readonly rows="9" class="large-text code" onclick="this.select();"><?php echo esc_textarea( Vigilante_Under_Attack::get_cache_bypass_block() ); ?></textarea>
1997 + <?php endif; ?>
1749 1998 </div>
1750 1999 <?php
1751 2000 }
1752 2001 }
@@ -1978,8 +2227,41 @@
1978 2227 return ! Vigilante_Settings::can_write_shared_files();
1979 2228 }
1980 2229
1981 2230 /**
2231 + * Whether this is the main site and the user cannot change what it builds the shared files from
2232 + *
2233 + * See Vigilante_Settings::get_main_site_file_settings(). On a subsite those
2234 + * settings only act on that site, so they are never locked there.
2235 + *
2236 + * @since 2.11.6
2237 + *
2238 + * @return bool
2239 + */
2240 + private function main_site_files_locked() {
2241 + return $this->shared_files_locked() && Vigilante_Settings::owns_shared_files();
2242 + }
2243 +
2244 + /**
2245 + * Sentence added to a bulk change when some settings were left as they were
2246 + *
2247 + * Importing a file, applying a preset and restoring the defaults touch every
2248 + * section at once, so the user is told that the shared file settings did
2249 + * not move.
2250 + *
2251 + * @since 2.11.6
2252 + *
2253 + * @return string Empty when the user can change every setting.
2254 + */
2255 + private function locked_file_settings_message() {
2256 + if ( ! Vigilante_Settings::get_locked_file_settings() ) {
2257 + return '';
2258 + }
2259 +
2260 + return ' ' . __( 'The settings that end up in wp-config.php or .htaccess were left as they were.', 'vigilante' ) . ' ' . Vigilante_Settings::get_shared_files_notice();
2261 + }
2262 +
2263 + /**
1982 2264 * Print the shared-files notice for a section that cannot be edited here
1983 2265 *
1984 2266 * @since 2.9.8
1985 2267 */
@@ -1994,8 +2276,109 @@
1994 2276 <?php
1995 2277 }
1996 2278
1997 2279 /**
2280 + * Acting on another user's account needs permission over that user
2281 + *
2282 + * Since 2.10.3 the handlers behind these tools ask for edit_user over the
2283 + * target, which is the rule WordPress itself applies. On a network the core
2284 + * grants edit_user only to network administrators, so for anybody else these
2285 + * controls do nothing. Better to say so than to paint a button that silently
2286 + * skips every user.
2287 + *
2288 + * @since 2.10.4
2289 + * @return bool
2290 + */
2291 + private function forwarded_chain_readings() {
2292 + // Shown, not decided on: the firewall resolves the address elsewhere.
2293 + $chain = Vigilante_IP_Utils::trusted_forwarded_for();
2294 +
2295 + if ( '' === $chain ) {
2296 + return array();
2297 + }
2298 +
2299 + $public = array();
2300 +
2301 + foreach ( explode( ',', $chain ) as $entry ) {
2302 + $address = Vigilante_IP_Utils::unmap_ipv4( trim( $entry ) );
2303 +
2304 + if ( filter_var( $address, FILTER_VALIDATE_IP ) && ! Vigilante_IP_Utils::is_own_network( $address ) ) {
2305 + $public[] = $address;
2306 + }
2307 + }
2308 +
2309 + if ( count( $public ) < 2 ) {
2310 + return array();
2311 + }
2312 +
2313 + return array(
2314 + 'now' => Vigilante_IP_Utils::client_from_chain( $chain ),
2315 + 'before' => $public[0],
2316 + );
2317 + }
2318 +
2319 + /**
2320 + * Whether the user tools of this screen are out of reach for this user
2321 + *
2322 + * @return bool
2323 + */
2324 + private function user_actions_locked() {
2325 + // On a single site edit_user maps to edit_users, which a custom role with
2326 + // manage_options may lack: since 2.11.8 approving and rejecting a pending
2327 + // registration ask for it, so the buttons have to say so there too.
2328 + return is_multisite() ? ! current_user_can( 'manage_network_users' ) : ! current_user_can( 'edit_users' );
2329 + }
2330 +
2331 + /**
2332 + * Print the notice for user tools that cannot be used from this site
2333 + *
2334 + * @since 2.10.4
2335 + */
2336 + private function render_user_actions_notice() {
2337 + if ( ! $this->user_actions_locked() ) {
2338 + return;
2339 + }
2340 + ?>
2341 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2342 + <?php if ( is_multisite() ) : ?>
2343 + <p style="margin:0;"><?php esc_html_e( 'These tools act on user accounts, which on a network belong to the whole network rather than to one site. WordPress reserves that to network administrators, so they are managed from the network admin.', 'vigilante' ); ?></p>
2344 + <?php else : ?>
2345 + <p style="margin:0;"><?php esc_html_e( 'These tools act on other user accounts, and your role cannot edit users, so they are not available to you.', 'vigilante' ); ?></p>
2346 + <?php endif; ?>
2347 + </div>
2348 + <?php
2349 + }
2350 +
2351 + /**
2352 + * Approving a change to the shared config files needs the network
2353 + *
2354 + * Since 2.11.3 the handler behind the Approve button asks for
2355 + * manage_network_options, because the two files it approves, wp-config.php
2356 + * and the root .htaccess, belong to the installation, and so does the
2357 + * record of them. The button, though, went on being painted for everybody,
2358 + * so the administrator of a subsite saw the warning, saw the button,
2359 + * pressed it and got "Permission denied" with no explanation. That is
2360 + * exactly what user_actions_locked() above exists to avoid, one release
2361 + * later and one screen over. Flagged by @calzbert.
2362 + *
2363 + * @since 2.11.4
2364 + * @return bool
2365 + */
2366 + private function critical_approval_locked() {
2367 + return is_multisite() && ! current_user_can( 'manage_network_options' );
2368 + }
2369 +
2370 + /**
2371 + * The line that replaces the Approve button where it cannot be used
2372 + *
2373 + * @since 2.11.4
2374 + * @return string
2375 + */
2376 + private function critical_approval_notice() {
2377 + return __( 'These files belong to the whole network rather than to this site, so a change to them is approved from the network admin.', 'vigilante' );
2378 + }
2379 +
2380 + /**
1998 2381 * Check if module is disabled and render warning
1999 2382 *
2000 2383 * @param string $module_key Module key.
2001 2384 * @return bool True if disabled.
@@ -2539,14 +2922,15 @@
2539 2922
2540 2923 <?php $this->render_analyzer_widget( $analyzer_last_scan, $analyzer_history, $analyzer_categories_def, $analyzer_settings ); ?>
2541 2924
2542 2925 <div class="vigilante-modules-grid">
2543 - <h2><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2926 + <h2 id="vigilante-section-dashboard-modules"><?php esc_html_e( 'Security Modules', 'vigilante' ); ?></h2>
2544 2927 <p class="description"><?php esc_html_e( 'Enable or disable security modules. Each module controls a tab with detailed settings.', 'vigilante' ); ?></p>
2545 2928 <div class="vigilante-modules-list">
2546 2929 <?php foreach ( $options['modules'] as $module => $enabled ) :
2547 2930 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2548 2931 $description = isset( $module_descriptions[ $module ] ) ? $module_descriptions[ $module ] : '';
2932 + $vg_module_locked = $this->main_site_files_locked() && in_array( $module, Vigilante_Settings::get_main_site_file_settings()['modules'], true );
2549 2933 ?>
2550 2934 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2551 2935 <div class="vigilante-module-header">
2552 2936 <span class="vigilante-module-status"></span>
@@ -2559,8 +2943,9 @@
2559 2943 <input type="checkbox"
2560 2944 name="modules[<?php echo esc_attr( $module ); ?>]"
2561 2945 value="1"
2562 2946 <?php checked( $enabled ); ?>
2947 + <?php disabled( $vg_module_locked ); ?>
2563 2948 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2564 2949 data-module="<?php echo esc_attr( $module ); ?>">
2565 2950 <span class="vigilante-toggle-slider"></span>
2566 2951 </label>
@@ -2567,8 +2952,11 @@
2567 2952 </div>
2568 2953 <?php if ( $description ) : ?>
2569 2954 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2570 2955 <?php endif; ?>
2956 + <?php if ( $vg_module_locked ) : ?>
2957 + <p class="vigilante-module-desc"><?php esc_html_e( 'On the main site of a network this module also writes files every site shares, so only a network administrator can switch it.', 'vigilante' ); ?></p>
2958 + <?php endif; ?>
2571 2959 </div>
2572 2960 <?php endforeach; ?>
2573 2961 </div>
2574 2962 </div>
@@ -2600,9 +2988,9 @@
2600 2988 $ua_remaining_hours = floor( $ua_remaining / 3600 );
2601 2989 $ua_remaining_mins = floor( ( $ua_remaining % 3600 ) / 60 );
2602 2990 ?>
2603 2991 <div class="vigilante-preset-card vigilante-under-attack-card <?php echo $ua_active ? 'vigilante-under-attack-active' : ''; ?>">
2604 - <h3>
2992 + <h3 id="vigilante-section-dashboard-under-attack">
2605 2993 <span class="dashicons dashicons-shield"></span>
2606 2994 <?php esc_html_e( 'Under Attack', 'vigilante' ); ?>
2607 2995 </h3>
2608 2996 <p><?php esc_html_e( 'Emergency mode. JavaScript challenge for all visitors, aggressive rate limiting, and restricted access. Auto-deactivates after 4 hours.', 'vigilante' ); ?></p>
@@ -2958,14 +3346,21 @@
2958 3346 <?php esc_html_e( 'Full page caching systems that serve cached pages before PHP executes (Varnish, LiteSpeed Cache, NGINX FastCGI Cache, Cloudflare APO) may bypass PHP-level firewall rules for cached requests. The .htaccess rules will still apply on Apache/LiteSpeed servers.', 'vigilante' ); ?>
2959 3347 </p>
2960 3348 </div>
2961 3349
3350 + <?php $vg_main_locked = $this->main_site_files_locked(); ?>
3351 + <?php if ( $vg_main_locked ) : ?>
3352 + <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
3353 + <p style="margin:0;"><?php esc_html_e( 'On the main site of a network, blocking bad bots and bad query strings, the visitor IP detection and the two whitelists also build the .htaccess rules every site shares, so only a network administrator can change them.', 'vigilante' ); ?></p>
3354 + </div>
3355 + <?php endif; ?>
3356 +
2962 3357 <table class="form-table">
2963 3358 <tr>
2964 3359 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
2965 3360 <td>
2966 3361 <label>
2967 - <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
3362 + <input type="checkbox" name="firewall[block_bad_query_strings]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_query_strings'] ) ); ?>>
2968 3363 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
2969 3364 </label>
2970 3365 </td>
2971 3366 </tr>
@@ -3008,9 +3403,9 @@
3008 3403 <tr>
3009 3404 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3010 3405 <td>
3011 3406 <label>
3012 - <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3407 + <input type="checkbox" name="firewall[block_bad_bots]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( ! empty( $options['block_bad_bots'] ) ); ?>>
3013 3408 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3014 3409 </label>
3015 3410 </td>
3016 3411 </tr>
@@ -3124,8 +3519,29 @@
3124 3519 </table>
3125 3520 </div>
3126 3521 <?php endif; ?>
3127 3522
3523 + <?php
3524 + // Since 2.11.8 X-Forwarded-For is read from its end, where the proxy
3525 + // writes. The administrator's own request shows whether that end is
3526 + // a CDN or a balancer for everybody here. Cross review of 2.11.8.
3527 + $xff_readings = $this->forwarded_chain_readings();
3528 + if ( $xff_readings ) :
3529 + ?>
3530 + <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;">
3531 + <p style="margin:0;">
3532 + <?php
3533 + printf(
3534 + /* translators: 1: last address in the header, the one Vigilant reads, 2: first address in the header, which a visitor can write */
3535 + esc_html__( 'Your own request reaches the site with more than one public address in X-Forwarded-For. Vigilant reads the last one, %1$s, which is the one your proxy added, and not the first one, %2$s, which a visitor can write. If %1$s belongs to a CDN or a load balancer rather than to you, every visitor shares it for rate limiting, login lockouts and the IP lists: choose the header of that CDN in Visitor IP detection, such as CF-Connecting-IP for Cloudflare.', 'vigilante' ),
3536 + esc_html( $xff_readings['now'] ),
3537 + esc_html( $xff_readings['before'] )
3538 + );
3539 + ?>
3540 + </p>
3541 + </div>
3542 + <?php endif; ?>
3543 +
3128 3544 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3129 3545 <p class="description">
3130 3546 <?php
3131 3547 printf(
@@ -3139,9 +3555,9 @@
3139 3555 <tr>
3140 3556 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3141 3557 <td>
3142 3558 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3143 - <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]">
3559 + <select id="vigilante-f-firewall-trusted-proxy-header" name="firewall[trusted_proxy_header]" <?php disabled( $vg_main_locked ); ?>>
3144 3560 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3145 3561 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3146 3562 <option value="x-forwarded-for" <?php selected( $proxy_header, 'x-forwarded-for' ); ?>><?php esc_html_e( 'Behind a reverse proxy or load balancer (X-Forwarded-For)', 'vigilante' ); ?></option>
3147 3563 <option value="x-real-ip" <?php selected( $proxy_header, 'x-real-ip' ); ?>><?php esc_html_e( 'Behind an nginx proxy (X-Real-IP)', 'vigilante' ); ?></option>
@@ -3151,11 +3567,23 @@
3151 3567 </p>
3152 3568 </td>
3153 3569 </tr>
3154 3570 <tr>
3571 + <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th>
3572 + <td>
3573 + <textarea id="vigilante-f-firewall-trusted-proxies" name="firewall[trusted_proxies]" rows="3" class="large-text code" placeholder="10.0.0.0/8&#10;192.168.1.1" <?php disabled( $vg_main_locked ); ?>><?php echo esc_textarea( implode( "\n", $options['trusted_proxies'] ?? array() ) ); ?></textarea>
3574 + <p class="description">
3575 + <?php esc_html_e( 'Only used with a forwarded header selected above. One IP or CIDR range per line: the addresses your proxy or load balancer connects from. The forwarded header is accepted only from these. Left empty, Vigilant accepts it from your own private network, and for Cloudflare from Cloudflare\'s own ranges automatically.', 'vigilante' ); ?>
3576 + <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?>
3577 + <br><strong><?php esc_html_e( 'The header above is trusted but no proxy IPs are set. If your proxy or load balancer connects from a public address, add it here, or the header is ignored for safety and every visitor is seen as that proxy.', 'vigilante' ); ?></strong>
3578 + <?php endif; ?>
3579 + </p>
3580 + </td>
3581 + </tr>
3582 + <tr>
3155 3583 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3156 3584 <td>
3157 - <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3585 + <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code" placeholder="192.168.1.50&#10;192.168.1.0/24&#10;192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea>
3158 3586 <p class="description">
3159 3587 <?php esc_html_e( 'One IP per line. These IPs bypass the firewall checks, and they also reach wp-admin when the login URL is hidden, so remote managers such as MainWP or ManageWP are not turned away with a 404. The hidden login form itself stays hidden for every IP, this one included.', 'vigilante' ); ?>
3160 3588 <br>
3161 3589 <?php
@@ -3194,9 +3622,9 @@
3194 3622 <table class="form-table">
3195 3623 <tr>
3196 3624 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3197 3625 <td>
3198 - <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3626 + <textarea id="vigilante-f-firewall-ua-whitelist" name="firewall[ua_whitelist]" <?php disabled( $vg_main_locked ); ?> rows="4" class="large-text code"><?php echo esc_textarea( implode( "\n", $options['ua_whitelist'] ?? array() ) ); ?></textarea>
3199 3627 <p class="description"><?php esc_html_e( 'One User-Agent per line. These will bypass all firewall checks. Example: ManageWP, MainWP, UptimeRobot.', 'vigilante' ); ?></p>
3200 3628 </td>
3201 3629 </tr>
3202 3630 <tr>
@@ -3484,9 +3912,9 @@
3484 3912 $two_factor = $options['two_factor'] ?? array();
3485 3913 $two_factor_enabled = ! empty( $two_factor['enabled'] );
3486 3914 ?>
3487 3915 <div class="vigilante-settings-section vigilante-lockout-section">
3488 - <h2><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3916 + <h2 id="vigilante-section-login-status"><?php esc_html_e( 'Login Protection Status', 'vigilante' ); ?></h2>
3489 3917
3490 3918 <table class="form-table">
3491 3919 <tr>
3492 3920 <th scope="row"><?php esc_html_e( 'Current settings', 'vigilante' ); ?></th>
@@ -3646,9 +4074,9 @@
3646 4074 $excluded = $two_factor['excluded_users'] ?? array();
3647 4075 $method = $two_factor['method'] ?? 'email';
3648 4076 $grace_days = $two_factor['grace_period_days'] ?? 3;
3649 4077 ?>
3650 - <h3>
4078 + <h3 id="vigilante-section-login-2fa">
3651 4079 <?php esc_html_e( 'Two-Factor Authentication (2FA)', 'vigilante' ); ?>
3652 4080 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
3653 4081 <span class="vigilante-method-badge database"><?php esc_html_e( 'Database', 'vigilante' ); ?></span>
3654 4082 </h3>
@@ -3847,8 +4275,128 @@
3847 4275
3848 4276 /**
3849 4277 * Render security headers tab
3850 4278 */
4279 + /**
4280 + * Offer back the header settings the 2.9.8 migration wiped.
4281 + *
4282 + * Rendered outside the settings form on purpose, so its buttons can never
4283 + * submit it, and only when there is something to actually change. Shows the
4284 + * difference before anything is written: nothing is applied that the owner
4285 + * has not seen first.
4286 + *
4287 + * @since 2.10.0
4288 + */
4289 + private function render_headers_recovery_offer() {
4290 + /*
4291 + * On a network the .htaccess belongs to every site and only the main one
4292 + * writes it, so this is not a decision a subsite gets to make. Its own
4293 + * security_headers options are inert anyway: what the network serves
4294 + * comes from the file the main site owns. Without this gate a subsite
4295 + * administrator was shown a Restore button that could only ever answer
4296 + * with a permission error, which is worse than showing nothing.
4297 + */
4298 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
4299 + return;
4300 + }
4301 +
4302 + if ( ! Vigilante_Htaccess_Recovery::is_available() ) {
4303 + /*
4304 + * Already restored. Offer to take it back for as long as the previous
4305 + * section is still stored: a restore that cannot be undone is a second
4306 + * irreversible change on top of the one being repaired.
4307 + */
4308 + if ( Vigilante_Htaccess_Recovery::has_undo() ) {
4309 + ?>
4310 + <div class="notice notice-info inline" id="vigilante-headers-recovery-undo">
4311 + <p>
4312 + <?php esc_html_e( 'The Security Headers settings were restored from the copy Vigilant had kept of your .htaccess.', 'vigilante' ); ?>
4313 + <button type="button" class="button button-small" id="vigilante-recovery-undo">
4314 + <?php esc_html_e( 'Undo the restore', 'vigilante' ); ?>
4315 + </button>
4316 + </p>
4317 + </div>
4318 + <?php
4319 + }
4320 +
4321 + return;
4322 + }
4323 +
4324 + $rows = Vigilante_Htaccess_Recovery::get_diff( $this->settings );
4325 +
4326 + if ( empty( $rows ) ) {
4327 + return;
4328 + }
4329 +
4330 + $snapshot = Vigilante_Htaccess_Recovery::get_snapshot();
4331 + $taken = isset( $snapshot['time'] ) ? (int) $snapshot['time'] : 0;
4332 + $block = Vigilante_Htaccess_Recovery::get_raw_block();
4333 + ?>
4334 + <div class="vigilante-settings-section" id="vigilante-headers-recovery">
4335 + <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
4336 + <p>
4337 + <?php esc_html_e( 'An earlier update reset this tab to factory values: the migration replaced the whole section instead of merging into it. Your server kept sending the right headers, because the .htaccess had not been rewritten yet, so Vigilant saved a copy of that file before touching it. These are the settings it found in that copy.', 'vigilante' ); ?>
4338 + </p>
4339 + <?php if ( $taken ) : ?>
4340 + <p class="description">
4341 + <?php
4342 + printf(
4343 + /* translators: %s: date and time the .htaccess copy was taken. */
4344 + esc_html__( 'Copy taken on %s.', 'vigilante' ),
4345 + esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $taken ) )
4346 + );
4347 + ?>
4348 + </p>
4349 + <?php endif; ?>
4350 +
4351 + <table class="widefat striped">
4352 + <thead>
4353 + <tr>
4354 + <th scope="col"><?php esc_html_e( 'Setting', 'vigilante' ); ?></th>
4355 + <th scope="col"><?php esc_html_e( 'Now', 'vigilante' ); ?></th>
4356 + <th scope="col"><?php esc_html_e( 'Would be restored to', 'vigilante' ); ?></th>
4357 + </tr>
4358 + </thead>
4359 + <tbody>
4360 + <?php foreach ( $rows as $row ) : ?>
4361 + <tr>
4362 + <th scope="row"><?php echo esc_html( $row['label'] ); ?></th>
4363 + <td><?php echo esc_html( $row['current'] ); ?></td>
4364 + <td>
4365 + <?php echo esc_html( $row['recovered'] ); ?>
4366 + <?php if ( ! empty( $row['detail'] ) ) : ?>
4367 + <br><span class="description"><?php echo esc_html( $row['detail'] ); ?></span>
4368 + <?php endif; ?>
4369 + </td>
4370 + </tr>
4371 + <?php endforeach; ?>
4372 + </tbody>
4373 + </table>
4374 +
4375 + <p class="description">
4376 + <?php esc_html_e( 'Only these settings are written. The .htaccess is then rebuilt from them, the same way saving this tab rebuilds it. The stored copy of the file is never written back, so nothing your host, your cache plugin or your CDN added to it is touched.', 'vigilante' ); ?>
4377 + </p>
4378 +
4379 + <?php if ( '' !== $block ) : ?>
4380 + <details>
4381 + <summary><?php esc_html_e( 'Show the saved .htaccess block', 'vigilante' ); ?></summary>
4382 + <textarea readonly rows="12" class="large-text code" onclick="this.select();"><?php echo esc_textarea( $block ); ?></textarea>
4383 + </details>
4384 + <?php endif; ?>
4385 +
4386 + <p class="submit vigilante-submit-buttons">
4387 + <button type="button" class="button button-primary" id="vigilante-recovery-restore">
4388 + <?php esc_html_e( 'Restore these settings', 'vigilante' ); ?>
4389 + </button>
4390 + <button type="button" class="button" id="vigilante-recovery-dismiss">
4391 + <?php esc_html_e( 'No thanks, keep what I have', 'vigilante' ); ?>
4392 + </button>
4393 + </p>
4394 + <div id="vigilante-recovery-result"></div>
4395 + </div>
4396 + <?php
4397 + }
4398 +
3851 4399 private function render_tab_headers() {
3852 4400 $is_disabled = $this->render_module_disabled_notice( 'security_headers' );
3853 4401 // Every setting on this tab ends up in .htaccess, so on a subsite the
3854 4402 // whole tab is somebody else's, values included.
@@ -3854,8 +4402,10 @@
3854 4402 // whole tab is somebody else's, values included.
3855 4403 $vg_shared_locked = $this->shared_files_locked();
3856 4404 $options = $this->get_section_for_display( 'security_headers' );
3857 4405 ?>
4406 + <?php $this->render_headers_recovery_offer(); ?>
4407 +
3858 4408 <form class="vigilante-settings-form <?php echo $is_disabled ? 'vigilante-form-disabled' : ''; ?>" data-section="security_headers" <?php echo $is_disabled ? 'inert' : ''; ?>>
3859 4409 <?php $this->render_shared_files_notice(); ?>
3860 4410 <div id="vigilante-section-headers-main" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
3861 4411 <h2>
@@ -3897,9 +4447,9 @@
3897 4447 </td>
3898 4448 </tr>
3899 4449 </table>
3900 4450
3901 - <h3><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
4451 + <h3 id="vigilante-section-headers-csp"><?php esc_html_e( 'Content Security Policy', 'vigilante' ); ?></h3>
3902 4452 <table class="form-table">
3903 4453 <tr>
3904 4454 <th scope="row"><?php esc_html_e( 'Enable CSP', 'vigilante' ); ?></th>
3905 4455 <td>
@@ -3919,9 +4469,9 @@
3919 4469 </td>
3920 4470 </tr>
3921 4471 </table>
3922 4472
3923 - <h3><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
4473 + <h3 id="vigilante-section-headers-force-https"><?php esc_html_e( 'HTTPS', 'vigilante' ); ?></h3>
3924 4474 <p class="description"><?php esc_html_e( 'HTTPS is strongly recommended, but Vigilant will not impose it. Enable only what your site already supports.', 'vigilante' ); ?></p>
3925 4475 <table class="form-table">
3926 4476 <tr>
3927 4477 <th scope="row"><?php esc_html_e( 'Redirect HTTP to HTTPS', 'vigilante' ); ?></th>
@@ -3964,9 +4514,9 @@
3964 4514 </td>
3965 4515 </tr>
3966 4516 </table>
3967 4517
3968 - <h3><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
4518 + <h3 id="vigilante-section-headers-hsts"><?php esc_html_e( 'HSTS (HTTP Strict Transport Security)', 'vigilante' ); ?></h3>
3969 4519 <?php $vig_home_https = ( 0 === strpos( (string) get_option( 'home' ), 'https://' ) ); ?>
3970 4520 <p class="description"><?php esc_html_e( 'Tells browsers to reach this site over HTTPS and never over HTTP, for as long as the max age below.', 'vigilante' ); ?></p>
3971 4521 <?php if ( ! $vig_home_https ) : ?>
3972 4522 <p class="description" style="color:#b32d2e"><strong><?php esc_html_e( 'Unavailable: the site address still starts with http://. Enabling HSTS on a site not published over HTTPS would make it unreachable in any browser that honours it.', 'vigilante' ); ?></strong></p>
@@ -4007,9 +4557,9 @@
4007 4557 </td>
4008 4558 </tr>
4009 4559 </table>
4010 4560
4011 - <h3><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4561 + <h3 id="vigilante-section-headers-fingerprint"><?php esc_html_e( 'Server Identity', 'vigilante' ); ?></h3>
4012 4562 <p class="description"><?php esc_html_e( 'Hide identifying information that servers expose in responses.', 'vigilante' ); ?></p>
4013 4563 <table class="form-table">
4014 4564 <tr>
4015 4565 <th scope="row"><?php esc_html_e( 'Server Signature', 'vigilante' ); ?></th>
@@ -4031,8 +4581,55 @@
4031 4581 </tr>
4032 4582 </table>
4033 4583 </div>
4034 4584
4585 + <?php $vg_cop = ( isset( $options['cross_origin_policies'] ) && is_array( $options['cross_origin_policies'] ) ) ? $options['cross_origin_policies'] : array(); ?>
4586 + <div id="vigilante-section-headers-cross-origin" class="vigilante-settings-section <?php echo $vg_shared_locked ? 'vigilante-form-disabled' : ''; ?>" <?php echo $vg_shared_locked ? 'inert' : ''; ?>>
4587 + <h2>
4588 + <?php esc_html_e( 'Cross-Origin Policies', 'vigilante' ); ?>
4589 + <span class="vigilante-method-badge htaccess"><?php esc_html_e( 'HTACCESS', 'vigilante' ); ?></span>
4590 + </h2>
4591 + <p><?php esc_html_e( 'Control how other origins may open, embed or fetch your site. Vigilant already sends these headers with the values below.', 'vigilante' ); ?></p>
4592 +
4593 + <table class="form-table">
4594 + <tr>
4595 + <th scope="row"><label for="vigilante-f-security-headers-coop"><?php esc_html_e( 'Cross-Origin-Opener-Policy (COOP)', 'vigilante' ); ?></label></th>
4596 + <td>
4597 + <select id="vigilante-f-security-headers-coop" name="security_headers[cross_origin_policies][opener_policy]">
4598 + <option value="" <?php selected( empty( $vg_cop['opener_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
4599 + <option value="unsafe-none" <?php selected( $vg_cop['opener_policy'] ?? '', 'unsafe-none' ); ?>>unsafe-none</option>
4600 + <option value="same-origin-allow-popups" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin-allow-popups' ); ?>><?php esc_html_e( 'same-origin-allow-popups (recommended)', 'vigilante' ); ?></option>
4601 + <option value="same-origin" <?php selected( $vg_cop['opener_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
4602 + </select>
4603 + <p class="description"><?php esc_html_e( '&#9432; Cuts the link between your site and a window from another origin that opened it. Side effect: external tools that open your site in a new tab and talk to it through window.opener, such as Google Tag Assistant, will report that they cannot connect. Pick unsafe-none or Disabled if you need those tools.', 'vigilante' ); ?></p>
4604 + </td>
4605 + </tr>
4606 + <tr>
4607 + <th scope="row"><label for="vigilante-f-security-headers-coep"><?php esc_html_e( 'Cross-Origin-Embedder-Policy (COEP)', 'vigilante' ); ?></label></th>
4608 + <td>
4609 + <select id="vigilante-f-security-headers-coep" name="security_headers[cross_origin_policies][embedder_policy]">
4610 + <option value="unsafe-none" <?php selected( ( $vg_cop['embedder_policy'] ?? 'unsafe-none' ), 'unsafe-none' ); ?>><?php esc_html_e( 'unsafe-none (header not sent)', 'vigilante' ); ?></option>
4611 + <option value="credentialless" <?php selected( $vg_cop['embedder_policy'] ?? '', 'credentialless' ); ?>>credentialless</option>
4612 + <option value="require-corp" <?php selected( $vg_cop['embedder_policy'] ?? '', 'require-corp' ); ?>>require-corp</option>
4613 + </select>
4614 + <p class="description"><?php esc_html_e( '&#9432; Requires every cross-origin resource to opt in. require-corp can block third-party images, fonts, videos and embeds that do not send their own CORP or CORS headers.', 'vigilante' ); ?></p>
4615 + </td>
4616 + </tr>
4617 + <tr>
4618 + <th scope="row"><label for="vigilante-f-security-headers-corp"><?php esc_html_e( 'Cross-Origin-Resource-Policy (CORP)', 'vigilante' ); ?></label></th>
4619 + <td>
4620 + <select id="vigilante-f-security-headers-corp" name="security_headers[cross_origin_policies][resource_policy]">
4621 + <option value="" <?php selected( empty( $vg_cop['resource_policy'] ) ); ?>><?php esc_html_e( 'Disabled (header not sent)', 'vigilante' ); ?></option>
4622 + <option value="same-site" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-site' ); ?>>same-site</option>
4623 + <option value="same-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'same-origin' ); ?>>same-origin</option>
4624 + <option value="cross-origin" <?php selected( $vg_cop['resource_policy'] ?? '', 'cross-origin' ); ?>><?php esc_html_e( 'cross-origin (recommended)', 'vigilante' ); ?></option>
4625 + </select>
4626 + <p class="description"><?php esc_html_e( '&#9432; Declares who may load resources from this site. same-origin stops hotlinking, but it also breaks CDNs, feed readers and any external service that fetches your images or files.', 'vigilante' ); ?></p>
4627 + </td>
4628 + </tr>
4629 + </table>
4630 + </div>
4631 +
4035 4632 <p class="submit vigilante-submit-buttons">
4036 4633 <?php if ( ! $vg_shared_locked ) : ?>
4037 4634 <button type="submit" class="button button-primary vigilante-save-btn" data-original-text="<?php esc_attr_e( 'Save Settings', 'vigilante' ); ?>">
4038 4635 <?php esc_html_e( 'Save Settings', 'vigilante' ); ?>
@@ -4347,8 +4944,16 @@
4347 4944 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4348 4945 </h2>
4349 4946 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
4350 4947
4948 + <?php if ( Vigilante_User_Security::session_limit_is_network_wide() ) : ?>
4949 + <div class="notice notice-warning inline">
4950 + <p>
4951 + <?php esc_html_e( 'This limit does not apply on a network. WordPress keeps the sessions of an account for the whole network, not per site, so a limit set here would count and close the sessions that person opened on other sites, including an administrator session elsewhere. A network-wide session policy is planned; until then these settings are saved but not enforced.', 'vigilante' ); ?>
4952 + </p>
4953 + </div>
4954 + <?php endif; ?>
4955 +
4351 4956 <table class="form-table">
4352 4957 <tr>
4353 4958 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
4354 4959 <td>
@@ -4551,8 +5156,11 @@
4551 5156 <h2 class="vigilante-tools-header">
4552 5157 <?php esc_html_e( 'User security tools', 'vigilante' ); ?>
4553 5158 </h2>
4554 5159
5160 + <?php $this->render_user_actions_notice(); ?>
5161 + <?php if ( ! $this->user_actions_locked() ) : ?>
5162 +
4555 5163 <!-- Force Password Reset -->
4556 5164 <div class="vigilante-tool-box">
4557 5165 <h3><?php esc_html_e( 'Force password reset', 'vigilante' ); ?></h3>
4558 5166 <p class="description"><?php esc_html_e( 'Force users to reset their password. Useful after a security incident. Users will receive an email with a reset link.', 'vigilante' ); ?></p>
@@ -4685,12 +5293,20 @@
4685 5293 </div>
4686 5294
4687 5295 <!-- Pending Registrations -->
4688 5296 <?php
4689 - $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log );
5297 + // Enforcement-only: this instance exists to read the queue, and the
5298 + // flag keeps it from registering the module's own hooks a second
5299 + // time. It is not inert, and saying it was would be a false comment:
5300 + // init_enforcement_hooks() does add its three filters again, on top
5301 + // of the ones already registered. They are idempotent (the same
5302 + // methods of an equivalent instance, deciding on the same user meta),
5303 + // so running them twice in an admin request changes nothing, which is
5304 + // why this is accepted rather than worked around.
5305 + $user_security = new Vigilante_User_Security( $this->settings, $this->activity_log, true );
4690 5306 $pending_users = $user_security->get_pending_users();
4691 5307 ?>
4692 - <div class="vigilante-tool-box vigilante-pending-users-section">
5308 + <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
4693 5309 <h3>
4694 5310 <?php esc_html_e( 'Pending registrations', 'vigilante' ); ?>
4695 5311 <?php if ( count( $pending_users ) > 0 ) : ?>
4696 5312 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
@@ -4696,9 +5312,20 @@
4696 5312 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
4697 5313 <?php endif; ?>
4698 5314 </h3>
4699 5315
4700 - <?php if ( empty( $registration['enabled'] ) ) : ?>
5316 + <?php
5317 + /*
5318 + * The queue is shown whenever there is somebody in it, even with
5319 + * the feature off. Since 2.11.10 an account already waiting stays
5320 + * blocked when the feature is switched off, which is the point:
5321 + * turning a setting off must not quietly let in people an
5322 + * administrator decided not to approve. But hiding the table then
5323 + * left them locked out with no button anywhere to approve or
5324 + * reject them. Found by the cross review of 2.11.10.
5325 + */
5326 + ?>
5327 + <?php if ( empty( $registration['enabled'] ) && empty( $pending_users ) ) : ?>
4701 5328 <p class="description">
4702 5329 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
4703 5330 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
4704 5331 </p>
@@ -4707,8 +5334,9 @@
4707 5334 <span class="dashicons dashicons-yes-alt"></span>
4708 5335 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
4709 5336 </div>
4710 5337 <?php else : ?>
5338 + <?php $this->render_user_actions_notice(); ?>
4711 5339 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
4712 5340 <thead>
4713 5341 <tr>
4714 5342 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -4718,9 +5346,9 @@
4718 5346 </tr>
4719 5347 </thead>
4720 5348 <tbody>
4721 5349 <?php foreach ( $pending_users as $pending_user ) :
4722 - $pending_since = get_user_meta( $pending_user->ID, 'vigilante_pending_since', true );
5350 + $pending_since = get_user_meta( $pending_user->ID, Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_since' ), true );
4723 5351 ?>
4724 5352 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
4725 5353 <td>
4726 5354 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
@@ -4737,12 +5365,12 @@
4737 5365 }
4738 5366 ?>
4739 5367 </td>
4740 5368 <td>
4741 - <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5369 + <button type="button" class="button button-small vigilante-approve-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" <?php disabled( $this->user_actions_locked() ); ?>>
4742 5370 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
4743 5371 </button>
4744 - <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;">
5372 + <button type="button" class="button button-small vigilante-reject-user" data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>" style="color: #d63638;" <?php disabled( $this->user_actions_locked() ); ?>>
4745 5373 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
4746 5374 </button>
4747 5375 </td>
4748 5376 </tr>
@@ -4864,8 +5492,10 @@
4864 5492 </button>
4865 5493 </p>
4866 5494 </div>
4867 5495 </div>
5496 +
5497 + <?php endif; ?>
4868 5498 </div>
4869 5499 <?php
4870 5500 }
4871 5501
@@ -5579,8 +6209,9 @@
5579 6209 'user' => (string) ( $log->user_login ?? '' ),
5580 6210 'ip' => $ip_val,
5581 6211 'user_agent' => $ua_val,
5582 6212 'request_method' => (string) $request_method,
6213 + 'request_uri' => Vigilante_Activity_Log::extract_request_uri( $log->extra_data ?? '' ),
5583 6214 'date' => (string) ( $log->created_at ?? '' ),
5584 6215 'severity' => (string) ( $log->severity ?? 'info' ),
5585 6216 'is_ip_whitelisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_whitelist, true ) ),
5586 6217 'is_ip_blacklisted' => ( '' !== $ip_val && in_array( $ip_val, $ip_blacklist, true ) ),
@@ -5633,8 +6264,13 @@
5633 6264 */
5634 6265 private function render_tab_file_integrity() {
5635 6266 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5636 6267 $options = $this->settings->get_section( 'file_integrity' );
6268 + // On the main site of a network the critical-file scan is the network's
6269 + // canary for a change to wp-config.php or the root .htaccess, so a
6270 + // main-site admin without network rights cannot turn it off. Since
6271 + // 2.11.8; see Vigilante_Settings::get_main_site_file_settings().
6272 + $vg_main_locked = $this->main_site_files_locked();
5637 6273 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5638 6274 $last_results = get_option( 'vigilante_last_integrity_results' );
5639 6275 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5640 6276
@@ -5760,10 +6396,13 @@
5760 6396 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
5761 6397 </label>
5762 6398 <br>
5763 6399 <label>
5764 - <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php checked( $options['scan_critical_config'] ?? true ); ?>>
6400 + <input type="checkbox" name="file_integrity[scan_critical_config]" value="1" <?php disabled( $vg_main_locked ); ?> <?php checked( $options['scan_critical_config'] ?? true ); ?>>
5765 6401 <?php esc_html_e( 'Critical config files (wp-config.php, .htaccess baseline monitoring)', 'vigilante' ); ?>
6402 + <?php if ( $vg_main_locked ) : ?>
6403 + <span class="description" style="display:block;margin-left:24px;"><?php echo esc_html( Vigilante_Settings::get_shared_files_notice() ); ?></span>
6404 + <?php endif; ?>
5766 6405 </label>
5767 6406 <br>
5768 6407 <label>
5769 6408 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6042,9 +6681,15 @@
6042 6681 $crit_diff = $crit_item['diff'] ?? array();
6043 6682 $crit_id = sanitize_html_class( $crit_file );
6044 6683 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6045 6684 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6046 - $diff_unavailable = is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] );
6685 + // The lines of a shared file are for whoever approves it. Results
6686 + // stored before 2.11.8 on the main site still carry them, so the
6687 + // screen asks too, not only the scan that wrote them.
6688 + $diff_network = ( is_array( $crit_diff ) && ! empty( $crit_diff['network'] ) ) || $this->critical_approval_locked();
6689 + $diff_rescan = is_array( $crit_diff ) && ! empty( $crit_diff['rescan'] );
6690 + $diff_redaction = is_array( $crit_diff ) && ! empty( $crit_diff['redaction'] );
6691 + $diff_unavailable = $diff_network || ( is_array( $crit_diff ) && ! empty( $crit_diff['unavailable'] ) );
6047 6692 ?>
6048 6693 <tr>
6049 6694 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6050 6695 <td>
@@ -6067,18 +6712,36 @@
6067 6712 <td>
6068 6713 <button type="button" class="button button-small vigilante-toggle-critical-content" data-target="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" data-label-show="<?php esc_attr_e( 'Review changes', 'vigilante' ); ?>" data-label-hide="<?php esc_attr_e( 'Hide changes', 'vigilante' ); ?>">
6069 6714 <?php esc_html_e( 'Review changes', 'vigilante' ); ?>
6070 6715 </button>
6071 - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6072 - <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6073 - </button>
6716 + <?php if ( $this->critical_approval_locked() ) : ?>
6717 + <span class="description" style="display:block;margin-top:4px;">
6718 + <?php echo esc_html( $this->critical_approval_notice() ); ?>
6719 + </span>
6720 + <?php else : ?>
6721 + <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>">
6722 + <?php esc_html_e( 'Approve', 'vigilante' ); ?>
6723 + </button>
6724 + <?php endif; ?>
6074 6725 </td>
6075 6726 </tr>
6076 6727 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6077 6728 <td colspan="3" style="padding: 0;">
6078 6729 <div class="vigilante-critical-content" style="max-height: 400px; overflow: auto; background: #fff; padding: 10px; font-size: 12px; line-height: 1.5; font-family: Consolas, Monaco, monospace; border-top: 1px solid #c3c4c7;">
6079 - <?php if ( $diff_unavailable ) : ?>
6730 + <?php if ( $diff_network ) : ?>
6080 6731 <p style="color: #50575e; font-style: italic; margin: 0;">
6732 + <?php esc_html_e( 'This file belongs to the whole network, so its line changes are only shown to network administrators, on the main site.', 'vigilante' ); ?>
6733 + </p>
6734 + <?php elseif ( $diff_rescan ) : ?>
6735 + <p style="color: #50575e; font-style: italic; margin: 0;">
6736 + <?php esc_html_e( 'Run a new scan to see the line changes of this file.', 'vigilante' ); ?>
6737 + </p>
6738 + <?php elseif ( $diff_redaction ) : ?>
6739 + <p style="color: #50575e; font-style: italic; margin: 0;">
6740 + <?php esc_html_e( 'The line changes of this file are not shown because a value in it could not be hidden safely. The change itself is still detected.', 'vigilante' ); ?>
6741 + </p>
6742 + <?php elseif ( $diff_unavailable ) : ?>
6743 + <p style="color: #50575e; font-style: italic; margin: 0;">
6081 6744 <?php esc_html_e( 'Diff not available for this file (baseline was created before diff tracking was added). Approve to enable diff on future changes.', 'vigilante' ); ?>
6082 6745 </p>
6083 6746 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6084 6747 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6106,9 +6769,9 @@
6106 6769 <?php endif; ?>
6107 6770
6108 6771 <?php if ( $has_closed ) : ?>
6109 6772 <div class="vigilante-file-list vigilante-closed-plugins">
6110 - <h3 style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6773 + <h3 id="vigilante-section-fi-closed-plugins" style="color: #d63638;"><?php esc_html_e( 'Closed + Removed Plugins', 'vigilante' ); ?></h3>
6111 6774 <p class="description" style="color: #d63638;">
6112 6775 <?php esc_html_e( '&#9888; Warning: These plugins have been closed in the WordPress.org repository. Closures usually indicate malware, security issues, guideline violations, or supply chain attacks. Uninstall and replace as soon as possible.', 'vigilante' ); ?>
6113 6776 </p>
6114 6777 <table class="wp-list-table widefat striped">
@@ -6317,8 +6980,15 @@
6317 6980 if ( ! current_user_can( 'manage_options' ) ) {
6318 6981 wp_die( esc_html__( 'Permission denied.', 'vigilante' ), 403 );
6319 6982 }
6320 6983
6984 + // The archive carries wp-config.php, which a whole network shares. On a
6985 + // network manage_options is held by every subsite administrator, so the
6986 + // same gate the writers use applies here.
6987 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
6988 + wp_die( esc_html( Vigilante_Settings::get_shared_files_notice() ), 403 );
6989 + }
6990 +
6321 6991 $backup_manager = new Vigilante_Backup_Manager();
6322 6992 $result = $backup_manager->stream_files_zip();
6323 6993
6324 6994 // stream_files_zip() exits on success; only a WP_Error returns here.
@@ -6407,10 +7077,30 @@
6407 7077
6408 7078 // Read ONLY saved options from database (not merged with defaults)
6409 7079 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6410 7080
6411 - $rejected_ips = array();
7081 + // What is stored before this request changes anything: the shared file
7082 + // settings this user may not change are put back from here (2.11.6).
7083 + $stored_options = $saved_options;
7084 + $locked = Vigilante_Settings::get_locked_file_settings();
6412 7085
7086 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7087 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7088 + }
7089 +
7090 + // A module switch is a single key, so refusing says more than a success
7091 + // that changed nothing, and the dashboard puts the toggle back.
7092 + if ( 'modules' === $section && isset( $locked['modules'], $data['modules'] ) && is_array( $locked['modules'] ) && is_array( $data['modules'] ) ) {
7093 + foreach ( array_keys( $data['modules'] ) as $vg_module ) {
7094 + if ( in_array( sanitize_key( $vg_module ), $locked['modules'], true ) ) {
7095 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7096 + }
7097 + }
7098 + }
7099 +
7100 + $rejected_ips = array();
7101 + $rejected_proxies = array();
7102 +
6413 7103 // Handle modules
6414 7104 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6415 7105 if ( ! isset( $saved_options['modules'] ) ) {
6416 7106 $saved_options['modules'] = array();
@@ -6434,9 +7124,9 @@
6434 7124 // went straight into the option. An entry the matcher can never
6435 7125 // match still sits in a security list looking like protection,
6436 7126 // so the ones that cannot match are dropped and reported back
6437 7127 // instead of being stored in silence.
6438 - $rejected_ips = $this->filter_ip_lists( $section, $processed );
7128 + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
6439 7129
6440 7130 // Save the processed section
6441 7131 $saved_options[ $section ] = $processed;
6442 7132
@@ -6447,8 +7137,10 @@
6447 7137
6448 7138 // Clear cache before saving
6449 7139 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6450 7140
7141 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
7142 +
6451 7143 // Save to database
6452 7144 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6453 7145
6454 7146 // Clear the settings cache
@@ -6510,8 +7202,21 @@
6510 7202 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6511 7203 );
6512 7204 }
6513 7205
7206 + if ( ! empty( $rejected_proxies ) ) {
7207 + $message .= ' ' . sprintf(
7208 + /* translators: %s: comma separated list of the trusted proxy entries that were not saved. */
7209 + _n(
7210 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so this entry was not saved: %s',
7211 + 'A trusted proxy must be an exact IP or a CIDR range, not a wildcard, so these entries were not saved: %s',
7212 + count( $rejected_proxies ),
7213 + 'vigilante'
7214 + ),
7215 + implode( ', ', array_map( 'esc_html', $rejected_proxies ) )
7216 + );
7217 + }
7218 +
6514 7219 wp_send_json_success( $message );
6515 7220 }
6516 7221
6517 7222 /**
@@ -6522,9 +7227,21 @@
6522 7227 * @param string $section Section being saved.
6523 7228 * @param array $processed Section data, edited in place.
6524 7229 * @return array Entries that were dropped, for the message back to the user.
6525 7230 */
6526 - private function filter_ip_lists( $section, &$processed ) {
7231 + private function filter_ip_lists( $section, &$processed, &$rejected_proxies = array() ) {
7232 + $rejected_proxies = array();
7233 +
7234 + // Trusted proxies feed an identity decision, so only exact addresses and
7235 + // CIDR ranges belong there: a wildcard is stripped with its own message,
7236 + // never stored looking effective. The matcher ignores it anyway (see
7237 + // Vigilante_IP_Utils::in_list_ip_or_cidr), this stops it persisting.
7238 + if ( 'firewall' === $section && isset( $processed['trusted_proxies'] ) && is_array( $processed['trusted_proxies'] ) ) {
7239 + $split = Vigilante_IP_Utils::split_list_ip_or_cidr( $processed['trusted_proxies'] );
7240 + $processed['trusted_proxies'] = $split['valid'];
7241 + $rejected_proxies = $split['rejected'];
7242 + }
7243 +
6527 7244 $lists = array(
6528 7245 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
6529 7246 'login_security' => array( 'ip_whitelist' ),
6530 7247 );
@@ -6871,13 +7588,27 @@
6871 7588
6872 7589 // Sanitize imported data recursively
6873 7590 $imported = map_deep( $imported, 'sanitize_text_field' );
6874 7591
6875 - // Validate structure
6876 - $defaults = $this->settings->get_default_options();
6877 - $merged = array_replace_recursive( $defaults, $imported );
7592 + // Validate structure: only sections and keys of the schema survive, and
7593 + // every value takes the type of its default. Until 2.11.0 this was an
7594 + // array_replace_recursive() of the file over the defaults, so any key in
7595 + // the file, known or not, landed in vigilante_options (S7). Sections
7596 + // the file does not carry keep their defaults; a section it does carry
7597 + // replaces the default one whole, because validate_options() has
7598 + // already filled in whatever the file left out.
7599 + $defaults = $this->settings->get_default_options();
7600 + $validated = $this->settings->validate_options( $imported );
7601 + $merged = $defaults;
6878 7602
7603 + foreach ( $validated as $section => $data ) {
7604 + if ( is_array( $data ) ) {
7605 + $merged[ $section ] = $data;
7606 + }
7607 + }
7608 +
6879 7609 // Save
7610 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
6880 7611 update_option( Vigilante_Settings::OPTION_NAME, $merged );
6881 7612 $this->settings->clear_cache();
6882 7613
6883 7614 // Re-evaluate the active preset marker. The imported config may match
@@ -6900,9 +7631,9 @@
6900 7631 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
6901 7632 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
6902 7633 }
6903 7634
6904 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
7635 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
6905 7636 }
6906 7637
6907 7638 /**
6908 7639 * Detect whether a vigilante_options array matches a known preset.
@@ -7005,9 +7736,11 @@
7005 7736 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7006 7737
7007 7738 // Handle reset to defaults
7008 7739 if ( 'reset' === $preset ) {
7009 - $defaults = Vigilante_Settings::get_defaults_preserving_user_data( get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7740 + $stored_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
7741 + $defaults = Vigilante_Settings::get_defaults_preserving_user_data( $stored_options );
7742 + $defaults = Vigilante_Settings::keep_locked_file_settings( $defaults, $stored_options );
7010 7743 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7011 7744 $this->settings->clear_cache();
7012 7745
7013 7746 // Clear active preset
@@ -7015,9 +7748,9 @@
7015 7748
7016 7749 // Apply file changes after reset
7017 7750 $this->apply_all_file_changes( $defaults );
7018 7751
7019 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
7752 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7020 7753 return;
7021 7754 }
7022 7755
7023 7756 $presets = $this->settings->get_presets();
@@ -7043,8 +7776,9 @@
7043 7776 // invent keys that are missing on both sides.
7044 7777 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7045 7778
7046 7779 $merged = Vigilante_Settings::merge_preset( $current, $preset_options );
7780 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7047 7781
7048 7782 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7049 7783 $this->settings->clear_cache();
7050 7784
@@ -7053,9 +7787,9 @@
7053 7787
7054 7788 // Apply file changes after preset
7055 7789 $this->apply_all_file_changes( $merged );
7056 7790
7057 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
7791 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7058 7792 }
7059 7793
7060 7794 /**
7061 7795 * AJAX: Reset a specific section to defaults
@@ -7090,27 +7824,19 @@
7090 7824 * On a subsite, the settings written to wp-config.php and .htaccess are
7091 7825 * the main site's business. Resetting the local copy of those would only
7092 7826 * make this screen disagree with the file, so they are carried over
7093 7827 * untouched, and a section that is nothing but shared settings is not
7094 - * reset at all.
7828 + * reset at all. On the main site, a user without network rights keeps
7829 + * the ones the shared files are built from as well (2.11.6).
7095 7830 */
7096 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7097 - $shared = Vigilante_Settings::get_shared_file_settings();
7831 + $locked = Vigilante_Settings::get_locked_file_settings();
7098 7832
7099 - if ( isset( $shared[ $section ] ) ) {
7100 - if ( true === $shared[ $section ] ) {
7101 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7102 - }
7103 -
7104 - foreach ( $shared[ $section ] as $shared_key ) {
7105 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7106 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7107 - }
7108 - }
7109 - }
7833 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7834 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7110 7835 }
7111 7836
7112 7837 $current_options[ $section ] = $new_values;
7838 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7113 7839
7114 7840 // Save
7115 7841 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7116 7842 $this->settings->clear_cache();
@@ -7193,8 +7919,19 @@
7193 7919 // Save new results
7194 7920 update_option( 'vigilante_last_integrity_scan', time() );
7195 7921 update_option( 'vigilante_last_integrity_results', $results );
7196 7922
7923 + // On the main site the scan does compute the lines of wp-config.php and
7924 + // .htaccess, for the network administrator. Somebody without network
7925 + // rights gets the change and its sizes, not the lines.
7926 + if ( $this->critical_approval_locked() && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7927 + foreach ( $results['modified'] as $index => $item ) {
7928 + if ( is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
7929 + $results['modified'][ $index ]['diff'] = Vigilante_File_Integrity::network_only_diff();
7930 + }
7931 + }
7932 + }
7933 +
7197 7934 wp_send_json_success( array(
7198 7935 'message' => __( 'Scan completed.', 'vigilante' ),
7199 7936 'results' => $results,
7200 7937 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7228,11 +7965,41 @@
7228 7965 if ( ! current_user_can( 'manage_options' ) ) {
7229 7966 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7230 7967 }
7231 7968
7969 + $results = get_option( 'vigilante_last_integrity_results' );
7970 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
7971 +
7232 7972 delete_option( 'vigilante_last_integrity_results' );
7233 7973 delete_option( 'vigilante_last_integrity_scan' );
7234 7974
7975 + /*
7976 + * A pending change to wp-config.php or the root .htaccess is closed by
7977 + * approving it, which takes the network. Clearing the results was one
7978 + * more way to close it without, until the next scan: the ignore list was
7979 + * shut in 2.11.8 and this button was left open, found by the cross
7980 + * review of 2.11.8. So for somebody who cannot approve, those entries
7981 + * stay and everything else goes.
7982 + */
7983 + if ( $this->critical_approval_locked() && is_array( $results ) && ! empty( $results['modified'] ) && is_array( $results['modified'] ) ) {
7984 + $critical = array_values(
7985 + array_filter(
7986 + $results['modified'],
7987 + function ( $item ) {
7988 + return is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' );
7989 + }
7990 + )
7991 + );
7992 +
7993 + if ( $critical ) {
7994 + $results['modified'] = $critical;
7995 + $results['suspicious'] = array();
7996 + $results['extra'] = array();
7997 + update_option( 'vigilante_last_integrity_results', $results );
7998 + update_option( 'vigilante_last_integrity_scan', $scanned_at ? $scanned_at : time() );
7999 + }
8000 + }
8001 +
7235 8002 if ( $this->database ) {
7236 8003 $this->database->clear_file_hashes();
7237 8004 }
7238 8005
@@ -7258,8 +8025,14 @@
7258 8025 if ( empty( $file ) ) {
7259 8026 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7260 8027 }
7261 8028
8029 + // A change to a shared file is closed by approving it, and approving it
8030 + // takes the network. Ignoring it would close the same warning without.
8031 + if ( $this->critical_approval_locked() && in_array( $file, array( 'wp-config.php', '.htaccess' ), true ) ) {
8032 + wp_send_json_error( $this->critical_approval_notice() );
8033 + }
8034 +
7262 8035 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7263 8036 $file_integrity->ignore_file( $file );
7264 8037
7265 8038 // Also remove the file from stored scan results so UI updates
@@ -7323,12 +8096,14 @@
7323 8096 if ( ! is_array( $raw_files ) ) {
7324 8097 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7325 8098 }
7326 8099
7327 - $files = array();
8100 + $files = array();
8101 + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
7328 8102 foreach ( $raw_files as $f ) {
7329 8103 $clean = sanitize_text_field( $f );
7330 - if ( '' !== $clean ) {
8104 + // Same rule as ajax_ignore_file() for the two shared files.
8105 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
7331 8106 $files[] = $clean;
7332 8107 }
7333 8108 }
7334 8109