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 +483 -47 2.11.52.11.12 View file →
@@ -199,8 +199,26 @@
199 199 /**
200 200 * Run database migrations based on stored version
201 201 */
202 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 +
203 221 $db_version = get_option( 'vigilante_db_version', '0' );
204 222
205 223 // 1.2.3: Fix IP lists corrupted by sanitize_text_field stripping newlines
206 224 if ( version_compare( $db_version, '1.2.3', '<' ) ) {
@@ -464,11 +482,153 @@
464 482 $this->database->purge_for_2_11_0();
465 483
466 484 update_option( 'vigilante_db_version', '2.11.0' );
467 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 + }
468 540 }
469 541
470 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 + /**
471 631 * Migration: Remove orphaned email fields from saved options
472 632 *
473 633 * v1.10.0 centralized notification recipients into email section.
474 634 * Old per-module notify_email fields and dead email section fields
@@ -736,23 +896,32 @@
736 896 if ( ! did_action( 'plugins_loaded' ) ) {
737 897 return 0;
738 898 }
739 899
740 - $registration_approval = $this->settings->get_section( 'user_security' );
741 - $approval_settings = $registration_approval['registration_approval'] ?? array();
742 -
743 - if ( empty( $approval_settings['enabled'] ) ) {
744 - return 0;
745 - }
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 + */
746 906
747 907 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
748 - $pending_users = get_users( array(
749 - 'meta_key' => 'vigilante_pending_approval',
908 + $args = array(
909 + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ),
750 910 'meta_value' => '1',
751 911 'fields' => 'ID',
752 - ) );
912 + );
753 913 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
754 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 +
755 924 return count( $pending_users );
756 925 }
757 926
758 927 /**
@@ -1545,8 +1714,11 @@
1545 1714 'reviewChanges' => __( 'Review changes', 'vigilante' ),
1546 1715 'hideChanges' => __( 'Hide changes', 'vigilante' ),
1547 1716 'changes' => __( 'Changes', 'vigilante' ),
1548 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' ),
1549 1721 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ),
1550 1722 'diffLines' => __( 'lines', 'vigilante' ),
1551 1723 // Under Attack mode strings
1552 1724 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ),
@@ -2055,8 +2227,41 @@
2055 2227 return ! Vigilante_Settings::can_write_shared_files();
2056 2228 }
2057 2229
2058 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 + /**
2059 2264 * Print the shared-files notice for a section that cannot be edited here
2060 2265 *
2061 2266 * @since 2.9.8
2062 2267 */
@@ -2082,10 +2287,46 @@
2082 2287 *
2083 2288 * @since 2.10.4
2084 2289 * @return bool
2085 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 + */
2086 2324 private function user_actions_locked() {
2087 - return is_multisite() && ! current_user_can( 'manage_network_users' );
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' );
2088 2329 }
2089 2330
2090 2331 /**
2091 2332 * Print the notice for user tools that cannot be used from this site
@@ -2097,9 +2338,13 @@
2097 2338 return;
2098 2339 }
2099 2340 ?>
2100 2341 <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;">
2342 + <?php if ( is_multisite() ) : ?>
2101 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; ?>
2102 2347 </div>
2103 2348 <?php
2104 2349 }
2105 2350
@@ -2683,8 +2928,9 @@
2683 2928 <div class="vigilante-modules-list">
2684 2929 <?php foreach ( $options['modules'] as $module => $enabled ) :
2685 2930 $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) );
2686 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 );
2687 2933 ?>
2688 2934 <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>">
2689 2935 <div class="vigilante-module-header">
2690 2936 <span class="vigilante-module-status"></span>
@@ -2697,8 +2943,9 @@
2697 2943 <input type="checkbox"
2698 2944 name="modules[<?php echo esc_attr( $module ); ?>]"
2699 2945 value="1"
2700 2946 <?php checked( $enabled ); ?>
2947 + <?php disabled( $vg_module_locked ); ?>
2701 2948 aria-label="<?php echo esc_attr( $toggle_label ); ?>"
2702 2949 data-module="<?php echo esc_attr( $module ); ?>">
2703 2950 <span class="vigilante-toggle-slider"></span>
2704 2951 </label>
@@ -2705,8 +2952,11 @@
2705 2952 </div>
2706 2953 <?php if ( $description ) : ?>
2707 2954 <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p>
2708 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; ?>
2709 2959 </div>
2710 2960 <?php endforeach; ?>
2711 2961 </div>
2712 2962 </div>
@@ -3096,14 +3346,21 @@
3096 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' ); ?>
3097 3347 </p>
3098 3348 </div>
3099 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 +
3100 3357 <table class="form-table">
3101 3358 <tr>
3102 3359 <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th>
3103 3360 <td>
3104 3361 <label>
3105 - <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'] ) ); ?>>
3106 3363 <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?>
3107 3364 </label>
3108 3365 </td>
3109 3366 </tr>
@@ -3146,9 +3403,9 @@
3146 3403 <tr>
3147 3404 <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th>
3148 3405 <td>
3149 3406 <label>
3150 - <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'] ) ); ?>>
3151 3408 <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?>
3152 3409 </label>
3153 3410 </td>
3154 3411 </tr>
@@ -3262,8 +3519,29 @@
3262 3519 </table>
3263 3520 </div>
3264 3521 <?php endif; ?>
3265 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 +
3266 3544 <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3>
3267 3545 <p class="description">
3268 3546 <?php
3269 3547 printf(
@@ -3277,9 +3555,9 @@
3277 3555 <tr>
3278 3556 <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th>
3279 3557 <td>
3280 3558 <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?>
3281 - <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 ); ?>>
3282 3560 <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option>
3283 3561 <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option>
3284 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>
3285 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>
@@ -3289,11 +3567,23 @@
3289 3567 </p>
3290 3568 </td>
3291 3569 </tr>
3292 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>
3293 3583 <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th>
3294 3584 <td>
3295 - <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>
3296 3586 <p class="description">
3297 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' ); ?>
3298 3588 <br>
3299 3589 <?php
@@ -3332,9 +3622,9 @@
3332 3622 <table class="form-table">
3333 3623 <tr>
3334 3624 <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th>
3335 3625 <td>
3336 - <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>
3337 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>
3338 3628 </td>
3339 3629 </tr>
3340 3630 <tr>
@@ -4043,9 +4333,9 @@
4043 4333 ?>
4044 4334 <div class="vigilante-settings-section" id="vigilante-headers-recovery">
4045 4335 <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2>
4046 4336 <p>
4047 - <?php esc_html_e( 'Updating to 2.9.8 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' ); ?>
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' ); ?>
4048 4338 </p>
4049 4339 <?php if ( $taken ) : ?>
4050 4340 <p class="description">
4051 4341 <?php
@@ -4654,8 +4944,16 @@
4654 4944 <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span>
4655 4945 </h2>
4656 4946 <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p>
4657 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 +
4658 4956 <table class="form-table">
4659 4957 <tr>
4660 4958 <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th>
4661 4959 <td>
@@ -4995,9 +5293,17 @@
4995 5293 </div>
4996 5294
4997 5295 <!-- Pending Registrations -->
4998 5296 <?php
4999 - $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 );
5000 5306 $pending_users = $user_security->get_pending_users();
5001 5307 ?>
5002 5308 <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section">
5003 5309 <h3>
@@ -5006,9 +5312,20 @@
5006 5312 <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span>
5007 5313 <?php endif; ?>
5008 5314 </h3>
5009 5315
5010 - <?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 ) ) : ?>
5011 5328 <p class="description">
5012 5329 <span class="dashicons dashicons-info" style="color: #72aee6;"></span>
5013 5330 <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?>
5014 5331 </p>
@@ -5017,8 +5334,9 @@
5017 5334 <span class="dashicons dashicons-yes-alt"></span>
5018 5335 <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p>
5019 5336 </div>
5020 5337 <?php else : ?>
5338 + <?php $this->render_user_actions_notice(); ?>
5021 5339 <table class="wp-list-table widefat fixed striped vigilante-pending-users-table">
5022 5340 <thead>
5023 5341 <tr>
5024 5342 <th><?php esc_html_e( 'User', 'vigilante' ); ?></th>
@@ -5028,9 +5346,9 @@
5028 5346 </tr>
5029 5347 </thead>
5030 5348 <tbody>
5031 5349 <?php foreach ( $pending_users as $pending_user ) :
5032 - $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 );
5033 5351 ?>
5034 5352 <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>">
5035 5353 <td>
5036 5354 <?php echo get_avatar( $pending_user->ID, 32 ); ?>
@@ -5047,12 +5365,12 @@
5047 5365 }
5048 5366 ?>
5049 5367 </td>
5050 5368 <td>
5051 - <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() ); ?>>
5052 5370 <?php esc_html_e( 'Approve', 'vigilante' ); ?>
5053 5371 </button>
5054 - <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() ); ?>>
5055 5373 <?php esc_html_e( 'Reject', 'vigilante' ); ?>
5056 5374 </button>
5057 5375 </td>
5058 5376 </tr>
@@ -5946,8 +6264,13 @@
5946 6264 */
5947 6265 private function render_tab_file_integrity() {
5948 6266 $is_disabled = $this->render_module_disabled_notice( 'file_integrity' );
5949 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();
5950 6273 $last_scan = get_option( 'vigilante_last_integrity_scan' );
5951 6274 $last_results = get_option( 'vigilante_last_integrity_results' );
5952 6275 $ignored_files = get_option( 'vigilante_ignored_files', array() );
5953 6276
@@ -6073,10 +6396,13 @@
6073 6396 <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?>
6074 6397 </label>
6075 6398 <br>
6076 6399 <label>
6077 - <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 ); ?>>
6078 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; ?>
6079 6405 </label>
6080 6406 <br>
6081 6407 <label>
6082 6408 <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>>
@@ -6355,9 +6681,15 @@
6355 6681 $crit_diff = $crit_item['diff'] ?? array();
6356 6682 $crit_id = sanitize_html_class( $crit_file );
6357 6683 $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0;
6358 6684 $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0;
6359 - $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'] ) );
6360 6692 ?>
6361 6693 <tr>
6362 6694 <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td>
6363 6695 <td>
@@ -6394,10 +6726,22 @@
6394 6726 </tr>
6395 6727 <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;">
6396 6728 <td colspan="3" style="padding: 0;">
6397 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;">
6398 - <?php if ( $diff_unavailable ) : ?>
6730 + <?php if ( $diff_network ) : ?>
6399 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;">
6400 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' ); ?>
6401 6745 </p>
6402 6746 <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?>
6403 6747 <p style="color: #50575e; font-style: italic; margin: 0;">
@@ -6733,10 +7077,30 @@
6733 7077
6734 7078 // Read ONLY saved options from database (not merged with defaults)
6735 7079 $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() );
6736 7080
6737 - $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();
6738 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 +
6739 7103 // Handle modules
6740 7104 if ( 'modules' === $section && isset( $data['modules'] ) ) {
6741 7105 if ( ! isset( $saved_options['modules'] ) ) {
6742 7106 $saved_options['modules'] = array();
@@ -6760,9 +7124,9 @@
6760 7124 // went straight into the option. An entry the matcher can never
6761 7125 // match still sits in a security list looking like protection,
6762 7126 // so the ones that cannot match are dropped and reported back
6763 7127 // instead of being stored in silence.
6764 - $rejected_ips = $this->filter_ip_lists( $section, $processed );
7128 + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies );
6765 7129
6766 7130 // Save the processed section
6767 7131 $saved_options[ $section ] = $processed;
6768 7132
@@ -6773,8 +7137,10 @@
6773 7137
6774 7138 // Clear cache before saving
6775 7139 wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' );
6776 7140
7141 + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options );
7142 +
6777 7143 // Save to database
6778 7144 update_option( Vigilante_Settings::OPTION_NAME, $saved_options );
6779 7145
6780 7146 // Clear the settings cache
@@ -6836,8 +7202,21 @@
6836 7202 implode( ', ', array_map( 'esc_html', $rejected_ips ) )
6837 7203 );
6838 7204 }
6839 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 +
6840 7219 wp_send_json_success( $message );
6841 7220 }
6842 7221
6843 7222 /**
@@ -6848,9 +7227,21 @@
6848 7227 * @param string $section Section being saved.
6849 7228 * @param array $processed Section data, edited in place.
6850 7229 * @return array Entries that were dropped, for the message back to the user.
6851 7230 */
6852 - 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 +
6853 7244 $lists = array(
6854 7245 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ),
6855 7246 'login_security' => array( 'ip_whitelist' ),
6856 7247 );
@@ -7215,8 +7606,9 @@
7215 7606 }
7216 7607 }
7217 7608
7218 7609 // Save
7610 + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7219 7611 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7220 7612 $this->settings->clear_cache();
7221 7613
7222 7614 // Re-evaluate the active preset marker. The imported config may match
@@ -7239,9 +7631,9 @@
7239 7631 if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) {
7240 7632 wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' );
7241 7633 }
7242 7634
7243 - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) );
7635 + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7244 7636 }
7245 7637
7246 7638 /**
7247 7639 * Detect whether a vigilante_options array matches a known preset.
@@ -7344,9 +7736,11 @@
7344 7736 $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : '';
7345 7737
7346 7738 // Handle reset to defaults
7347 7739 if ( 'reset' === $preset ) {
7348 - $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 );
7349 7743 update_option( Vigilante_Settings::OPTION_NAME, $defaults );
7350 7744 $this->settings->clear_cache();
7351 7745
7352 7746 // Clear active preset
@@ -7354,9 +7748,9 @@
7354 7748
7355 7749 // Apply file changes after reset
7356 7750 $this->apply_all_file_changes( $defaults );
7357 7751
7358 - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) );
7752 + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() );
7359 7753 return;
7360 7754 }
7361 7755
7362 7756 $presets = $this->settings->get_presets();
@@ -7382,8 +7776,9 @@
7382 7776 // invent keys that are missing on both sides.
7383 7777 $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current );
7384 7778
7385 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() ) );
7386 7781
7387 7782 update_option( Vigilante_Settings::OPTION_NAME, $merged );
7388 7783 $this->settings->clear_cache();
7389 7784
@@ -7392,9 +7787,9 @@
7392 7787
7393 7788 // Apply file changes after preset
7394 7789 $this->apply_all_file_changes( $merged );
7395 7790
7396 - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) );
7791 + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() );
7397 7792 }
7398 7793
7399 7794 /**
7400 7795 * AJAX: Reset a specific section to defaults
@@ -7429,27 +7824,19 @@
7429 7824 * On a subsite, the settings written to wp-config.php and .htaccess are
7430 7825 * the main site's business. Resetting the local copy of those would only
7431 7826 * make this screen disagree with the file, so they are carried over
7432 7827 * untouched, and a section that is nothing but shared settings is not
7433 - * 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).
7434 7830 */
7435 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
7436 - $shared = Vigilante_Settings::get_shared_file_settings();
7831 + $locked = Vigilante_Settings::get_locked_file_settings();
7437 7832
7438 - if ( isset( $shared[ $section ] ) ) {
7439 - if ( true === $shared[ $section ] ) {
7440 - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7441 - }
7442 -
7443 - foreach ( $shared[ $section ] as $shared_key ) {
7444 - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) {
7445 - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ];
7446 - }
7447 - }
7448 - }
7833 + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) {
7834 + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() );
7449 7835 }
7450 7836
7451 7837 $current_options[ $section ] = $new_values;
7838 + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) );
7452 7839
7453 7840 // Save
7454 7841 update_option( Vigilante_Settings::OPTION_NAME, $current_options );
7455 7842 $this->settings->clear_cache();
@@ -7532,8 +7919,19 @@
7532 7919 // Save new results
7533 7920 update_option( 'vigilante_last_integrity_scan', time() );
7534 7921 update_option( 'vigilante_last_integrity_results', $results );
7535 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 +
7536 7934 wp_send_json_success( array(
7537 7935 'message' => __( 'Scan completed.', 'vigilante' ),
7538 7936 'results' => $results,
7539 7937 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ),
@@ -7567,11 +7965,41 @@
7567 7965 if ( ! current_user_can( 'manage_options' ) ) {
7568 7966 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
7569 7967 }
7570 7968
7969 + $results = get_option( 'vigilante_last_integrity_results' );
7970 + $scanned_at = get_option( 'vigilante_last_integrity_scan' );
7971 +
7571 7972 delete_option( 'vigilante_last_integrity_results' );
7572 7973 delete_option( 'vigilante_last_integrity_scan' );
7573 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 +
7574 8002 if ( $this->database ) {
7575 8003 $this->database->clear_file_hashes();
7576 8004 }
7577 8005
@@ -7597,8 +8025,14 @@
7597 8025 if ( empty( $file ) ) {
7598 8026 wp_send_json_error( __( 'No file specified.', 'vigilante' ) );
7599 8027 }
7600 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 +
7601 8035 $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database );
7602 8036 $file_integrity->ignore_file( $file );
7603 8037
7604 8038 // Also remove the file from stored scan results so UI updates
@@ -7662,12 +8096,14 @@
7662 8096 if ( ! is_array( $raw_files ) ) {
7663 8097 wp_send_json_error( __( 'Invalid request.', 'vigilante' ) );
7664 8098 }
7665 8099
7666 - $files = array();
8100 + $files = array();
8101 + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array();
7667 8102 foreach ( $raw_files as $f ) {
7668 8103 $clean = sanitize_text_field( $f );
7669 - if ( '' !== $clean ) {
8104 + // Same rule as ajax_ignore_file() for the two shared files.
8105 + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) {
7670 8106 $files[] = $clean;
7671 8107 }
7672 8108 }
7673 8109