| @@ -150,8 +150,70 @@ | ||
| 150 | 150 | return true; |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | 153 | /** |
| 154 | + * Delete one canonical private key file with exact absence readback. | |
| 155 | + * | |
| 156 | + * @param string $file_key Name of key file. | |
| 157 | + * | |
| 158 | + * @return bool Whether exact absence was proved. | |
| 159 | + */ | |
| 160 | + public function delete_key_file_with_result( $file_key ) { | |
| 161 | + if ( ! is_string( $file_key ) || 1 !== preg_match( '/^[A-Za-z0-9][A-Za-z0-9_-]{0,254}$/D', $file_key ) ) { | |
| 162 | + return false; | |
| 163 | + } | |
| 164 | + | |
| 165 | + $key_dir = static::get_keys_dir(); | |
| 166 | + if ( ! is_string( $key_dir ) || '' === $key_dir ) { | |
| 167 | + return false; | |
| 168 | + } | |
| 169 | + | |
| 170 | + $file_path = trailingslashit( $key_dir ) . $file_key; | |
| 171 | + if ( ! $this->key_entry_present( $file_path ) ) { | |
| 172 | + return true; | |
| 173 | + } | |
| 174 | + | |
| 175 | + MainWP_Utility::delete_file( $file_path ); | |
| 176 | + clearstatcache( true, $file_path ); | |
| 177 | + | |
| 178 | + // A dangling symlink fails every exists() check, so the transport-aware helper skips it and leaves the entry behind. | |
| 179 | + if ( is_link( $file_path ) ) { | |
| 180 | + wp_delete_file( $file_path ); | |
| 181 | + clearstatcache( true, $file_path ); | |
| 182 | + } | |
| 183 | + | |
| 184 | + return ! $this->key_entry_present( $file_path ); | |
| 185 | + } | |
| 186 | + | |
| 187 | + /** | |
| 188 | + * Whether any directory entry, file or symlink, still occupies the key path. | |
| 189 | + * | |
| 190 | + * @param string $file_path Absolute key path. | |
| 191 | + * | |
| 192 | + * @return bool Whether an entry is present. | |
| 193 | + */ | |
| 194 | + private function key_entry_present( $file_path ) { | |
| 195 | + return $this->key_file_exists( $file_path ) || is_link( $file_path ); | |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 199 | + * Check one exact private key path through the active filesystem boundary. | |
| 200 | + * | |
| 201 | + * @param string $file_path Absolute key path. | |
| 202 | + * | |
| 203 | + * @return bool Whether the path exists. | |
| 204 | + */ | |
| 205 | + private function key_file_exists( $file_path ) { | |
| 206 | + global $wp_filesystem; | |
| 207 | + | |
| 208 | + if ( is_object( $wp_filesystem ) && method_exists( $wp_filesystem, 'exists' ) ) { | |
| 209 | + return (bool) $wp_filesystem->exists( $file_path ); | |
| 210 | + } | |
| 211 | + | |
| 212 | + return file_exists( $file_path ); | |
| 213 | + } | |
| 214 | + | |
| 215 | + /** | |
| 154 | 216 | * Method get_decrypt_values() |
| 155 | 217 | * |
| 156 | 218 | * Get decrypt value. |
| 157 | 219 | * |
| @@ -231,9 +293,9 @@ | ||
| 231 | 293 | * @param mixed $key_val The value. |
| 232 | 294 | * |
| 233 | 295 | * @return mixed Result. |
| 234 | 296 | */ |
| 235 | - private function save_key_file( $key_file, $key_val ) { | |
| 297 | + public function save_key_file( $key_file, $key_val ) { | |
| 236 | 298 | static::init_keys_dir(); |
| 237 | 299 | $key_dir = static::get_keys_dir(); |
| 238 | 300 | $file_path = $key_dir . $key_file; |
| 239 | 301 | $saved = file_put_contents( $file_path, $key_val ); //phpcs:ignore |
| @@ -366,9 +428,12 @@ | ||
| 366 | 428 | |
| 367 | 429 | if ( $hasWPFileSystem && ! empty( $wp_filesystem ) ) { |
| 368 | 430 | |
| 369 | 431 | if ( ! $wp_filesystem->is_dir( $keysDir ) ) { |
| 370 | - $wp_filesystem->mkdir( $keysDir, 0777 ); | |
| 432 | + // MWP-1557: 0700 preferred (owner only), 0750 fallback for shared-hosting umask edge cases. Mirrors migrate_private_filenames(). | |
| 433 | + if ( ! $wp_filesystem->mkdir( $keysDir, 0700 ) ) { | |
| 434 | + $wp_filesystem->mkdir( $keysDir, 0750 ); | |
| 435 | + } | |
| 371 | 436 | } |
| 372 | 437 | |
| 373 | 438 | if ( ! file_exists( $keysDir . '.htaccess' ) ) { |
| 374 | 439 | $file_htaccess = $keysDir . '.htaccess'; |
| @@ -382,9 +447,12 @@ | ||
| 382 | 447 | } else { |
| 383 | 448 | |
| 384 | 449 | //phpcs:disable |
| 385 | 450 | if ( ! file_exists( $keysDir ) ) { |
| 386 | - mkdir( $keysDir, 0777, true ); | |
| 451 | + // MWP-1557: 0700 preferred (owner only), 0750 fallback for shared-hosting umask edge cases. Mirrors migrate_private_filenames(). | |
| 452 | + if ( ! mkdir( $keysDir, 0700, true ) ) { | |
| 453 | + mkdir( $keysDir, 0750, true ); | |
| 454 | + } | |
| 387 | 455 | } |
| 388 | 456 | |
| 389 | 457 | if ( ! file_exists( $keysDir . '.htaccess' ) ) { |
| 390 | 458 | $file_htaccess = @fopen( $keysDir . '.htaccess', 'w+' ); |
| @@ -410,8 +478,235 @@ | ||
| 410 | 478 | */ |
| 411 | 479 | public static function get_keys_dir() { |
| 412 | 480 | $dirs = MainWP_System_Utility::get_mainwp_dir(); |
| 413 | 481 | return $dirs[0] . 'pk' . DIRECTORY_SEPARATOR; |
| 482 | + } | |
| 483 | + | |
| 484 | + /** | |
| 485 | + * Method register_migration_hooks() | |
| 486 | + * | |
| 487 | + * Register the post-upgrade hook that triggers the one-time pk/ filename | |
| 488 | + * migration. Called from MainWP_System::activate_this_plugin() before | |
| 489 | + * MainWP_Install::install() runs, so the action handler is registered when | |
| 490 | + * `mainwp_db_after_update` fires. | |
| 491 | + * | |
| 492 | + * @return void | |
| 493 | + */ | |
| 494 | + public static function register_migration_hooks() { | |
| 495 | + add_action( 'mainwp_db_after_update', array( static::class, 'migrate_private_filenames' ), 10, 2 ); | |
| 496 | + add_action( 'mainwp_db_after_update', array( static::class, 'migrate_sibling_dir_perms' ), 10, 2 ); | |
| 497 | + add_action( 'mainwp_db_after_update', array( static::class, 'fix_sibling_dir_perms_9023' ), 10, 2 ); | |
| 498 | + } | |
| 499 | + | |
| 500 | + /** | |
| 501 | + * Method migrate_private_filenames() | |
| 502 | + * | |
| 503 | + * One-time bulk migration for MWP-1557: rename legacy pk/ filenames | |
| 504 | + * (`mainwp_priv_encrypt_keys_<site_id>`) to the opaque HMAC-derived names | |
| 505 | + * computed by MainWP_System_Utility::get_private_filename(). Also tightens | |
| 506 | + * directory permissions from the legacy 0777 to 0700 (or 0750 fallback). | |
| 507 | + * | |
| 508 | + * Idempotent: only matches the legacy filename pattern, so re-running on | |
| 509 | + * already-migrated installs is a no-op. Lazy migration in | |
| 510 | + * MainWP_Encrypt_Data_Lib::get_key_file() handles any files this bulk | |
| 511 | + * pass might miss. | |
| 512 | + * | |
| 513 | + * @param string $from_version Pre-upgrade mainwp_db_version. | |
| 514 | + * @param string $to_version Post-upgrade mainwp_db_version. | |
| 515 | + * | |
| 516 | + * @return void | |
| 517 | + */ | |
| 518 | + public static function migrate_private_filenames( $from_version, $to_version ) { | |
| 519 | + unset( $to_version ); | |
| 520 | + if ( ! version_compare( $from_version, '9.0.2.0', '<' ) ) { | |
| 521 | + return; | |
| 522 | + } | |
| 523 | + static::init_keys_dir(); | |
| 524 | + $key_dir = static::get_keys_dir(); | |
| 525 | + if ( ! is_dir( $key_dir ) ) { | |
| 526 | + return; | |
| 527 | + } | |
| 528 | + | |
| 529 | + // Tighten directory permissions; 0700 preferred, 0750 if a shared web group needs read access. | |
| 530 | + if ( ! @chmod( $key_dir, 0700 ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 531 | + @chmod( $key_dir, 0750 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 532 | + } | |
| 533 | + | |
| 534 | + // Rename legacy pk files to opaque HMAC-derived names. | |
| 535 | + $entries = @scandir( $key_dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- best-effort directory walk. | |
| 536 | + if ( ! is_array( $entries ) ) { | |
| 537 | + return; | |
| 538 | + } | |
| 539 | + foreach ( $entries as $entry ) { | |
| 540 | + if ( ! preg_match( '/^mainwp_priv_encrypt_keys_(\d+)$/', $entry, $m ) ) { | |
| 541 | + continue; | |
| 542 | + } | |
| 543 | + $site_id = (int) $m[1]; | |
| 544 | + $new_name = MainWP_System_Utility::get_private_filename( 'pk', $site_id, 'priv_encrypt_keys' ); | |
| 545 | + $old_path = $key_dir . $entry; | |
| 546 | + $new_path = $key_dir . $new_name; | |
| 547 | + if ( file_exists( $new_path ) ) { | |
| 548 | + // New file already present (rare race); legacy is stale, remove it. | |
| 549 | + wp_delete_file( $old_path ); | |
| 550 | + continue; | |
| 551 | + } | |
| 552 | + @rename( $old_path, $new_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.rename_rename -- best-effort direct rename inside the keys dir, before WP_Filesystem is initialized; lazy migration in get_key_file() handles failures. | |
| 553 | + } | |
| 554 | + } | |
| 555 | + | |
| 556 | + /** | |
| 557 | + * Method migrate_sibling_dir_perms() | |
| 558 | + * | |
| 559 | + * One-time chmod sweep for installs that created mainwp/ subdirs before | |
| 560 | + * MWP-1558's mkdir tightening landed in 9.0.2.0. mkdir() does not touch | |
| 561 | + * the mode of an existing directory, so pre-fix installs keep their | |
| 562 | + * legacy 0777 even after upgrading. Reported by Daan Kortenbach in the | |
| 563 | + * MWP-1557/1558 follow-up sweep (MWP-1566). | |
| 564 | + * | |
| 565 | + * Targets the known set of subdirs the plugin manages. Idempotent: | |
| 566 | + * chmodding an already-correct dir is a no-op. @chmod failures are | |
| 567 | + * swallowed (Windows hosts, shared-hosting suexec mismatches, dirs | |
| 568 | + * owned by a different system user -- all expected). | |
| 569 | + * | |
| 570 | + * @param string $from_version Pre-upgrade mainwp_db_version. | |
| 571 | + * @param string $to_version Post-upgrade mainwp_db_version. | |
| 572 | + * | |
| 573 | + * @return void | |
| 574 | + */ | |
| 575 | + public static function migrate_sibling_dir_perms( $from_version, $to_version ) { | |
| 576 | + unset( $to_version ); | |
| 577 | + if ( ! version_compare( $from_version, '9.0.2.1', '<' ) ) { | |
| 578 | + return; | |
| 579 | + } | |
| 580 | + $dirs = MainWP_System_Utility::get_mainwp_dir(); | |
| 581 | + if ( empty( $dirs[0] ) || ! is_dir( $dirs[0] ) ) { | |
| 582 | + return; | |
| 583 | + } | |
| 584 | + $base = rtrim( $dirs[0], '/\\' ) . DIRECTORY_SEPARATOR; | |
| 585 | + | |
| 586 | + // mainwp/ root: 0755 (public-asset convention, holds index.php + subdirs). | |
| 587 | + @chmod( rtrim( $base, '/\\' ), 0755 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 588 | + | |
| 589 | + // Public-asset subdirs. cost-tracker-products-icons is module-specific (constant-gated) | |
| 590 | + // but uses the same get_mainwp_dir(..., true) public pattern, so legacy installs that ran | |
| 591 | + // Cost Tracker pre-9.0.2.0 need the same chmod. | |
| 592 | + foreach ( array( 'icons', 'plugin-icons', 'theme-icons', 'client-images', 'site-icons', 'themes', 'cost-tracker-products-icons' ) as $sub ) { | |
| 593 | + $p = $base . $sub; | |
| 594 | + if ( is_dir( $p ) ) { | |
| 595 | + @chmod( $p, 0755 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 596 | + } | |
| 597 | + } | |
| 598 | + | |
| 599 | + // Private (htaccess-protected) subdirs. | |
| 600 | + foreach ( array( 'cookies', 'templates', 'templates' . DIRECTORY_SEPARATOR . 'emails', 'bulk' ) as $sub ) { | |
| 601 | + $p = $base . $sub; | |
| 602 | + if ( is_dir( $p ) ) { | |
| 603 | + @chmod( $p, 0750 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 604 | + } | |
| 605 | + } | |
| 606 | + | |
| 607 | + // Per-user dirs and all descendants: mainwp/<userid>/, /<userid>/bulk/, per-site | |
| 608 | + // <siteid>/ dirs from get_mainwp_specific_dir($website->id), and any deeper paths | |
| 609 | + // that backup_download_file() may have materialized via dirname($pFile) on legacy | |
| 610 | + // installs with custom backup filenames. All private (0750). | |
| 611 | + | |
| 612 | + $public_access_dirs = array( 'favorites' ); | |
| 613 | + | |
| 614 | + $chmod_recursive = function ( $dir ) use ( &$chmod_recursive, $public_access_dirs ) { | |
| 615 | + @chmod( $dir, 0750 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 616 | + $subs = @glob( $dir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort directory walk. | |
| 617 | + if ( is_array( $subs ) ) { | |
| 618 | + foreach ( $subs as $s ) { | |
| 619 | + if ( in_array( basename( $s ), $public_access_dirs, true ) ) { | |
| 620 | + @chmod( $s, 0755 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 621 | + continue; | |
| 622 | + } | |
| 623 | + $chmod_recursive( $s ); | |
| 624 | + } | |
| 625 | + } | |
| 626 | + }; | |
| 627 | + | |
| 628 | + $userdirs = @glob( $base . '[0-9]*', GLOB_ONLYDIR ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- NOSONAR - best-effort directory walk. | |
| 629 | + | |
| 630 | + if ( is_array( $userdirs ) ) { | |
| 631 | + foreach ( $userdirs as $udir ) { | |
| 632 | + @chmod( $udir, 0751 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 633 | + $subs = @glob( $udir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort directory walk. | |
| 634 | + if ( is_array( $subs ) ) { | |
| 635 | + foreach ( $subs as $s ) { | |
| 636 | + if ( in_array( basename( $s ), $public_access_dirs, true ) ) { | |
| 637 | + @chmod( $s, 0755 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 638 | + continue; | |
| 639 | + } | |
| 640 | + $chmod_recursive( $s ); | |
| 641 | + } | |
| 642 | + } | |
| 643 | + } | |
| 644 | + } | |
| 645 | + } | |
| 646 | + | |
| 647 | + /** | |
| 648 | + * Method fix_sibling_dir_perms_9023() | |
| 649 | + * | |
| 650 | + * One-time chmod sweep for MWP-1566: fix a small number of installs that ran the MWP-1558/1566 | |
| 651 | + * follow-up sweep (MWP-1566) on. | |
| 652 | + * | |
| 653 | + * @param string $from_version Pre-upgrade mainwp_db_version. | |
| 654 | + * @param string $to_version Post-upgrade mainwp_db_version. | |
| 655 | + * | |
| 656 | + * @return void | |
| 657 | + */ | |
| 658 | + public static function fix_sibling_dir_perms_9023( $from_version, $to_version ) { | |
| 659 | + unset( $to_version ); | |
| 660 | + | |
| 661 | + if ( empty( $from_version ) || version_compare( $from_version, '9.0.2.1', '<' ) || version_compare( $from_version, '9.0.2.3', '>=' ) ) { | |
| 662 | + return; | |
| 663 | + } | |
| 664 | + | |
| 665 | + $dirs = MainWP_System_Utility::get_mainwp_dir(); | |
| 666 | + if ( empty( $dirs[0] ) || ! is_dir( $dirs[0] ) ) { | |
| 667 | + return; | |
| 668 | + } | |
| 669 | + $base = rtrim( $dirs[0], '/\\' ) . DIRECTORY_SEPARATOR; | |
| 670 | + | |
| 671 | + // Per-user dirs and all descendants: mainwp/<userid>/, /<userid>/bulk/, per-site | |
| 672 | + // <siteid>/ dirs from get_mainwp_specific_dir($website->id), and any deeper paths | |
| 673 | + // that backup_download_file() may have materialized via dirname($pFile) on legacy | |
| 674 | + // installs with custom backup filenames. All private (0750). | |
| 675 | + | |
| 676 | + $public_access_dirs = array( 'favorites' ); | |
| 677 | + | |
| 678 | + $chmod_recursive = function ( $dir ) use ( &$chmod_recursive, $public_access_dirs ) { | |
| 679 | + @chmod( $dir, 0750 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 680 | + $subs = @glob( $dir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- NOSONAR - best-effort directory walk. | |
| 681 | + if ( is_array( $subs ) ) { | |
| 682 | + foreach ( $subs as $s ) { | |
| 683 | + if ( in_array( basename( $s ), $public_access_dirs, true ) ) { | |
| 684 | + @chmod( $s, 0755 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 685 | + continue; | |
| 686 | + } | |
| 687 | + $chmod_recursive( $s ); | |
| 688 | + } | |
| 689 | + } | |
| 690 | + }; | |
| 691 | + | |
| 692 | + $userdirs = @glob( $base . '[0-9]*', GLOB_ONLYDIR ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- NOSONAR - best-effort directory walk. | |
| 693 | + | |
| 694 | + if ( is_array( $userdirs ) ) { | |
| 695 | + foreach ( $userdirs as $udir ) { | |
| 696 | + @chmod( $udir, 0751 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 697 | + $subs = @glob( $udir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort directory walk. | |
| 698 | + if ( is_array( $subs ) ) { | |
| 699 | + foreach ( $subs as $s ) { | |
| 700 | + if ( in_array( basename( $s ), $public_access_dirs, true ) ) { | |
| 701 | + @chmod( $s, 0755 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- NOSONAR - best-effort hardening. | |
| 702 | + continue; | |
| 703 | + } | |
| 704 | + $chmod_recursive( $s ); | |
| 705 | + } | |
| 706 | + } | |
| 707 | + } | |
| 708 | + } | |
| 414 | 709 | } |
| 415 | 710 | |
| 416 | 711 | |
| 417 | 712 | /** |