| @@ -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 | /** |
| @@ -1468,8 +1637,11 @@ | ||
| 1468 | 1637 | 'currentUserId' => get_current_user_id(), |
| 1469 | 1638 | 'logoutUrl' => wp_logout_url( wp_login_url() ), |
| 1470 | 1639 | 'adminUrl' => admin_url( 'admin.php?page=vigilante' ), |
| 1471 | 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(), | |
| 1472 | 1644 | 'underAttack' => array( |
| 1473 | 1645 | 'active' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->is_active(), |
| 1474 | 1646 | 'remaining' => ( new Vigilante_Under_Attack( $this->settings, $this->activity_log ) )->get_remaining_time(), |
| 1475 | 1647 | ), |
| @@ -1536,13 +1708,17 @@ | ||
| 1536 | 1708 | 'criticalConfigTitle' => __( 'Critical config files modified', 'vigilante' ), |
| 1537 | 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' ), |
| 1538 | 1710 | 'approve' => __( 'Approve', 'vigilante' ), |
| 1539 | 1711 | 'approving' => __( 'Approving...', 'vigilante' ), |
| 1712 | + 'approvalLockedNotice' => $this->critical_approval_notice(), | |
| 1540 | 1713 | 'criticalApproved' => __( 'Change approved. Next scan will use the current state as baseline.', 'vigilante' ), |
| 1541 | 1714 | 'reviewChanges' => __( 'Review changes', 'vigilante' ), |
| 1542 | 1715 | 'hideChanges' => __( 'Hide changes', 'vigilante' ), |
| 1543 | 1716 | 'changes' => __( 'Changes', 'vigilante' ), |
| 1544 | 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' ), | |
| 1545 | 1721 | 'diffEmpty' => __( 'No line-level changes detected (may be whitespace or reordering).', 'vigilante' ), |
| 1546 | 1722 | 'diffLines' => __( 'lines', 'vigilante' ), |
| 1547 | 1723 | // Under Attack mode strings |
| 1548 | 1724 | 'underAttackConfirmActivate' => __( 'Activate Under Attack mode? All visitors will see a verification page for the next 4 hours.', 'vigilante' ), |
| @@ -2051,8 +2227,41 @@ | ||
| 2051 | 2227 | return ! Vigilante_Settings::can_write_shared_files(); |
| 2052 | 2228 | } |
| 2053 | 2229 | |
| 2054 | 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 | + /** | |
| 2055 | 2264 | * Print the shared-files notice for a section that cannot be edited here |
| 2056 | 2265 | * |
| 2057 | 2266 | * @since 2.9.8 |
| 2058 | 2267 | */ |
| @@ -2078,10 +2287,46 @@ | ||
| 2078 | 2287 | * |
| 2079 | 2288 | * @since 2.10.4 |
| 2080 | 2289 | * @return bool |
| 2081 | 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 | + */ | |
| 2082 | 2324 | private function user_actions_locked() { |
| 2083 | - 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' ); | |
| 2084 | 2329 | } |
| 2085 | 2330 | |
| 2086 | 2331 | /** |
| 2087 | 2332 | * Print the notice for user tools that cannot be used from this site |
| @@ -2093,14 +2338,47 @@ | ||
| 2093 | 2338 | return; |
| 2094 | 2339 | } |
| 2095 | 2340 | ?> |
| 2096 | 2341 | <div class="notice notice-info inline" style="margin:10px 0 16px;padding:8px 12px;"> |
| 2342 | + <?php if ( is_multisite() ) : ?> | |
| 2097 | 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; ?> | |
| 2098 | 2347 | </div> |
| 2099 | 2348 | <?php |
| 2100 | 2349 | } |
| 2101 | 2350 | |
| 2102 | 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 | + /** | |
| 2103 | 2381 | * Check if module is disabled and render warning |
| 2104 | 2382 | * |
| 2105 | 2383 | * @param string $module_key Module key. |
| 2106 | 2384 | * @return bool True if disabled. |
| @@ -2650,8 +2928,9 @@ | ||
| 2650 | 2928 | <div class="vigilante-modules-list"> |
| 2651 | 2929 | <?php foreach ( $options['modules'] as $module => $enabled ) : |
| 2652 | 2930 | $label = isset( $module_labels[ $module ] ) ? $module_labels[ $module ] : ucwords( str_replace( '_', ' ', $module ) ); |
| 2653 | 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 ); | |
| 2654 | 2933 | ?> |
| 2655 | 2934 | <div class="vigilante-module-item <?php echo $enabled ? 'enabled' : 'disabled'; ?>"> |
| 2656 | 2935 | <div class="vigilante-module-header"> |
| 2657 | 2936 | <span class="vigilante-module-status"></span> |
| @@ -2664,8 +2943,9 @@ | ||
| 2664 | 2943 | <input type="checkbox" |
| 2665 | 2944 | name="modules[<?php echo esc_attr( $module ); ?>]" |
| 2666 | 2945 | value="1" |
| 2667 | 2946 | <?php checked( $enabled ); ?> |
| 2947 | + <?php disabled( $vg_module_locked ); ?> | |
| 2668 | 2948 | aria-label="<?php echo esc_attr( $toggle_label ); ?>" |
| 2669 | 2949 | data-module="<?php echo esc_attr( $module ); ?>"> |
| 2670 | 2950 | <span class="vigilante-toggle-slider"></span> |
| 2671 | 2951 | </label> |
| @@ -2672,8 +2952,11 @@ | ||
| 2672 | 2952 | </div> |
| 2673 | 2953 | <?php if ( $description ) : ?> |
| 2674 | 2954 | <p class="vigilante-module-desc"><?php echo esc_html( $description ); ?></p> |
| 2675 | 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; ?> | |
| 2676 | 2959 | </div> |
| 2677 | 2960 | <?php endforeach; ?> |
| 2678 | 2961 | </div> |
| 2679 | 2962 | </div> |
| @@ -3063,14 +3346,21 @@ | ||
| 3063 | 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' ); ?> |
| 3064 | 3347 | </p> |
| 3065 | 3348 | </div> |
| 3066 | 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 | + | |
| 3067 | 3357 | <table class="form-table"> |
| 3068 | 3358 | <tr> |
| 3069 | 3359 | <th scope="row"><?php esc_html_e( 'Block Bad Query Strings', 'vigilante' ); ?></th> |
| 3070 | 3360 | <td> |
| 3071 | 3361 | <label> |
| 3072 | - <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'] ) ); ?>> | |
| 3073 | 3363 | <?php esc_html_e( 'Block malicious query string patterns', 'vigilante' ); ?> |
| 3074 | 3364 | </label> |
| 3075 | 3365 | </td> |
| 3076 | 3366 | </tr> |
| @@ -3113,9 +3403,9 @@ | ||
| 3113 | 3403 | <tr> |
| 3114 | 3404 | <th scope="row"><?php esc_html_e( 'Block Bad Bots', 'vigilante' ); ?></th> |
| 3115 | 3405 | <td> |
| 3116 | 3406 | <label> |
| 3117 | - <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'] ) ); ?>> | |
| 3118 | 3408 | <?php esc_html_e( 'Block known malicious bots and scanners', 'vigilante' ); ?> |
| 3119 | 3409 | </label> |
| 3120 | 3410 | </td> |
| 3121 | 3411 | </tr> |
| @@ -3229,8 +3519,29 @@ | ||
| 3229 | 3519 | </table> |
| 3230 | 3520 | </div> |
| 3231 | 3521 | <?php endif; ?> |
| 3232 | 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 | + | |
| 3233 | 3544 | <h3><?php esc_html_e( 'IP Lists', 'vigilante' ); ?></h3> |
| 3234 | 3545 | <p class="description"> |
| 3235 | 3546 | <?php |
| 3236 | 3547 | printf( |
| @@ -3244,9 +3555,9 @@ | ||
| 3244 | 3555 | <tr> |
| 3245 | 3556 | <th scope="row"><label for="vigilante-f-firewall-trusted-proxy-header"><?php esc_html_e( 'Visitor IP detection', 'vigilante' ); ?></label></th> |
| 3246 | 3557 | <td> |
| 3247 | 3558 | <?php $proxy_header = $options['trusted_proxy_header'] ?? ''; ?> |
| 3248 | - <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 ); ?>> | |
| 3249 | 3560 | <option value="" <?php selected( $proxy_header, '' ); ?>><?php esc_html_e( 'Direct connection, only REMOTE_ADDR (recommended)', 'vigilante' ); ?></option> |
| 3250 | 3561 | <option value="cf-connecting-ip" <?php selected( $proxy_header, 'cf-connecting-ip' ); ?>><?php esc_html_e( 'Behind Cloudflare (CF-Connecting-IP)', 'vigilante' ); ?></option> |
| 3251 | 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> |
| 3252 | 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> |
| @@ -3256,11 +3567,23 @@ | ||
| 3256 | 3567 | </p> |
| 3257 | 3568 | </td> |
| 3258 | 3569 | </tr> |
| 3259 | 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 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> | |
| 3260 | 3583 | <th scope="row"><label for="vigilante-f-firewall-ip-whitelist"><?php esc_html_e( 'IP Whitelist', 'vigilante' ); ?></label></th> |
| 3261 | 3584 | <td> |
| 3262 | - <textarea id="vigilante-f-firewall-ip-whitelist" name="firewall[ip_whitelist]" rows="4" class="large-text code" placeholder="192.168.1.50 192.168.1.0/24 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 192.168.1.0/24 192.168.1.*"><?php echo esc_textarea( implode( "\n", $options['ip_whitelist'] ?? array() ) ); ?></textarea> | |
| 3263 | 3586 | <p class="description"> |
| 3264 | 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' ); ?> |
| 3265 | 3588 | <br> |
| 3266 | 3589 | <?php |
| @@ -3299,9 +3622,9 @@ | ||
| 3299 | 3622 | <table class="form-table"> |
| 3300 | 3623 | <tr> |
| 3301 | 3624 | <th scope="row"><label for="vigilante-f-firewall-ua-whitelist"><?php esc_html_e( 'User-Agent Whitelist', 'vigilante' ); ?></label></th> |
| 3302 | 3625 | <td> |
| 3303 | - <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> | |
| 3304 | 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> |
| 3305 | 3628 | </td> |
| 3306 | 3629 | </tr> |
| 3307 | 3630 | <tr> |
| @@ -4010,9 +4333,9 @@ | ||
| 4010 | 4333 | ?> |
| 4011 | 4334 | <div class="vigilante-settings-section" id="vigilante-headers-recovery"> |
| 4012 | 4335 | <h2><?php esc_html_e( 'Recover your previous header settings', 'vigilante' ); ?></h2> |
| 4013 | 4336 | <p> |
| 4014 | - <?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' ); ?> | |
| 4015 | 4338 | </p> |
| 4016 | 4339 | <?php if ( $taken ) : ?> |
| 4017 | 4340 | <p class="description"> |
| 4018 | 4341 | <?php |
| @@ -4621,8 +4944,16 @@ | ||
| 4621 | 4944 | <span class="vigilante-method-badge php"><?php esc_html_e( 'PHP', 'vigilante' ); ?></span> |
| 4622 | 4945 | </h2> |
| 4623 | 4946 | <p><?php esc_html_e( 'Limit the number of simultaneous sessions per user.', 'vigilante' ); ?></p> |
| 4624 | 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 | + | |
| 4625 | 4956 | <table class="form-table"> |
| 4626 | 4957 | <tr> |
| 4627 | 4958 | <th scope="row"><?php esc_html_e( 'Enable Session Limits', 'vigilante' ); ?></th> |
| 4628 | 4959 | <td> |
| @@ -4962,9 +5293,17 @@ | ||
| 4962 | 5293 | </div> |
| 4963 | 5294 | |
| 4964 | 5295 | <!-- Pending Registrations --> |
| 4965 | 5296 | <?php |
| 4966 | - $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 ); | |
| 4967 | 5306 | $pending_users = $user_security->get_pending_users(); |
| 4968 | 5307 | ?> |
| 4969 | 5308 | <div id="vigilante-section-users-pending" class="vigilante-tool-box vigilante-pending-users-section"> |
| 4970 | 5309 | <h3> |
| @@ -4973,9 +5312,20 @@ | ||
| 4973 | 5312 | <span class="vigilante-badge vigilante-badge-warning"><?php echo esc_html( count( $pending_users ) ); ?></span> |
| 4974 | 5313 | <?php endif; ?> |
| 4975 | 5314 | </h3> |
| 4976 | 5315 | |
| 4977 | - <?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 ) ) : ?> | |
| 4978 | 5328 | <p class="description"> |
| 4979 | 5329 | <span class="dashicons dashicons-info" style="color: #72aee6;"></span> |
| 4980 | 5330 | <?php esc_html_e( 'Registration approval is disabled. Enable it in the settings above to require manual approval for new users.', 'vigilante' ); ?> |
| 4981 | 5331 | </p> |
| @@ -4984,8 +5334,9 @@ | ||
| 4984 | 5334 | <span class="dashicons dashicons-yes-alt"></span> |
| 4985 | 5335 | <p><?php esc_html_e( 'No pending registrations.', 'vigilante' ); ?></p> |
| 4986 | 5336 | </div> |
| 4987 | 5337 | <?php else : ?> |
| 5338 | + <?php $this->render_user_actions_notice(); ?> | |
| 4988 | 5339 | <table class="wp-list-table widefat fixed striped vigilante-pending-users-table"> |
| 4989 | 5340 | <thead> |
| 4990 | 5341 | <tr> |
| 4991 | 5342 | <th><?php esc_html_e( 'User', 'vigilante' ); ?></th> |
| @@ -4995,9 +5346,9 @@ | ||
| 4995 | 5346 | </tr> |
| 4996 | 5347 | </thead> |
| 4997 | 5348 | <tbody> |
| 4998 | 5349 | <?php foreach ( $pending_users as $pending_user ) : |
| 4999 | - $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 ); | |
| 5000 | 5351 | ?> |
| 5001 | 5352 | <tr data-user-id="<?php echo esc_attr( $pending_user->ID ); ?>"> |
| 5002 | 5353 | <td> |
| 5003 | 5354 | <?php echo get_avatar( $pending_user->ID, 32 ); ?> |
| @@ -5014,12 +5365,12 @@ | ||
| 5014 | 5365 | } |
| 5015 | 5366 | ?> |
| 5016 | 5367 | </td> |
| 5017 | 5368 | <td> |
| 5018 | - <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() ); ?>> | |
| 5019 | 5370 | <?php esc_html_e( 'Approve', 'vigilante' ); ?> |
| 5020 | 5371 | </button> |
| 5021 | - <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() ); ?>> | |
| 5022 | 5373 | <?php esc_html_e( 'Reject', 'vigilante' ); ?> |
| 5023 | 5374 | </button> |
| 5024 | 5375 | </td> |
| 5025 | 5376 | </tr> |
| @@ -5913,8 +6264,13 @@ | ||
| 5913 | 6264 | */ |
| 5914 | 6265 | private function render_tab_file_integrity() { |
| 5915 | 6266 | $is_disabled = $this->render_module_disabled_notice( 'file_integrity' ); |
| 5916 | 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(); | |
| 5917 | 6273 | $last_scan = get_option( 'vigilante_last_integrity_scan' ); |
| 5918 | 6274 | $last_results = get_option( 'vigilante_last_integrity_results' ); |
| 5919 | 6275 | $ignored_files = get_option( 'vigilante_ignored_files', array() ); |
| 5920 | 6276 | |
| @@ -6040,10 +6396,13 @@ | ||
| 6040 | 6396 | <?php esc_html_e( 'Uploads directory (detect PHP files, double extensions, .htaccess)', 'vigilante' ); ?> |
| 6041 | 6397 | </label> |
| 6042 | 6398 | <br> |
| 6043 | 6399 | <label> |
| 6044 | - <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 ); ?>> | |
| 6045 | 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; ?> | |
| 6046 | 6405 | </label> |
| 6047 | 6406 | <br> |
| 6048 | 6407 | <label> |
| 6049 | 6408 | <input type="checkbox" name="file_integrity[check_closed_plugins]" value="1" <?php checked( $options['check_closed_plugins'] ?? true ); ?>> |
| @@ -6322,9 +6681,15 @@ | ||
| 6322 | 6681 | $crit_diff = $crit_item['diff'] ?? array(); |
| 6323 | 6682 | $crit_id = sanitize_html_class( $crit_file ); |
| 6324 | 6683 | $added_count = is_array( $crit_diff ) ? count( $crit_diff['added'] ?? array() ) : 0; |
| 6325 | 6684 | $removed_count = is_array( $crit_diff ) ? count( $crit_diff['removed'] ?? array() ) : 0; |
| 6326 | - $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'] ) ); | |
| 6327 | 6692 | ?> |
| 6328 | 6693 | <tr> |
| 6329 | 6694 | <td><code style="color: #e36210;"><?php echo esc_html( $crit_file ); ?></code></td> |
| 6330 | 6695 | <td> |
| @@ -6347,18 +6712,36 @@ | ||
| 6347 | 6712 | <td> |
| 6348 | 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' ); ?>"> |
| 6349 | 6714 | <?php esc_html_e( 'Review changes', 'vigilante' ); ?> |
| 6350 | 6715 | </button> |
| 6351 | - <button type="button" class="button button-small button-primary vigilante-approve-critical-file" data-file="<?php echo esc_attr( $crit_file ); ?>"> | |
| 6352 | - <?php esc_html_e( 'Approve', 'vigilante' ); ?> | |
| 6353 | - </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; ?> | |
| 6354 | 6725 | </td> |
| 6355 | 6726 | </tr> |
| 6356 | 6727 | <tr id="vigilante-critical-content-<?php echo esc_attr( $crit_id ); ?>" class="vigilante-critical-content-row" style="display:none;"> |
| 6357 | 6728 | <td colspan="3" style="padding: 0;"> |
| 6358 | 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;"> |
| 6359 | - <?php if ( $diff_unavailable ) : ?> | |
| 6730 | + <?php if ( $diff_network ) : ?> | |
| 6360 | 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;"> | |
| 6361 | 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' ); ?> |
| 6362 | 6745 | </p> |
| 6363 | 6746 | <?php elseif ( empty( $crit_diff['added'] ) && empty( $crit_diff['removed'] ) ) : ?> |
| 6364 | 6747 | <p style="color: #50575e; font-style: italic; margin: 0;"> |
| @@ -6694,10 +7077,30 @@ | ||
| 6694 | 7077 | |
| 6695 | 7078 | // Read ONLY saved options from database (not merged with defaults) |
| 6696 | 7079 | $saved_options = get_option( Vigilante_Settings::OPTION_NAME, array() ); |
| 6697 | 7080 | |
| 6698 | - $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(); | |
| 6699 | 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 | + | |
| 6700 | 7103 | // Handle modules |
| 6701 | 7104 | if ( 'modules' === $section && isset( $data['modules'] ) ) { |
| 6702 | 7105 | if ( ! isset( $saved_options['modules'] ) ) { |
| 6703 | 7106 | $saved_options['modules'] = array(); |
| @@ -6721,9 +7124,9 @@ | ||
| 6721 | 7124 | // went straight into the option. An entry the matcher can never |
| 6722 | 7125 | // match still sits in a security list looking like protection, |
| 6723 | 7126 | // so the ones that cannot match are dropped and reported back |
| 6724 | 7127 | // instead of being stored in silence. |
| 6725 | - $rejected_ips = $this->filter_ip_lists( $section, $processed ); | |
| 7128 | + $rejected_ips = $this->filter_ip_lists( $section, $processed, $rejected_proxies ); | |
| 6726 | 7129 | |
| 6727 | 7130 | // Save the processed section |
| 6728 | 7131 | $saved_options[ $section ] = $processed; |
| 6729 | 7132 | |
| @@ -6734,8 +7137,10 @@ | ||
| 6734 | 7137 | |
| 6735 | 7138 | // Clear cache before saving |
| 6736 | 7139 | wp_cache_delete( Vigilante_Settings::OPTION_NAME, 'options' ); |
| 6737 | 7140 | |
| 7141 | + $saved_options = Vigilante_Settings::keep_locked_file_settings( $saved_options, $stored_options ); | |
| 7142 | + | |
| 6738 | 7143 | // Save to database |
| 6739 | 7144 | update_option( Vigilante_Settings::OPTION_NAME, $saved_options ); |
| 6740 | 7145 | |
| 6741 | 7146 | // Clear the settings cache |
| @@ -6797,8 +7202,21 @@ | ||
| 6797 | 7202 | implode( ', ', array_map( 'esc_html', $rejected_ips ) ) |
| 6798 | 7203 | ); |
| 6799 | 7204 | } |
| 6800 | 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 | + | |
| 6801 | 7219 | wp_send_json_success( $message ); |
| 6802 | 7220 | } |
| 6803 | 7221 | |
| 6804 | 7222 | /** |
| @@ -6809,9 +7227,21 @@ | ||
| 6809 | 7227 | * @param string $section Section being saved. |
| 6810 | 7228 | * @param array $processed Section data, edited in place. |
| 6811 | 7229 | * @return array Entries that were dropped, for the message back to the user. |
| 6812 | 7230 | */ |
| 6813 | - 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 | + | |
| 6814 | 7244 | $lists = array( |
| 6815 | 7245 | 'firewall' => array( 'ip_whitelist', 'ip_blacklist' ), |
| 6816 | 7246 | 'login_security' => array( 'ip_whitelist' ), |
| 6817 | 7247 | ); |
| @@ -7176,8 +7606,9 @@ | ||
| 7176 | 7606 | } |
| 7177 | 7607 | } |
| 7178 | 7608 | |
| 7179 | 7609 | // Save |
| 7610 | + $merged = Vigilante_Settings::keep_locked_file_settings( $merged, get_option( Vigilante_Settings::OPTION_NAME, array() ) ); | |
| 7180 | 7611 | update_option( Vigilante_Settings::OPTION_NAME, $merged ); |
| 7181 | 7612 | $this->settings->clear_cache(); |
| 7182 | 7613 | |
| 7183 | 7614 | // Re-evaluate the active preset marker. The imported config may match |
| @@ -7200,9 +7631,9 @@ | ||
| 7200 | 7631 | if ( ! wp_next_scheduled( 'vigilante_under_attack_post_scan' ) ) { |
| 7201 | 7632 | wp_schedule_single_event( time() + 5, 'vigilante_under_attack_post_scan' ); |
| 7202 | 7633 | } |
| 7203 | 7634 | |
| 7204 | - wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) ); | |
| 7635 | + wp_send_json_success( __( 'Settings imported successfully.', 'vigilante' ) . $this->locked_file_settings_message() ); | |
| 7205 | 7636 | } |
| 7206 | 7637 | |
| 7207 | 7638 | /** |
| 7208 | 7639 | * Detect whether a vigilante_options array matches a known preset. |
| @@ -7305,9 +7736,11 @@ | ||
| 7305 | 7736 | $preset = isset( $_POST['preset'] ) ? sanitize_key( $_POST['preset'] ) : ''; |
| 7306 | 7737 | |
| 7307 | 7738 | // Handle reset to defaults |
| 7308 | 7739 | if ( 'reset' === $preset ) { |
| 7309 | - $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 ); | |
| 7310 | 7743 | update_option( Vigilante_Settings::OPTION_NAME, $defaults ); |
| 7311 | 7744 | $this->settings->clear_cache(); |
| 7312 | 7745 | |
| 7313 | 7746 | // Clear active preset |
| @@ -7315,9 +7748,9 @@ | ||
| 7315 | 7748 | |
| 7316 | 7749 | // Apply file changes after reset |
| 7317 | 7750 | $this->apply_all_file_changes( $defaults ); |
| 7318 | 7751 | |
| 7319 | - wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) ); | |
| 7752 | + wp_send_json_success( __( 'Settings reset to defaults.', 'vigilante' ) . $this->locked_file_settings_message() ); | |
| 7320 | 7753 | return; |
| 7321 | 7754 | } |
| 7322 | 7755 | |
| 7323 | 7756 | $presets = $this->settings->get_presets(); |
| @@ -7343,8 +7776,9 @@ | ||
| 7343 | 7776 | // invent keys that are missing on both sides. |
| 7344 | 7777 | $current = Vigilante_Settings::merge_preset( $this->settings->get_default_options(), $current ); |
| 7345 | 7778 | |
| 7346 | 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() ) ); | |
| 7347 | 7781 | |
| 7348 | 7782 | update_option( Vigilante_Settings::OPTION_NAME, $merged ); |
| 7349 | 7783 | $this->settings->clear_cache(); |
| 7350 | 7784 | |
| @@ -7353,9 +7787,9 @@ | ||
| 7353 | 7787 | |
| 7354 | 7788 | // Apply file changes after preset |
| 7355 | 7789 | $this->apply_all_file_changes( $merged ); |
| 7356 | 7790 | |
| 7357 | - wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) ); | |
| 7791 | + wp_send_json_success( __( 'Preset applied successfully.', 'vigilante' ) . $this->locked_file_settings_message() ); | |
| 7358 | 7792 | } |
| 7359 | 7793 | |
| 7360 | 7794 | /** |
| 7361 | 7795 | * AJAX: Reset a specific section to defaults |
| @@ -7390,27 +7824,19 @@ | ||
| 7390 | 7824 | * On a subsite, the settings written to wp-config.php and .htaccess are |
| 7391 | 7825 | * the main site's business. Resetting the local copy of those would only |
| 7392 | 7826 | * make this screen disagree with the file, so they are carried over |
| 7393 | 7827 | * untouched, and a section that is nothing but shared settings is not |
| 7394 | - * 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). | |
| 7395 | 7830 | */ |
| 7396 | - if ( ! Vigilante_Settings::can_write_shared_files() ) { | |
| 7397 | - $shared = Vigilante_Settings::get_shared_file_settings(); | |
| 7831 | + $locked = Vigilante_Settings::get_locked_file_settings(); | |
| 7398 | 7832 | |
| 7399 | - if ( isset( $shared[ $section ] ) ) { | |
| 7400 | - if ( true === $shared[ $section ] ) { | |
| 7401 | - wp_send_json_error( Vigilante_Settings::get_shared_files_notice() ); | |
| 7402 | - } | |
| 7403 | - | |
| 7404 | - foreach ( $shared[ $section ] as $shared_key ) { | |
| 7405 | - if ( array_key_exists( $shared_key, (array) $current_options[ $section ] ) ) { | |
| 7406 | - $new_values[ $shared_key ] = $current_options[ $section ][ $shared_key ]; | |
| 7407 | - } | |
| 7408 | - } | |
| 7409 | - } | |
| 7833 | + if ( isset( $locked[ $section ] ) && true === $locked[ $section ] ) { | |
| 7834 | + wp_send_json_error( Vigilante_Settings::get_shared_files_notice() ); | |
| 7410 | 7835 | } |
| 7411 | 7836 | |
| 7412 | 7837 | $current_options[ $section ] = $new_values; |
| 7838 | + $current_options = Vigilante_Settings::keep_locked_file_settings( $current_options, get_option( Vigilante_Settings::OPTION_NAME, array() ) ); | |
| 7413 | 7839 | |
| 7414 | 7840 | // Save |
| 7415 | 7841 | update_option( Vigilante_Settings::OPTION_NAME, $current_options ); |
| 7416 | 7842 | $this->settings->clear_cache(); |
| @@ -7493,8 +7919,19 @@ | ||
| 7493 | 7919 | // Save new results |
| 7494 | 7920 | update_option( 'vigilante_last_integrity_scan', time() ); |
| 7495 | 7921 | update_option( 'vigilante_last_integrity_results', $results ); |
| 7496 | 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 | + | |
| 7497 | 7934 | wp_send_json_success( array( |
| 7498 | 7935 | 'message' => __( 'Scan completed.', 'vigilante' ), |
| 7499 | 7936 | 'results' => $results, |
| 7500 | 7937 | 'ignored_count' => count( get_option( 'vigilante_ignored_files', array() ) ), |
| @@ -7528,11 +7965,41 @@ | ||
| 7528 | 7965 | if ( ! current_user_can( 'manage_options' ) ) { |
| 7529 | 7966 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 7530 | 7967 | } |
| 7531 | 7968 | |
| 7969 | + $results = get_option( 'vigilante_last_integrity_results' ); | |
| 7970 | + $scanned_at = get_option( 'vigilante_last_integrity_scan' ); | |
| 7971 | + | |
| 7532 | 7972 | delete_option( 'vigilante_last_integrity_results' ); |
| 7533 | 7973 | delete_option( 'vigilante_last_integrity_scan' ); |
| 7534 | 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 | + | |
| 7535 | 8002 | if ( $this->database ) { |
| 7536 | 8003 | $this->database->clear_file_hashes(); |
| 7537 | 8004 | } |
| 7538 | 8005 | |
| @@ -7558,8 +8025,14 @@ | ||
| 7558 | 8025 | if ( empty( $file ) ) { |
| 7559 | 8026 | wp_send_json_error( __( 'No file specified.', 'vigilante' ) ); |
| 7560 | 8027 | } |
| 7561 | 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 | + | |
| 7562 | 8035 | $file_integrity = new Vigilante_File_Integrity( $this->settings, $this->database ); |
| 7563 | 8036 | $file_integrity->ignore_file( $file ); |
| 7564 | 8037 | |
| 7565 | 8038 | // Also remove the file from stored scan results so UI updates |
| @@ -7623,12 +8096,14 @@ | ||
| 7623 | 8096 | if ( ! is_array( $raw_files ) ) { |
| 7624 | 8097 | wp_send_json_error( __( 'Invalid request.', 'vigilante' ) ); |
| 7625 | 8098 | } |
| 7626 | 8099 | |
| 7627 | - $files = array(); | |
| 8100 | + $files = array(); | |
| 8101 | + $shared = $this->critical_approval_locked() ? array( 'wp-config.php', '.htaccess' ) : array(); | |
| 7628 | 8102 | foreach ( $raw_files as $f ) { |
| 7629 | 8103 | $clean = sanitize_text_field( $f ); |
| 7630 | - if ( '' !== $clean ) { | |
| 8104 | + // Same rule as ajax_ignore_file() for the two shared files. | |
| 8105 | + if ( '' !== $clean && ! in_array( $clean, $shared, true ) ) { | |
| 7631 | 8106 | $files[] = $clean; |
| 7632 | 8107 | } |
| 7633 | 8108 | } |
| 7634 | 8109 | |