PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
← All changes | class/class-mainwp-keys-manager.php +298 -3 5.2 → 6.2.1 View file →
@@ -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 /**