| @@ -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', '<' ) ) { |
| @@ -504,11 +522,113 @@ | ||
| 504 | 522 | } |
| 505 | 523 | |
| 506 | 524 | update_option( 'vigilante_db_version', '2.11.9' ); |
| 507 | 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 | + } | |
| 508 | 540 | } |
| 509 | 541 | |
| 510 | 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 | + /** | |
| 511 | 631 | * Migration: Remove orphaned email fields from saved options |
| 512 | 632 | * |
| 513 | 633 | * v1.10.0 centralized notification recipients into email section. |
| 514 | 634 | * Old per-module notify_email fields and dead email section fields |
| @@ -776,23 +896,32 @@ | ||
| 776 | 896 | if ( ! did_action( 'plugins_loaded' ) ) { |
| 777 | 897 | return 0; |
| 778 | 898 | } |
| 779 | 899 | |
| 780 | - $registration_approval = $this->settings->get_section( 'user_security' ); | |
| 781 | - $approval_settings = $registration_approval['registration_approval'] ?? array(); | |
| 782 | - | |
| 783 | - if ( empty( $approval_settings['enabled'] ) ) { | |
| 784 | - return 0; | |
| 785 | - } | |
| 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 | + */ | |
| 786 | 906 | |
| 787 | 907 | // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context. |
| 788 | - $pending_users = get_users( array( | |
| 789 | - 'meta_key' => 'vigilante_pending_approval', | |
| 908 | + $args = array( | |
| 909 | + 'meta_key' => Vigilante_User_Security::site_user_meta_key( 'vigilante_pending_approval' ), | |
| 790 | 910 | 'meta_value' => '1', |
| 791 | 911 | 'fields' => 'ID', |
| 792 | - ) ); | |
| 912 | + ); | |
| 793 | 913 | // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value |
| 794 | 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 | + | |
| 795 | 924 | return count( $pending_users ); |
| 796 | 925 | } |
| 797 | 926 | |
| 798 | 927 | /** |
| @@ -3401,10 +3530,10 @@ | ||
| 3401 | 3530 | <div id="vigilante-xff-chain-notice" class="notice notice-warning inline" style="margin:10px 0 16px;padding:8px 12px;"> |
| 3402 | 3531 | <p style="margin:0;"> |
| 3403 | 3532 | <?php |
| 3404 | 3533 | printf( |
| 3405 | - /* translators: 1: address Vigilant reads now, 2: address earlier versions read */ | |
| 3406 | - 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; up to version 2.11.7 it read 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' ), | |
| 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' ), | |
| 3407 | 3536 | esc_html( $xff_readings['now'] ), |
| 3408 | 3537 | esc_html( $xff_readings['before'] ) |
| 3409 | 3538 | ); |
| 3410 | 3539 | ?> |
| @@ -3440,9 +3569,9 @@ | ||
| 3440 | 3569 | </tr> |
| 3441 | 3570 | <tr> |
| 3442 | 3571 | <th scope="row"><label for="vigilante-f-firewall-trusted-proxies"><?php esc_html_e( 'Trusted proxy IPs', 'vigilante' ); ?></label></th> |
| 3443 | 3572 | <td> |
| 3444 | - <textarea id="vigilante-f-firewall-trusted-proxies" name="firewall[trusted_proxies]" rows="3" class="large-text code" placeholder="10.0.0.0/8 192.168.1.1"><?php echo esc_textarea( implode( "\n", $options['trusted_proxies'] ?? array() ) ); ?></textarea> | |
| 3573 | + <textarea id="vigilante-f-firewall-trusted-proxies" name="firewall[trusted_proxies]" rows="3" class="large-text code" placeholder="10.0.0.0/8 192.168.1.1" <?php disabled( $vg_main_locked ); ?>><?php echo esc_textarea( implode( "\n", $options['trusted_proxies'] ?? array() ) ); ?></textarea> | |
| 3445 | 3574 | <p class="description"> |
| 3446 | 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' ); ?> |
| 3447 | 3576 | <?php if ( in_array( $proxy_header, array( 'x-forwarded-for', 'x-real-ip' ), true ) && empty( $options['trusted_proxies'] ) ) : ?> |
| 3448 | 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> |
| @@ -4204,9 +4333,9 @@ | ||
| 4204 | 4333 | ?> |
| 4205 | 4334 | <div class="vigilante-settings-section" id="vigilante-headers-recovery"> |
| 4206 | 4335 | <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2> |
| 4207 | 4336 | <p> |
| 4208 | - <?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' ); ?> | |
| 4209 | 4338 | </p> |
| 4210 | 4339 | <?php if ( $taken ) : ?> |
| 4211 | 4340 | <p class="description"> |
| 4212 | 4341 | <?php |
| @@ -4815,8 +4944,16 @@ | ||
| 4815 | 4944 | <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span> |
| 4816 | 4945 | </h2> |
| 4817 | 4946 | <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p> |
| 4818 | 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 | + | |
| 4819 | 4956 | <table class="form-table"> |
| 4820 | 4957 | <tr> |
| 4821 | 4958 | <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th> |
| 4822 | 4959 | <td> |
| @@ -5156,9 +5293,17 @@ | ||
| 5156 | 5293 | </div> |
| 5157 | 5294 | |
| 5158 | 5295 | <!-- Pending Registrations --> |
| 5159 | 5296 | <?php |
| 5160 | - $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 ); | |
| 5161 | 5306 | $pending_users = $user_security->get_pending_users(); |
| 5162 | 5307 | ?> |
| 5163 | 5308 | <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section"> |
| 5164 | 5309 | <h3> |
| @@ -5167,9 +5312,20 @@ | ||
| 5167 | 5312 | <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span> |
| 5168 | 5313 | <?php endif; ?> |
| 5169 | 5314 | </h3> |
| 5170 | 5315 | |
| 5171 | - <?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 ) ) : ?> | |
| 5172 | 5328 | <p class="description"> |
| 5173 | 5329 | <span class="dashicons dashicons-info" style="color: #72aee6;"></span> |
| 5174 | 5330 | <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?> |
| 5175 | 5331 | </p> |
| @@ -5190,9 +5346,9 @@ | ||
| 5190 | 5346 | </tr> |
| 5191 | 5347 | </thead> |
| 5192 | 5348 | <tbody> |
| 5193 | 5349 | <?php foreach ( $pending_users as $pending_user ) : |
| 5194 | - $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 ); | |
| 5195 | 5351 | ?> |
| 5196 | 5352 | <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>"> |
| 5197 | 5353 | <td> |
| 5198 | 5354 | <?php echo get_avatar( $pending_user->ID, 32 ); ?> |