PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.2
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.2
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-system-utility.php +380 -31 6.0.11 → 6.2.2 View file →
@@ -30,8 +30,19 @@
30 30 */
31 31 private static $instance = null;
32 32
33 33 /**
34 + * Request-local contrast results for tag backgrounds.
35 + *
36 + * Keyed by normalized #rrggbb. Invalid colors are not stored.
37 + *
38 + * @static
39 + *
40 + * @var array
41 + */
42 + private static $tag_contrast_cache = array();
43 +
44 + /**
34 45 * Method instance()
35 46 *
36 47 * Create a public static instance.
37 48 *
@@ -44,10 +55,37 @@
44 55 }
45 56 return static::$instance;
46 57 }
47 58
59 + /**
60 + * Constructor.
61 + *
62 + * Run each time the class is called.
63 + *
64 + * @return void
65 + */
66 + public function __construct() {
67 + add_action( 'admin_init', array( &$this, 'admin_init' ) );
68 + }
48 69
49 70 /**
71 + * Method admin_init().
72 + *
73 + * @return void
74 + */
75 + public function admin_init() {
76 + $current_month = gmdate( 'Y-m' );
77 + if ( get_option( 'mainwp_last_short_term_notice_purge' ) !== $current_month ) { // Run once per month.
78 + MainWP_Utility::purge_short_term_notices();
79 + update_option(
80 + 'mainwp_last_short_term_notice_purge',
81 + $current_month,
82 + false
83 + );
84 + }
85 + }
86 +
87 + /**
50 88 * Method get_class_name()
51 89 *
52 90 * Get Class Name.
53 91 *
@@ -181,9 +219,9 @@
181 219 $dirs = static::get_mainwp_dir();
182 220 $dir = $dirs[0] . 'icons' . DIRECTORY_SEPARATOR;
183 221 $url = $dirs[1] . 'icons/';
184 222 if ( ! $wp_filesystem->exists( $dir ) ) {
185 - $wp_filesystem->mkdir( $dir, 0777 );
223 + $wp_filesystem->mkdir( $dir, 0755 ); // MWP-1558: tightened from 0777 to WP convention; public-asset dir, blocks cross-tenant manipulation on shared hosting.
186 224 }
187 225 if ( ! $wp_filesystem->exists( $dir . 'index.php' ) ) {
188 226 $wp_filesystem->touch( $dir . 'index.php' );
189 227 }
@@ -275,9 +313,9 @@
275 313
276 314 $dir = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . 'mainwp' . DIRECTORY_SEPARATOR;
277 315 $url = $upload_dir['baseurl'] . '/mainwp/';
278 316 if ( ! $wp_filesystem->exists( $dir ) ) {
279 - $wp_filesystem->mkdir( $dir, 0777 );
317 + $wp_filesystem->mkdir( $dir, 0755 ); // MWP-1558: tightened from 0777 to WP convention; closes cross-tenant pk/ substitution attack on shared hosting.
280 318 }
281 319 if ( ! $wp_filesystem->exists( $dir . 'index.php' ) ) {
282 320 $wp_filesystem->touch( $dir . 'index.php' );
283 321 }
@@ -286,9 +324,10 @@
286 324 $newdir = $dir . $subdir . DIRECTORY_SEPARATOR;
287 325 $url = $url . $subdir . '/';
288 326
289 327 if ( ! $wp_filesystem->exists( $newdir ) ) {
290 - $wp_filesystem->mkdir( $newdir, 0777 );
328 + // MWP-1558: 0750 for private (htaccess-protected) subdirs, 0755 for public-asset subdirs (icons, client-images, etc.).
329 + $wp_filesystem->mkdir( $newdir, $direct_access ? 0755 : 0750 );
291 330 }
292 331
293 332 if ( $direct_access ) {
294 333 if ( ! $wp_filesystem->exists( trailingslashit( $newdir ) . 'index.php' ) ) {
@@ -441,13 +480,13 @@
441 480 if ( $hasWPFileSystem && ! empty( $wp_filesystem ) ) {
442 481
443 482 // need to check user dir first.
444 483 if ( ! $wp_filesystem->is_dir( $userdir ) ) {
445 - $wp_filesystem->mkdir( $userdir, 0777 );
484 + $wp_filesystem->mkdir( $userdir, 0750 ); // MWP-1558: tightened from 0777; per-user backup root.
446 485 }
447 486
448 487 if ( ! $wp_filesystem->is_dir( $newdir ) ) {
449 - $wp_filesystem->mkdir( $newdir, 0777 );
488 + $wp_filesystem->mkdir( $newdir, 0750 ); // MWP-1558: tightened from 0777; per-user backup subdir.
450 489 }
451 490
452 491 if ( ! empty( $dirs[0] ) . $userid && ! $wp_filesystem->exists( trailingslashit( $dirs[0] . $userid ) . '.htaccess' ) ) {
453 492 $file_htaccess = trailingslashit( $dirs[0] . $userid ) . '.htaccess';
@@ -456,13 +495,13 @@
456 495 } else {
457 496
458 497 // need to check user dir first.
459 498 if ( ! file_exists( $userdir ) ) {
460 - mkdir( $userdir, 0777, true ); // NOSONAR - @newdir is valid.
499 + mkdir( $userdir, 0750, true ); // MWP-1558: tightened from 0777. NOSONAR - @newdir is valid.
461 500 }
462 501
463 502 if ( ! file_exists( $newdir ) ) {
464 - mkdir( $newdir, 0777, true ); // NOSONAR - @newdir is valid.
503 + mkdir( $newdir, 0750, true ); // MWP-1558: tightened from 0777. NOSONAR - @newdir is valid.
465 504 }
466 505
467 506 if ( ! empty( $dirs[0] ) . $userid && ! file_exists( trailingslashit( $dirs[0] . $userid ) . '.htaccess' ) ) {
468 507 $file = fopen( trailingslashit( $dirs[0] . $userid ) . '.htaccess', 'w+' );
@@ -504,8 +543,66 @@
504 543 return $dirs[1] . $userid . '/' . $dir . '/';
505 544 }
506 545
507 546 /**
547 + * Method get_or_create_filename_secret()
548 + *
549 + * Return a stable per-install secret used to derive opaque private filenames
550 + * for sensitive files under wp-content/uploads/mainwp/. Generated once per
551 + * install and persisted as a plain wp_option (autoload off).
552 + *
553 + * Storage rationale: the secret is stored as a plain wp_option rather than
554 + * via MainWP_Keys_Manager::update_key_value(). The Keys_Manager would store
555 + * the key file inside the very pk/ directory whose filenames this secret
556 + * protects, defeating the purpose. The plaintext-in-DB trade-off is
557 + * acceptable because the threat model targets external URL guessing only;
558 + * a database read primitive that retrieves this secret would also retrieve
559 + * the encrypted privkey ciphertext it would be used to locate.
560 + *
561 + * @return string 64-character hex secret (256 bits of entropy).
562 + */
563 + public static function get_or_create_filename_secret() {
564 + // Use site-option storage so the secret is network-wide on multisite (consistent across blogs)
565 + // and falls through to get_option() on single-site. Matches MainWP_Install's pattern for network state.
566 + $secret = get_site_option( 'mainwp_private_filename_secret' );
567 + if ( ! is_string( $secret ) || ! preg_match( '/^[a-f0-9]{64}$/', $secret ) ) {
568 + $candidate = bin2hex( random_bytes( 32 ) );
569 + // add_site_option is atomic at the DB layer: only one concurrent writer wins.
570 + // Losing requests re-read so all callers see the same secret. Prevents a race where
571 + // two concurrent first-upgrade requests would both generate (and write) different
572 + // secrets, breaking subsequent filename derivations done by the losing request.
573 + if ( add_site_option( 'mainwp_private_filename_secret', $candidate ) ) {
574 + $secret = $candidate;
575 + } else {
576 + $secret = get_site_option( 'mainwp_private_filename_secret' );
577 + }
578 + }
579 + return $secret;
580 + }
581 +
582 + /**
583 + * Method get_private_filename()
584 + *
585 + * Compute the opaque filename for a private file under a wp-content/uploads/mainwp/
586 + * subdirectory. Replaces predictable patterns like 'mainwp_priv_encrypt_keys_<site_id>'
587 + * with HMAC-SHA256 derivations that are not externally enumerable.
588 + *
589 + * Filenames are deterministic per install: the same (subdir, key, purpose)
590 + * always yields the same output. Different subdir or purpose values produce
591 + * independent filename spaces with no cross-collision.
592 + *
593 + * @param string $subdir Subdirectory under mainwp/ (e.g., 'pk', 'cookies').
594 + * @param string $key Internal identifier (e.g., site_id, cookie salt).
595 + * @param string $purpose Domain separator to prevent cross-subdir filename collisions.
596 + *
597 + * @return string 64-character hex filename.
598 + */
599 + public static function get_private_filename( $subdir, $key, $purpose ) {
600 + $secret = static::get_or_create_filename_secret();
601 + return hash_hmac( 'sha256', $subdir . ':' . $purpose . ':' . $key, $secret );
602 + }
603 +
604 + /**
508 605 * Method get_mainwp_dir_allow_access()
509 606 *
510 607 * Get MainWP specific sub folder allow access.
511 608 *
@@ -668,22 +765,14 @@
668 765 $tagx = MainWP_DB_Common::instance()->get_group_by_name( $tag );
669 766 $tagc = is_object( $tagx ) && '' !== $tagx->color ? $tagx->color : '';
670 767 }
671 768
672 - if ( '' !== $tagc ) {
673 - $tag_a_style = 'style="color:#fff!important;opacity:1;"';
674 - $tag_style = 'style="background-color:' . esc_html( $tagc ) . '"';
675 - } else {
676 - $tag_a_style = '';
677 - $tag_style = '';
769 + $tag_id = '';
770 + if ( isset( $tags_ids[ $idx ] ) && ! empty( $tags_ids[ $idx ] ) ) {
771 + $tag_id = $tags_ids[ $idx ];
678 772 }
679 773
680 - if ( isset( $tags_ids[ $idx ] ) && ! empty( $tags_ids[ $idx ] ) ) {
681 - $tag_id = $tags_ids[ $idx ];
682 - $tags_labels .= '<span ' . $tag_style . ' tag_id="' . $tag_id . '" class="ui tag mini label"><a ' . $tag_a_style . ' href="' . esc_url( $href . $tag_id ) . '">' . esc_html( $tag ) . '</a></span>';
683 - } else {
684 - $tags_labels .= '<span ' . $tag_style . ' class="ui tag mini label">' . esc_html( $tag ) . '</span>';
685 - }
774 + $tags_labels .= self::get_site_tag_label( $tag, $tag_id, $href, $tagc );
686 775 }
687 776 }
688 777 }
689 778 return $tags_labels;
@@ -720,22 +809,14 @@
720 809 foreach ( $tags as $idx => $tag ) {
721 810 $tag = trim( $tag );
722 811 $tagc = $tags_colors[ $idx ];
723 812
724 - if ( '' !== $tagc ) {
725 - $tag_a_style = 'style="color:#fff!important;opacity:1;"';
726 - $tag_style = 'style="background-color:' . esc_html( $tagc ) . '"';
727 - } else {
728 - $tag_a_style = '';
729 - $tag_style = '';
813 + $tag_id = '';
814 + if ( isset( $tags_ids[ $idx ] ) && ! empty( $tags_ids[ $idx ] ) ) {
815 + $tag_id = $tags_ids[ $idx ];
730 816 }
731 817
732 - if ( isset( $tags_ids[ $idx ] ) && ! empty( $tags_ids[ $idx ] ) ) {
733 - $tag_id = $tags_ids[ $idx ];
734 - $tags_labels .= '<span ' . $tag_style . ' tag_id="' . $tag_id . '" class="ui tag mini label"><a ' . $tag_a_style . ' href="' . esc_url( $href . $tag_id ) . '">' . esc_html( $tag ) . '</a></span>';
735 - } else {
736 - $tags_labels .= '<span ' . $tag_style . ' class="ui tag mini label">' . esc_html( $tag ) . '</span>';
737 - }
818 + $tags_labels .= self::get_site_tag_label( $tag, $tag_id, $href, $tagc );
738 819 }
739 820 }
740 821 }
741 822 return $tags_labels;
@@ -741,8 +822,133 @@
741 822 return $tags_labels;
742 823 }
743 824
744 825 /**
826 + * Method get_tag_contrast_color()
827 + *
828 + * Pick white or black text for a tag background.
829 + * White is returned when its contrast is at least 4.5:1.
830 + *
831 + * @param string $background Stored tag color.
832 + *
833 + * @return string #ffffff, #000000, or an empty string when the color is unusable.
834 + */
835 + public static function get_tag_contrast_color( $background ) {
836 + $normalized = self::normalize_tag_color( $background );
837 + if ( '' === $normalized ) {
838 + return '';
839 + }
840 +
841 + if ( isset( self::$tag_contrast_cache[ $normalized ] ) ) {
842 + return self::$tag_contrast_cache[ $normalized ];
843 + }
844 +
845 + $white_contrast = 1.05 / ( self::tag_relative_luminance( $normalized ) + 0.05 );
846 + if ( $white_contrast >= 4.5 ) {
847 + $foreground = '#ffffff';
848 + } else {
849 + $foreground = '#000000';
850 + }
851 +
852 + self::$tag_contrast_cache[ $normalized ] = $foreground;
853 + return $foreground;
854 + }
855 +
856 + /**
857 + * Method normalize_tag_color()
858 + *
859 + * Reduce a stored tag color to lowercase #rrggbb.
860 + *
861 + * @param string $background Stored tag color.
862 + *
863 + * @return string Normalized color, or an empty string when it is not 3 or 6 digit hex.
864 + */
865 + private static function normalize_tag_color( $background ) {
866 + if ( ! is_string( $background ) ) {
867 + return '';
868 + }
869 +
870 + $background = trim( $background );
871 + if ( ! preg_match( '/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/', $background ) ) {
872 + return '';
873 + }
874 +
875 + $background = strtolower( $background );
876 + if ( 4 === strlen( $background ) ) {
877 + $background = '#' . $background[1] . $background[1] . $background[2] . $background[2] . $background[3] . $background[3];
878 + }
879 +
880 + return $background;
881 + }
882 +
883 + /**
884 + * Method tag_relative_luminance()
885 + *
886 + * WCAG relative luminance for a normalized #rrggbb color.
887 + *
888 + * @param string $hex Normalized background.
889 + *
890 + * @return float Luminance between 0 and 1.
891 + */
892 + private static function tag_relative_luminance( $hex ) {
893 + $red = self::tag_channel_linear( hexdec( substr( $hex, 1, 2 ) ) / 255 );
894 + $green = self::tag_channel_linear( hexdec( substr( $hex, 3, 2 ) ) / 255 );
895 + $blue = self::tag_channel_linear( hexdec( substr( $hex, 5, 2 ) ) / 255 );
896 +
897 + return ( 0.2126 * $red ) + ( 0.7152 * $green ) + ( 0.0722 * $blue );
898 + }
899 +
900 + /**
901 + * Method tag_channel_linear()
902 + *
903 + * Convert an sRGB channel to linear light.
904 + *
905 + * @param float $channel Channel from 0 to 1.
906 + *
907 + * @return float Linear channel.
908 + */
909 + private static function tag_channel_linear( $channel ) {
910 + if ( $channel <= 0.04045 ) {
911 + return $channel / 12.92;
912 + }
913 +
914 + return pow( ( $channel + 0.055 ) / 1.055, 2.4 );
915 + }
916 +
917 + /**
918 + * Method get_site_tag_label()
919 + *
920 + * Build one tag chip for the linked and plain renderers.
921 + *
922 + * @param string $tag Tag name.
923 + * @param string $tag_id Tag id. An empty id renders a plain chip.
924 + * @param string $href Link prefix.
925 + * @param string $background Stored background color.
926 + *
927 + * @return string Chip HTML.
928 + */
929 + private static function get_site_tag_label( $tag, $tag_id, $href, $background ) {
930 + $foreground = static::get_tag_contrast_color( $background );
931 + $tag_style = '';
932 +
933 + if ( '' !== $foreground ) {
934 + $normalized = self::normalize_tag_color( $background );
935 + $tag_style = 'style="background-color:' . esc_attr( $normalized ) . ';color:' . esc_attr( $foreground ) . '"';
936 + }
937 +
938 + if ( ! empty( $tag_id ) ) {
939 + $tag_a_style = 'style="opacity:1;"';
940 + if ( '' !== $foreground ) {
941 + $tag_a_style = 'style="color:' . esc_attr( $foreground ) . '!important;opacity:1;"';
942 + }
943 +
944 + return '<span ' . $tag_style . ' tag_id="' . esc_attr( $tag_id ) . '" class="ui tag mini label"><a ' . $tag_a_style . ' href="' . esc_url( $href . $tag_id ) . '">' . esc_html( $tag ) . '</a></span>';
945 + }
946 +
947 + return '<span ' . $tag_style . ' class="ui tag mini label">' . esc_html( $tag ) . '</span>';
948 + }
949 +
950 + /**
745 951 * Filter client tags
746 952 *
747 953 * @param array $item Array containing tags.
748 954 *
@@ -1832,6 +2038,149 @@
1832 2038 return $const;
1833 2039 }
1834 2040 }
1835 2041 return $http_ver_int;
2042 + }
2043 +
2044 +
2045 + /**
2046 + * Method test_self_connect()
2047 + *
2048 + * Server self-connect test.
2049 + *
2050 + * @param string $url Dashboard self-connect URL.
2051 + *
2052 + * @return array|WP_Error
2053 + */
2054 + public static function test_self_connect( $url ) {
2055 +
2056 + $secret = static::get_self_connect_secret();
2057 + $timestamp = time();
2058 +
2059 + if ( empty( $secret ) ) {
2060 + return new \WP_Error(
2061 + 'mainwp_self_connect_secret',
2062 + __( 'Unable to generate self-connect secret.', 'mainwp' )
2063 + );
2064 + }
2065 +
2066 + $message = 'mainwp_self_connect|' . $timestamp;
2067 +
2068 + $signature = hash_hmac(
2069 + 'sha256',
2070 + $message,
2071 + $secret
2072 + );
2073 +
2074 + /**
2075 + * Filter: https_local_ssl_verify
2076 + *
2077 + * Filters whether the server-self check shoul verify SSL Cert.
2078 + *
2079 + * @since Unknown
2080 + */
2081 + $args = array(
2082 + 'blocking' => true,
2083 + 'sslverify' => apply_filters( 'https_local_ssl_verify', true ),
2084 + 'timeout' => 15,
2085 + 'headers' => array(
2086 + 'X-MainWP-Self-Connect-Timestamp' => (string) $timestamp,
2087 + 'X-MainWP-Self-Connect-Signature' => $signature,
2088 + ),
2089 + 'body' => array(
2090 + 'action' => 'mainwp_self_connect',
2091 + 'mainwp_run' => 'self_connect',
2092 + ),
2093 + );
2094 + return wp_remote_post( $url, $args );
2095 + }
2096 +
2097 +
2098 + /**
2099 + * Method handle_self_connect()
2100 + *
2101 + * Server self-connect response.
2102 + *
2103 + * @return void
2104 + */
2105 + public function handle_self_connect() {
2106 +
2107 + if ( ! $this->verify_self_connect_request() ) {
2108 + status_header( 403 );
2109 + exit;
2110 + }
2111 +
2112 + status_header( 200 );
2113 + header( 'Content-Type: text/plain; charset=utf-8' );
2114 +
2115 + echo 'MainWP Self Connect OK';
2116 + exit;
2117 + }
2118 +
2119 +
2120 + /**
2121 + * Method verify_self_connect_request()
2122 + *
2123 + * Server self-connect test.
2124 + *
2125 + * @return bool
2126 + */
2127 + private function verify_self_connect_request() {
2128 +
2129 + $signature = isset( $_SERVER['HTTP_X_MAINWP_SELF_CONNECT_SIGNATURE'] )
2130 + ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_MAINWP_SELF_CONNECT_SIGNATURE'] ) )
2131 + : '';
2132 +
2133 + $timestamp = isset( $_SERVER['HTTP_X_MAINWP_SELF_CONNECT_TIMESTAMP'] )
2134 + ? (int) $_SERVER['HTTP_X_MAINWP_SELF_CONNECT_TIMESTAMP']
2135 + : 0;
2136 +
2137 + if ( 64 !== strlen( $signature ) || empty( $timestamp ) ) {
2138 + return false;
2139 + }
2140 +
2141 + // Prevent replay attacks.
2142 + if ( abs( time() - $timestamp ) > 300 ) {
2143 + return false;
2144 + }
2145 +
2146 + $secret = static::get_self_connect_secret();
2147 +
2148 + if ( empty( $secret ) ) {
2149 + return false;
2150 + }
2151 +
2152 + $message = 'mainwp_self_connect|' . $timestamp;
2153 +
2154 + $expected = hash_hmac(
2155 + 'sha256',
2156 + $message,
2157 + $secret
2158 + );
2159 +
2160 + return hash_equals( $expected, $signature );
2161 + }
2162 +
2163 +
2164 + /**
2165 + * Method get_self_connect_secret()
2166 + *
2167 + * @return string Self connect secret.
2168 + */
2169 + public static function get_self_connect_secret() {
2170 +
2171 + $option_name = 'mainwp_self_connect_secret';
2172 + $secret = get_option( $option_name, '' );
2173 +
2174 + if ( ! is_string( $secret ) || 64 !== strlen( $secret ) ) {
2175 + try {
2176 + $secret = bin2hex( random_bytes( 32 ) );
2177 + } catch ( \Exception $e ) {
2178 + return '';
2179 + }
2180 +
2181 + update_option( $option_name, $secret, false );
2182 + }
2183 +
2184 + return $secret;
1836 2185 }
1837 2186 }