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-db.php +349 -84 6.0.12 → 6.2.2 View file →
@@ -83,8 +83,37 @@
83 83 return static::$instance;
84 84 }
85 85
86 86 /**
87 + * Keep only option names that are safe to splice into SQL as an identifier.
88 + *
89 + * Every wp_options view builder below puts each name in identifier position
90 + * (column and table aliases), where escape()/esc_sql() is not a defense: it
91 + * escapes quotes but leaves backticks alone, so a name like "a`, (SELECT ...)
92 + * AS `b" breaks out of the alias. Callers reach these through ?custom_fields on
93 + * the REST sites endpoints and through the view_fields param of
94 + * get_sql_website_by_params(). Anything outside [A-Za-z0-9_] is dropped rather
95 + * than escaped, which also makes the surviving escape() calls no-ops.
96 + *
97 + * @param array $fields Option names.
98 + *
99 + * @return array Option names safe for identifier position.
100 + */
101 + protected function filter_safe_option_names( $fields ) {
102 + if ( ! is_array( $fields ) ) {
103 + return array();
104 + }
105 + return array_values(
106 + array_filter(
107 + $fields,
108 + function ( $name ) {
109 + return is_string( $name ) && preg_match( '/^[A-Za-z0-9_]+$/', $name );
110 + }
111 + )
112 + );
113 + }
114 +
115 + /**
87 116 * Get wp_options database table view.
88 117 *
89 118 * @compatible function.
90 119 *
@@ -124,8 +153,10 @@
124 153 if ( ! in_array( 'cust_site_icon_info', $fields, true ) ) {
125 154 $fields[] = 'cust_site_icon_info';
126 155 }
127 156
157 + $fields = $this->filter_safe_option_names( $fields );
158 +
128 159 if ( is_array( $fields ) ) {
129 160 foreach ( $fields as $field ) {
130 161 if ( empty( $field ) ) {
131 162 continue;
@@ -133,9 +164,9 @@
133 164 if ( in_array( $field, $included_opts ) ) {
134 165 continue;
135 166 }
136 167 $view .= ', ';
137 - $view .= '(SELECT ' . $this->escape( $field ) . '.value FROM ' . $this->table_name( 'wp_options' ) . ' ' . $this->escape( $field ) . ' WHERE ' . $this->escape( $field ) . '.wpid = intwp.id AND ' . $this->escape( $field ) . '.name = "' . $this->escape( $field ) . '" LIMIT 1) AS ' . $this->escape( $field );
168 + $view .= '(SELECT `' . $this->escape( $field ) . '`.value FROM ' . $this->table_name( 'wp_options' ) . ' `' . $this->escape( $field ) . '` WHERE `' . $this->escape( $field ) . '`.wpid = intwp.id AND `' . $this->escape( $field ) . '`.name = "' . $this->escape( $field ) . '" LIMIT 1) AS `' . $this->escape( $field ) . '`';
138 169 }
139 170 }
140 171
141 172 $view .= ' FROM ' . $this->table_name( 'wp' ) . ' intwp)';
@@ -145,11 +176,11 @@
145 176
146 177 /**
147 178 * Method get_wp_options_join().
148 179 *
149 - * @param array $fields Extra option fields.
150 - * @param string $view_query view query.
151 - * @param array $params Additional parameters.
180 + * @param array $fields Extra option fields.
181 + * @param string $view_query view query.
182 + * @param array<string, mixed> $params Additional parameters.
152 183
153 184 *
154 185 * NOTE: This method is used to improve the performance of wp_options view, as the old view with subquery for each field will cause performance issue when there are many sites, and this method will generate the SQL with LEFT JOIN which will be much faster than subquery.
155 186 * The alias of wp table must be 'wp' to make sure the LEFT JOIN works, and the alias of wp_options table will be 'owp' + key of field in fields array, and the join condition is owp.wpid = wp.id AND owp.name = field name, and the select field is owp.value AS field name.
@@ -184,9 +215,9 @@
184 215 if ( ! in_array( 'cust_site_icon_info', $fields, true ) ) {
185 216 $fields[] = 'cust_site_icon_info';
186 217 }
187 218
188 - $fields = array_values( array_unique( array_filter( $fields ) ) );
219 + $fields = array_values( array_unique( array_filter( $this->filter_safe_option_names( $fields ) ) ) );
189 220
190 221 $tbl_wp_options = $this->table_name( 'wp_options' );
191 222
192 223 $selects = array();
@@ -257,8 +288,10 @@
257 288 if ( ! in_array( 'cust_site_icon_info', $fields, true ) ) {
258 289 $fields[] = 'cust_site_icon_info';
259 290 }
260 291
292 + $fields = $this->filter_safe_option_names( $fields );
293 +
261 294 if ( is_array( $fields ) ) {
262 295 foreach ( $fields as $field ) {
263 296 if ( empty( $field ) ) {
264 297 continue;
@@ -266,9 +299,9 @@
266 299 if ( in_array( $field, $included_opts ) ) {
267 300 continue;
268 301 }
269 302 $view .= ', ';
270 - $view .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS ' . $this->escape( $field );
303 + $view .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS `' . $this->escape( $field ) . '`';
271 304
272 305 $included_opts[] = $this->escape( $field );
273 306 }
274 307 }
@@ -309,9 +342,10 @@
309 342 // legacy support.
310 343 $selectgroups = isset( $params['with_tags'] ) && $params['with_tags'] ? true : false;
311 344 $orderBy = isset( $params['orderby'] ) ? $params['orderby'] : 'wp.url';
312 345 $offset = isset( $params['offset'] ) ? intval( $params['offset'] ) : false;
313 - $rowcount = isset( $params['rowcount'] ) && $params['rowcount'] ? true : false;
346 + $rowcount = isset( $params['rowcount'] ) ? (int) $params['rowcount'] : false;
347 + $count_sql = isset( $params['count_sql'] ) && $params['count_sql'] ? true : false;
314 348 $extraWhere = isset( $params['where'] ) ? $params['where'] : null; // NOTE: without 'AND' at begining and ending of 'where'.
315 349 $for_manager = isset( $params['for_manager'] ) && $params['for_manager'] ? true : false;
316 350 $others_fields = isset( $params['others_fields'] ) && is_array( $params['others_fields'] ) ? $params['others_fields'] : array( 'favi_icon' );
317 351 $is_staging = isset( $params['is_staging'] ) && in_array( $params['is_staging'], array( 'yes', 'no' ) ) ? $params['is_staging'] : 'no';
@@ -395,9 +429,9 @@
395 429 $status_conds = array();
396 430 if ( in_array( 'available_update', $status ) ) {
397 431 $available_sql = " ( wp.plugin_upgrades <> '' && wp.plugin_upgrades <> '[]' ) OR ( wp.theme_upgrades <> '' && wp.theme_upgrades <> '[]' ) OR ( wp.translation_upgrades <> '' && wp.translation_upgrades <> '[]' ) OR ( wp.premium_upgrades <> '' && wp.premium_upgrades <> '[]' ) ";
398 432 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
399 - $results = $this->wpdb->get_results( "SELECT wpid FROM {$table_name} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" );
433 + $results = $this->wpdb->get_results( "SELECT wpid FROM {$table_name} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; the rest of the query is a literal.
400 434 if ( $results ) {
401 435 $wp_ids = array();
402 436 foreach ( $results as $item ) {
403 437 if ( ! empty( $item->wpid ) ) {
@@ -474,8 +508,9 @@
474 508 'wp.userid',
475 509 'wp.plugins',
476 510 'wp.themes',
477 511 'wp.offline_check_result', // 1 - online, -1 offline.
512 + 'wp.automatic_update',
478 513 );
479 514
480 515 $select = ' wp.*,wp_sync.* ';
481 516 if ( 'base_view' === $view ) {
@@ -507,18 +542,19 @@
507 542 }
508 543
509 544 // wpgroups to fix issue for mysql 8.0, as groups will generate error syntax.
510 545 if ( $selectgroups ) {
511 - $qry = 'SELECT ' . $select . $view_selects . ', GROUP_CONCAT(gr.name ORDER BY gr.name SEPARATOR ",") as wpgroups, GROUP_CONCAT(gr.id ORDER BY gr.name SEPARATOR ",") as wpgroupids, GROUP_CONCAT(gr.color ORDER BY gr.name SEPARATOR ",") as wpgroups_colors ' .
512 - $select_clients . '
546 + $select_qry = 'SELECT ' . $select . $view_selects . ', GROUP_CONCAT(gr.name ORDER BY gr.name SEPARATOR ",") as wpgroups, GROUP_CONCAT(gr.id ORDER BY gr.name SEPARATOR ",") as wpgroupids, GROUP_CONCAT(gr.color ORDER BY gr.name SEPARATOR ",") as wpgroups_colors ' .
547 + $select_clients;
513 548
514 - FROM ' . $this->table_name( 'wp' ) . ' wp
549 + $qry = ' FROM ' . $this->table_name( 'wp' ) . ' wp
515 550 LEFT JOIN ' . $this->table_name( 'wp_group' ) . ' wpgr ON wp.id = wpgr.wpid
516 551 LEFT JOIN ' . $this->table_name( 'group' ) . ' gr ON wpgr.groupid = gr.id
517 552 ' . $join_clients . '
518 553 JOIN ' . $this->table_name( 'wp_sync' ) . ' wp_sync ON wp.id = wp_sync.wpid
519 554 ' . $view_joins . '
520 - WHERE 1 ' . $where . $connected_sql . '
555 + WHERE 1 ' . $where . $connected_sql;
556 + $group_qry = '
521 557 GROUP BY wp.id, wp_sync.sync_id
522 558 ORDER BY ' . $orderBy;
523 559 } elseif ( ! empty( $specific_wp_fields ) ) { // Optimize select sites data.
524 560 $select = $specific_wp_fields;
@@ -529,25 +565,33 @@
529 565 $select .= ',' . $specific_sync_fields;
530 566 $join_sync = ' JOIN ' . $this->table_name( 'wp_sync' ) . ' wp_sync ON wp.id = wp_sync.wpid';
531 567 $group_by .= ', wp_sync.sync_id';
532 568 }
533 - $qry = 'SELECT ' . $select . $view_selects . '
534 - FROM ' . $this->table_name( 'wp' ) . ' wp
569 + $select_qry = 'SELECT ' . $select . $view_selects;
570 + $qry = ' FROM ' . $this->table_name( 'wp' ) . ' wp
535 571 ' . $join_sync . ' ' . $view_joins . '
536 - WHERE 1 ' . $where . $connected_sql . '
572 + WHERE 1 ' . $where . $connected_sql;
573 + $group_qry = '
537 574 GROUP BY ' . $group_by . '
538 575 ORDER BY ' . $orderBy;
539 576 } else {
540 - $qry = 'SELECT ' . $select . $view_selects . $select_clients . '
541 - FROM ' . $this->table_name( 'wp' ) . ' wp
577 + $select_qry = 'SELECT ' . $select . $view_selects . $select_clients;
578 + $qry = ' FROM ' . $this->table_name( 'wp' ) . ' wp
542 579 ' . $join_clients . '
543 580 JOIN ' . $this->table_name( 'wp_sync' ) . ' wp_sync ON wp.id = wp_sync.wpid
544 581 ' . $view_joins . '
545 - WHERE 1 ' . $where . $connected_sql . '
582 + WHERE 1 ' . $where . $connected_sql;
583 + $group_qry = '
546 584 GROUP BY wp.id, wp_sync.sync_id
547 585 ORDER BY ' . $orderBy;
548 586 }
549 587
588 + if ( $count_sql ) {
589 + return 'SELECT COUNT(DISTINCT wp.id) ' . $qry;
590 + }
591 +
592 + $qry = $select_qry . $qry . $group_qry;
593 +
550 594 if ( ( false !== $offset ) && ( false !== $rowcount ) ) {
551 595 $qry .= ' LIMIT ' . $offset . ', ' . $rowcount;
552 596 } elseif ( false !== $rowcount ) {
553 597 $qry .= ' LIMIT ' . $rowcount;
@@ -599,10 +643,11 @@
599 643 'ignored_trans_updates',
600 644 );
601 645 } elseif ( in_array( $view, array( 'simple_view', 'base_view', 'monitor_view', 'ping_view', 'uptime_notification' ) ) ) {
602 646 $fields = array();
603 - if ( 'monitor_view' === $view ) {
647 + if ( 'monitor_view' === $view || 'ping_view' === $view ) {
604 648 $fields[] = 'health_site_status';
649 + $fields[] = 'bypass_cache';
605 650 }
606 651 } elseif ( 'custom_view' !== $view ) {
607 652 $fields = $default;
608 653 }
@@ -620,9 +665,9 @@
620 665 $fields[] = 'verify_method';
621 666 }
622 667 }
623 668
624 - $fields = array_values( array_filter( $fields ) );
669 + $fields = array_values( array_filter( $this->filter_safe_option_names( $fields ) ) );
625 670
626 671 $tbl_wp_options = $this->table_name( 'wp_options' );
627 672
628 673 $selects = array();
@@ -676,10 +721,11 @@
676 721 'ignored_trans_updates',
677 722 );
678 723 } elseif ( in_array( $view, array( 'simple_view', 'base_view', 'monitor_view', 'ping_view', 'uptime_notification' ) ) ) {
679 724 $fields = array();
680 - if ( 'monitor_view' === $view ) {
725 + if ( 'monitor_view' === $view || 'ping_view' === $view ) {
681 726 $fields[] = 'health_site_status';
727 + $fields[] = 'bypass_cache';
682 728 }
683 729 } elseif ( 'custom_view' !== $view ) {
684 730 $fields = $default;
685 731 }
@@ -699,8 +745,10 @@
699 745 $fields[] = 'verify_method';
700 746 }
701 747 }
702 748
749 + $fields = $this->filter_safe_option_names( $fields );
750 +
703 751 foreach ( $fields as $field ) {
704 752
705 753 if ( empty( $field ) ) {
706 754 continue;
@@ -706,9 +754,9 @@
706 754 continue;
707 755 }
708 756
709 757 $view_query .= ', ';
710 - $view_query .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS ' . $this->escape( $field );
758 + $view_query .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS `' . $this->escape( $field ) . '`';
711 759 }
712 760
713 761 $view_query .= ' FROM ' . $this->table_name( 'wp_options' ) .
714 762 " WHERE name IN ('" . implode( "','", $fields ) . "')
@@ -926,9 +974,9 @@
926 974 return false;
927 975 }
928 976
929 977 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
930 - $value = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) );
978 + $value = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
931 979
932 980 if ( null === $value && null !== $default_value ) {
933 981 return $default_value;
934 982 }
@@ -1006,9 +1054,9 @@
1006 1054 }
1007 1055
1008 1056 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1009 1057 $placeholders = implode( ',', array_fill( 0, count( $get_options ), '%s' ) );
1010 - $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( $site_id ), $get_options ) ) );
1058 + $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( $site_id ), $get_options ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; $placeholders is a generated list of %s.
1011 1059
1012 1060 $fill_options = array(
1013 1061 'primary_lasttime_backup',
1014 1062 );
@@ -1045,9 +1093,9 @@
1045 1093 $site_id = $website->id;
1046 1094 }
1047 1095
1048 1096 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1049 - $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) );
1097 + $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1050 1098 if ( empty( $rslt ) ) {
1051 1099 $this->wpdb->insert(
1052 1100 $this->table_name( 'wp_options' ),
1053 1101 array(
@@ -1092,9 +1140,9 @@
1092 1140 }
1093 1141
1094 1142 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1095 1143 foreach ( $options as $opt ) {
1096 - $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE wpid=%d AND name=%s", $site_id, $opt ) );
1144 + $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE wpid=%d AND name=%s", $site_id, $opt ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1097 1145 }
1098 1146 }
1099 1147
1100 1148
@@ -1115,9 +1163,9 @@
1115 1163 static::$general_options[] = array();
1116 1164 }
1117 1165
1118 1166 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1119 - $val = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) );
1167 + $val = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1120 1168
1121 1169 static::$general_options[ $option ] = $val;
1122 1170 return $val;
1123 1171 }
@@ -1158,9 +1206,9 @@
1158 1206 }
1159 1207
1160 1208 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1161 1209 $placeholders = implode( ',', array_fill( 0, count( $diff_options ), '%s' ) );
1162 - $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( 0 ), $diff_options ) ) );
1210 + $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( 0 ), $diff_options ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; $placeholders is a generated list of %s.
1163 1211
1164 1212 foreach ( (array) $options_db as $o ) {
1165 1213 $return_options[ $o->name ] = $o->value;
1166 1214 static::$general_options[ $o->name ] = $o->value;
@@ -1191,9 +1239,9 @@
1191 1239 }
1192 1240 static::$general_options[ $option ] = $value;
1193 1241
1194 1242 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1195 - $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) );
1243 + $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1196 1244
1197 1245 if ( empty( $rslt ) ) {
1198 1246 $this->wpdb->insert(
1199 1247 $this->table_name( 'wp_options' ),
@@ -1462,9 +1510,9 @@
1462 1510 $status_conds = array();
1463 1511 if ( in_array( 'available_update', $status ) ) {
1464 1512 $available_sql = " ( wp.plugin_upgrades <> '' && wp.plugin_upgrades <> '[]' ) OR ( wp.theme_upgrades <> '' && wp.theme_upgrades <> '[]' ) OR ( wp.translation_upgrades <> '' && wp.translation_upgrades <> '[]' ) OR ( wp.premium_upgrades <> '' && wp.premium_upgrades <> '[]' ) ";
1465 1513 $options_table = esc_sql( $this->table_name( 'wp_options' ) );
1466 - $results = $this->wpdb->get_results( "SELECT wpid FROM {$options_table} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" );
1514 + $results = $this->wpdb->get_results( "SELECT wpid FROM {$options_table} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; the rest of the query is a literal.
1467 1515 if ( $results ) {
1468 1516 $wp_ids = array();
1469 1517 foreach ( $results as $item ) {
1470 1518 if ( ! empty( $item->wpid ) ) {
@@ -1924,9 +1972,12 @@
1924 1972 $obj_data = MainWP_Utility::map_site( $website, $data );
1925 1973
1926 1974 if ( $full_data ) {
1927 1975 $sum_upgrades = 0;
1928 - if ( '' !== $obj_data->plugin_upgrades ) {
1976 + // The option-backed columns are SQL NULL until a first sync writes them, and
1977 + // map_site() copies a null property through, so an '' check alone leaves
1978 + // json_decode() a null on PHP 8.1+.
1979 + if ( ! empty( $obj_data->plugin_upgrades ) ) {
1929 1980 $plugin_upgrades = json_decode( $obj_data->plugin_upgrades, true );
1930 1981 if ( is_array( $plugin_upgrades ) ) {
1931 1982 $sum_upgrades += count( $plugin_upgrades );
1932 1983 }
@@ -1931,9 +1982,9 @@
1931 1982 $sum_upgrades += count( $plugin_upgrades );
1932 1983 }
1933 1984 }
1934 1985
1935 - if ( '' !== $obj_data->theme_upgrades ) {
1986 + if ( ! empty( $obj_data->theme_upgrades ) ) {
1936 1987 $theme_upgrades = json_decode( $obj_data->theme_upgrades, true );
1937 1988 if ( is_array( $theme_upgrades ) ) {
1938 1989 $sum_upgrades += count( $theme_upgrades );
1939 1990 }
@@ -1938,9 +1989,9 @@
1938 1989 $sum_upgrades += count( $theme_upgrades );
1939 1990 }
1940 1991 }
1941 1992
1942 - if ( '' !== $obj_data->wp_upgrades ) {
1993 + if ( ! empty( $obj_data->wp_upgrades ) ) {
1943 1994 $wp_upgrades = json_decode( $obj_data->wp_upgrades, true );
1944 1995 if ( is_array( $wp_upgrades ) ) {
1945 1996 $sum_upgrades += count( $wp_upgrades );
1946 1997 }
@@ -2914,9 +2965,36 @@
2914 2965
2915 2966 return (int) $this->wpdb->get_var( $qry ); //phpcs:ignore PluginCheck.Security.DirectDB.Unprepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_allowed is from validated get_sql_where_access_sites() with numeric IDs.
2916 2967 }
2917 2968
2969 +
2918 2970 /**
2971 + * Get child site connection status counts.
2972 + *
2973 + * @return array|null {
2974 + * Connection status counts.
2975 + *
2976 + * @type int $total_sites Total number of sites.
2977 + * @type int $connected_sites Number of connected sites.
2978 + * @type int $disconnected_sites Number of disconnected sites.
2979 + * }
2980 + */
2981 + public function get_sites_connections_status() {
2982 +
2983 + $is_staging = 'no';
2984 +
2985 + $sites_table = $this->table_name( 'wp' );
2986 + $sync_table = $this->table_name( 'wp_sync' );
2987 +
2988 + return $this->wpdb->get_row(
2989 + "SELECT COUNT(wp.id) AS total_sites, SUM(CASE WHEN s.sync_errors = '' THEN 1 ELSE 0 END) AS connected_sites, SUM(CASE WHEN s.sync_errors <> '' THEN 1 ELSE 0 END) AS disconnected_sites FROM {$sites_table} wp JOIN {$sync_table} s ON s.wpid = wp.id " . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names come from table_name(), which is the DB prefix plus a hardcoded suffix.
2990 + $this->get_sql_where_allow_access_sites( 'wp', $is_staging ),
2991 + ARRAY_A
2992 + );
2993 + }
2994 +
2995 +
2996 + /**
2919 2997 * Get child sites by group id via SQL.
2920 2998 *
2921 2999 * @param int $id Group ID.
2922 3000 * @param bool $selectgroups Selected groups. Default: false.
@@ -3083,9 +3161,9 @@
3083 3161 * @return string|null Child site IP address or null on failure.
3084 3162 */
3085 3163 public function get_wp_ip( $wpid ) {
3086 3164 $table_name = esc_sql( $this->table_name( 'request_log' ) );
3087 - return $this->wpdb->get_var( $this->wpdb->prepare( "SELECT ip FROM {$table_name} WHERE wpid = %d", $wpid ) );
3165 + return $this->wpdb->get_var( $this->wpdb->prepare( "SELECT ip FROM {$table_name} WHERE wpid = %d", $wpid ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3088 3166 }
3089 3167
3090 3168 /**
3091 3169 * Add website to the MainWP Dashboard.
@@ -3124,9 +3202,25 @@
3124 3202 $http_pass = isset( $params['http_pass'] ) ? $params['http_pass'] : null;
3125 3203 $sslVersion = isset( $params['sslVersion'] ) ? $params['sslVersion'] : 0;
3126 3204 $wpe = isset( $params['wpe'] ) ? $params['wpe'] : 0;
3127 3205 $isStaging = isset( $params['isStaging'] ) ? $params['isStaging'] : 0;
3206 + // 2 means "use the global mainwp_forceUseIPv4 option" and mainwp_string_to_bool()
3207 + // collapses it to 0, so it is kept before the boolean conversion. Null or absent
3208 + // stays null and leaves the NOT NULL column at its default.
3209 + $force_use_ipv4 = isset( $params['force_use_ipv4'] ) ? ( 2 === (int) $params['force_use_ipv4'] ? 2 : (int) mainwp_string_to_bool( $params['force_use_ipv4'] ) ) : null;
3128 3210
3211 + // MWP-1548: encrypt http_user / http_pass at rest. Empty / null
3212 + // values pass through unchanged (encrypt_credential is a no-op
3213 + // for those). A non-empty value that fails to encrypt produces
3214 + // false here, which we treat as a hard failure -- refuse to
3215 + // persist plaintext credentials when the encryption layer is
3216 + // unhealthy (missing keyfile, un-writable uploads dir).
3217 + $encrypted_http_user = MainWP_Credential_Storage::encrypt_credential( $http_user, 'http_user' );
3218 + $encrypted_http_pass = MainWP_Credential_Storage::encrypt_credential( $http_pass, 'http_pass' );
3219 + if ( false === $encrypted_http_user || false === $encrypted_http_pass ) {
3220 + return false;
3221 + }
3222 +
3129 3223 if ( MainWP_Utility::ctype_digit( $userid ) ) {
3130 3224 if ( '/' !== substr( $url, - 1 ) ) {
3131 3225 $url .= '/';
3132 3226 }
@@ -3166,22 +3260,26 @@
3166 3260 'verify_certificate' => intval( $verifyCertificate ),
3167 3261 'ssl_version' => $sslVersion,
3168 3262 'uniqueId' => $uniqueId,
3169 3263 'mainwpdir' => 0,
3170 - 'http_user' => $http_user,
3171 - 'http_pass' => $http_pass,
3264 + 'http_user' => $encrypted_http_user,
3265 + 'http_pass' => $encrypted_http_pass,
3172 3266 'wpe' => $wpe,
3173 3267 'is_staging' => $isStaging,
3174 3268 );
3175 3269
3270 + if ( null !== $force_use_ipv4 ) {
3271 + $values['force_use_ipv4'] = $force_use_ipv4;
3272 + }
3273 +
3176 3274 $syncValues = array(
3177 - 'dtsSync' => 0,
3178 - 'dtsSyncStart' => 0,
3179 - 'dtsAutomaticSync' => 0,
3180 - 'dtsAutomaticSyncStart' => 0,
3181 - 'totalsize' => 0,
3182 - 'extauth' => '',
3183 - 'sync_errors' => '',
3275 + 'dtsSync' => 0,
3276 + 'dtsSyncStart' => 0,
3277 + 'dtsAutomaticSync' => 0,
3278 + 'dtsAutomaticSyncStart' => 0,
3279 + 'totalsize' => 0,
3280 + 'extauth' => '',
3281 + 'sync_errors' => '',
3184 3282 );
3185 3283 if ( $this->wpdb->insert( $this->table_name( 'wp' ), $values ) ) {
3186 3284 $websiteid = $this->wpdb->insert_id;
3187 3285 MainWP_Logger::instance()->log_events( 'db-queries', sprintf( '[Insert site=%s]', $this->get_last_query() ) ); // after: $this->wpdb->insert_id.
@@ -3218,8 +3316,26 @@
3218 3316 'groupid' => $groupid,
3219 3317 )
3220 3318 );
3221 3319 }
3320 +
3321 + // Add primary monitor, use global settings.
3322 + $success = MainWP_DB_Uptime_Monitoring::instance()->update_wp_monitor(
3323 + array(
3324 + 'wpid' => $websiteid,
3325 + 'suburl' => '',
3326 + )
3327 + );
3328 +
3329 + $opt_name = 'mainwp_primary_monitor_repair_attempts_for_wp_' . $websiteid;
3330 +
3331 + if ( ! $success ) {
3332 + update_option( $opt_name, 1 );
3333 + MainWP_Utility::unhide_mainwp_message( 'failed-attempt-create-primary-monitor-' . $websiteid );
3334 + } elseif ( get_option( $opt_name ) ) {
3335 + delete_option( $opt_name );
3336 + }
3337 +
3222 3338 MainWP_Manage_Sites_List_Table::invalidate_manage_sites_cache();
3223 3339 return $websiteid;
3224 3340 }
3225 3341 }
@@ -3242,9 +3358,9 @@
3242 3358 $this->wpdb->delete( $this->table_name( 'wp_group' ), array( 'wpid' => $websiteid ) );
3243 3359 $this->wpdb->delete( $this->table_name( 'wp_sync' ), array( 'wpid' => $websiteid ) );
3244 3360 $this->wpdb->delete( $this->table_name( 'wp_options' ), array( 'wpid' => $websiteid ) );
3245 3361 MainWP_Encrypt_Data_Lib::remove_key_file( $websiteid );
3246 - MainWP_DB_Uptime_Monitoring::instance()->delete_monitor( array( 'wpid' => $websiteid ) );
3362 + MainWP_DB_Uptime_Monitoring::instance()->delete_site_monitors( $websiteid );
3247 3363 MainWP_Manage_Sites_List_Table::invalidate_manage_sites_cache();
3248 3364 return $nr;
3249 3365 }
3250 3366
@@ -3260,12 +3376,31 @@
3260 3376 * @return int|boolean The number of rows updated, or false on error.
3261 3377 */
3262 3378 public function update_website_values( $websiteid, $fields ) {
3263 3379 if ( ! empty( $fields ) ) {
3380 + // MWP-1548: encrypt http_user / http_pass at rest if either
3381 + // is present in $fields. Generic-fields updaters (clone
3382 + // flow in extensions-handler, callers that pass arbitrary
3383 + // column subsets) must go through the same fail-closed
3384 + // contract as add_website / update_website.
3385 + if ( array_key_exists( 'http_user', $fields ) ) {
3386 + $encrypted = MainWP_Credential_Storage::encrypt_credential( $fields['http_user'], 'http_user' );
3387 + if ( false === $encrypted ) {
3388 + return false;
3389 + }
3390 + $fields['http_user'] = $encrypted;
3391 + }
3392 + if ( array_key_exists( 'http_pass', $fields ) ) {
3393 + $encrypted = MainWP_Credential_Storage::encrypt_credential( $fields['http_pass'], 'http_pass' );
3394 + if ( false === $encrypted ) {
3395 + return false;
3396 + }
3397 + $fields['http_pass'] = $encrypted;
3398 + }
3264 3399 // Lock the data stream to prevent other processes from updating at the same time.
3265 3400 $table_name = esc_sql( $this->table_name( 'wp' ) );
3266 3401 $sql = $this->wpdb->prepare(
3267 - "SELECT * FROM {$table_name} WHERE id = %d FOR UPDATE",
3402 + "SELECT * FROM {$table_name} WHERE id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3268 3403 $websiteid
3269 3404 );
3270 3405 $this->wpdb->get_row( $sql );
3271 3406
@@ -3347,8 +3482,18 @@
3347 3482
3348 3483 if ( MainWP_Utility::ctype_digit( $websiteid ) && MainWP_Utility::ctype_digit( $userid ) ) {
3349 3484 $website = $this->get_website_by_id( $websiteid );
3350 3485 if ( MainWP_System_Utility::can_edit_website( $website ) ) {
3486 + // MWP-1548: encrypt http_user / http_pass at rest. Same
3487 + // fail-closed contract as add_website -- a non-empty
3488 + // value that fails to encrypt aborts the update so we
3489 + // never overwrite an existing encrypted row with
3490 + // plaintext when the encryption layer is unhealthy.
3491 + $encrypted_http_user = MainWP_Credential_Storage::encrypt_credential( $http_user, 'http_user' );
3492 + $encrypted_http_pass = MainWP_Credential_Storage::encrypt_credential( $http_pass, 'http_pass' );
3493 + if ( false === $encrypted_http_user || false === $encrypted_http_pass ) {
3494 + return false;
3495 + }
3351 3496 // update admin.
3352 3497 $this->wpdb->update(
3353 3498 $this->table_name( 'wp' ),
3354 3499 array(
@@ -3359,10 +3504,10 @@
3359 3504 'verify_certificate' => intval( $verifyCertificate ),
3360 3505 'ssl_version' => intval( $sslVersion ),
3361 3506 'wpe' => intval( $wpe ),
3362 3507 'uniqueId' => $uniqueId,
3363 - 'http_user' => $http_user,
3364 - 'http_pass' => $http_pass,
3508 + 'http_user' => $encrypted_http_user,
3509 + 'http_pass' => $encrypted_http_pass,
3365 3510 'disable_health_check' => $disableHealthChecking,
3366 3511 'health_threshold' => $healthThreshold,
3367 3512 'primary_backup_method' => $backup_method,
3368 3513 ),
@@ -3477,9 +3622,9 @@
3477 3622 $url .= '/';
3478 3623 }
3479 3624 $wp_table = esc_sql( $this->table_name( 'wp' ) );
3480 3625 $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
3481 - $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT );
3626 + $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3482 3627 if ( $results ) {
3483 3628 return $results;
3484 3629 }
3485 3630
@@ -3491,9 +3636,9 @@
3491 3636 $url = str_replace( 'https://', 'https://www.', $url );
3492 3637 $url = str_replace( 'http://', 'http://www.', $url );
3493 3638 }
3494 3639
3495 - $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT );
3640 + $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3496 3641 if ( $results ) {
3497 3642 return $results;
3498 3643 }
3499 3644
@@ -3498,12 +3643,68 @@
3498 3643 }
3499 3644
3500 3645 $url = str_replace( array( 'https://www.', 'http://www.', 'https://', 'http://', 'www.' ), array( '', '', '', '', '' ), $url );
3501 3646
3502 - return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE replace(replace(replace(replace(replace(wp.url, 'https://www.',''), 'http://www.',''), 'https://', ''), 'http://', ''), 'www.', '') = %s", $url ), OBJECT );
3647 + return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE replace(replace(replace(replace(replace(wp.url, 'https://www.',''), 'http://www.',''), 'https://', ''), 'http://', ''), 'www.', '') = %s", $url ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3503 3648 }
3504 3649
3505 3650 /**
3651 + * Get recently-synced child sites whose stored url differs from the
3652 + * child-reported siteurl.
3653 + *
3654 + * The trailing slash must be normalized in SQL: stored urls always end
3655 + * with a slash while child-reported siteurl values never do, so a plain
3656 + * inequality would match nearly every site.
3657 + *
3658 + * @since 6.2
3659 + *
3660 + * @param int $days Freshness gate: only sites synced within this many days.
3661 + *
3662 + * @return array|object|null Rows with id, url, siteurl or null on failure.
3663 + */
3664 + public function get_websites_with_url_siteurl_mismatch( $days = 30 ) {
3665 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
3666 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
3667 + $minimum_sync = time() - intval( $days ) * DAY_IN_SECONDS;
3668 +
3669 + return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT wp.id, wp.url, wp.siteurl FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.siteurl <> '' AND TRIM(TRAILING '/' FROM wp.url) <> TRIM(TRAILING '/' FROM wp.siteurl) AND wp_sync.dtsSync >= %d", $minimum_sync ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3670 + }
3671 +
3672 + /**
3673 + * Get IDs of child sites that have a non-empty value for a website option.
3674 + *
3675 + * @since 6.2
3676 + *
3677 + * @param string $option_name Website option name.
3678 + * @param int $limit Maximum number of IDs to return.
3679 + *
3680 + * @return array Site IDs.
3681 + */
3682 + public function get_website_ids_with_nonempty_option( $option_name, $limit = 3 ) {
3683 + $options_table = esc_sql( $this->table_name( 'wp_options' ) );
3684 +
3685 + return $this->wpdb->get_col( $this->wpdb->prepare( "SELECT wpid FROM {$options_table} WHERE name = %s AND value <> '' ORDER BY wpid ASC LIMIT %d", $option_name, max( 1, intval( $limit ) ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3686 + }
3687 +
3688 + /**
3689 + * Get one child site whose stored url exactly matches the given URL,
3690 + * excluding a site ID. Used as the duplicate guard before a URL correction.
3691 + *
3692 + * @since 6.2
3693 + *
3694 + * @param string $url URL to match ( trailing slash enforced ).
3695 + * @param int $exclude_id Site ID to exclude.
3696 + *
3697 + * @return object|null Matching row ( id, url ) or null.
3698 + */
3699 + public function get_website_by_exact_url_excluding_id( $url, $exclude_id ) {
3700 + $url = rtrim( (string) $url, '/' );
3701 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
3702 +
3703 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT id, url FROM {$wp_table} WHERE TRIM(TRAILING '/' FROM url) = %s AND id <> %d", $url, intval( $exclude_id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3704 + }
3705 +
3706 + /**
3506 3707 * Method get_websites_to_notice_health_threshold()
3507 3708 *
3508 3709 * Get websites to notice site health.
3509 3710 *
@@ -3537,12 +3738,9 @@
3537 3738 $view_selects = ',' . $opts_view['selects'];
3538 3739 $view_joins = $opts_view['joins'];
3539 3740 }
3540 3741 return $this->wpdb->get_results( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $option_view is a validated SQL subquery
3541 - "SELECT wp.*,wp_sync.* {$view_selects} FROM {$wp_table} wp
3542 - JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid
3543 - {$view_joins}
3544 - WHERE wp.disable_health_check <> 1 AND wp.offline_check_result = 1 AND ( {$where_global_threshold} OR{$where_site_threshold} ) AND wp_sync.health_site_noticed = 0 " .
3742 + "SELECT wp.*,wp_sync.* {$view_selects} FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid {$view_joins} WHERE wp.disable_health_check <> 1 AND wp.offline_check_result = 1 AND ( {$where_global_threshold} OR{$where_site_threshold} ) AND wp_sync.health_site_noticed = 0 " . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd, the view fragments come from get_wp_options_join(), which allowlists option names, and both threshold fragments are literals picked above.
3545 3743 $where . ' GROUP BY wp.id ',
3546 3744 OBJECT
3547 3745 );
3548 3746 }
@@ -3566,12 +3764,11 @@
3566 3764 $view_selects = ',' . $opts_view['selects'];
3567 3765 $view_joins = $opts_view['joins'];
3568 3766 }
3569 3767
3768 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- is a validated SQL subquery.
3570 3769 return $this->wpdb->get_results(
3571 - "SELECT wp.*{$view_selects} FROM {$wp_table} wp
3572 - {$view_joins}" . // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- is a validated SQL subquery.
3573 - ' WHERE wp.suspended = 0 AND wp.http_code_noticed = 0 AND wp.offline_check_result = -1 ' .
3770 + "SELECT wp.*{$view_selects} FROM {$wp_table} wp {$view_joins}" . ' WHERE wp.suspended = 0 AND wp.http_code_noticed = 0 AND wp.offline_check_result = -1 ' . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; the view fragments come from get_wp_options_join(), which allowlists option names.
3574 3771 $where . ' GROUP BY wp.id ',
3575 3772 OBJECT
3576 3773 );
3577 3774 }
@@ -3820,18 +4017,28 @@
3820 4017 *
3821 4018 * @return mixed Result
3822 4019 */
3823 4020 public function insert_lookup_item( $item_name, $item_id, $obj_name, $obj_id ) {
4021 + // get_lookup_items() and delete_lookup_items() match item_name as given, so a
4022 + // name that sanitizing would alter is refused instead of being stored under a
4023 + // value the caller can never read back or delete.
4024 + if ( ! is_string( $item_name ) || sanitize_text_field( $item_name ) !== $item_name ) {
4025 + return false;
4026 + }
3824 4027 if ( empty( $item_name ) || empty( $item_id ) || empty( $obj_name ) || empty( $obj_id ) ) {
3825 4028 return false;
3826 4029 }
3827 4030 $data = array(
3828 - 'item_name' => 'cost',
4031 + 'item_name' => $item_name,
3829 4032 'item_id' => $item_id,
3830 4033 'object_name' => $obj_name,
3831 4034 'object_id' => $obj_id,
3832 4035 );
3833 - $this->wpdb->insert( $this->table_name( 'lookup_item_objects' ), $data );
4036 + // wpdb refuses a value past the column length before any SQL runs, and
4037 + // insert_id would still hold the previous insert's id.
4038 + if ( false === $this->wpdb->insert( $this->table_name( 'lookup_item_objects' ), $data ) ) {
4039 + return false;
4040 + }
3834 4041 return $this->wpdb->insert_id; // must return lookup id.
3835 4042 }
3836 4043
3837 4044 /**
@@ -3893,10 +4100,16 @@
3893 4100 }
3894 4101
3895 4102
3896 4103 /**
3897 - * Return the user data for the given consumer_key.
4104 + * Insert a new REST API key row and return the credential payload.
3898 4105 *
4106 + * Returns an array with key_id, user_id, plaintext consumer_key,
4107 + * plaintext consumer_secret, and key_permissions on success. Returns
4108 + * false when the wpdb->insert() call fails (no current user, missing
4109 + * table, schema mismatch, etc.). Callers must check the return type
4110 + * before treating it as an array.
4111 + *
3899 4112 * @param string $consumer_key Consumer key.
3900 4113 * @param string $consumer_secret Secret key.
3901 4114 * @param string $scope scope.
3902 4115 * @param string $description description.
@@ -3902,9 +4115,9 @@
3902 4115 * @param string $description description.
3903 4116 * @param int $enabled 1 or 0.
3904 4117 * @param array $others others.
3905 4118 *
3906 - * @return array
4119 + * @return array|false Credential payload on success, false on failure.
3907 4120 */
3908 4121 public function insert_rest_api_key( $consumer_key, $consumer_secret, $scope, $description, $enabled, $others = array() ) {
3909 4122 global $current_user;
3910 4123
@@ -3915,18 +4128,19 @@
3915 4128 if ( empty( $user_id ) ) {
3916 4129 return false;
3917 4130 }
3918 4131
3919 - if ( ! is_array( $others ) ) {
3920 - $others = array();
3921 - }
4132 + unset( $others ); // Parameter retained for signature compatibility; key_pass/key_type fields are vestigial after MWP-1544 cleanup.
3922 4133
3923 - $pass = isset( $others['key_pass'] ) ? $others['key_pass'] : '';
3924 - $type = isset( $others['key_type'] ) ? intval( $others['key_type'] ) : 0;
4134 + // Hash the consumer_secret with WordPress's password hasher so the
4135 + // value at rest is no longer reversible by a DB-read primitive.
4136 + // The plaintext is returned to the caller below so it can be shown
4137 + // to the admin once at creation time. See MWP-1540.
4138 + $hashed_secret = wp_hash_password( $consumer_secret );
3925 4139
3926 4140 // Created API keys.
3927 4141 $permissions = in_array( $scope, array( 'read', 'write', 'delete', 'read_write' ), true ) ? sanitize_text_field( $scope ) : 'read';
3928 - $this->wpdb->insert(
4142 + $inserted = $this->wpdb->insert(
3929 4143 $this->table_name( 'api_keys' ),
3930 4144 array(
3931 4145 'user_id' => $user_id,
3932 4146 'description' => $description,
@@ -3931,13 +4145,11 @@
3931 4145 'user_id' => $user_id,
3932 4146 'description' => $description,
3933 4147 'permissions' => $permissions,
3934 4148 'consumer_key' => mainwp_api_hash( $consumer_key ),
3935 - 'consumer_secret' => $consumer_secret,
4149 + 'consumer_secret' => $hashed_secret,
3936 4150 'truncated_key' => substr( $consumer_key, -7 ),
3937 4151 'enabled' => $enabled,
3938 - 'key_pass' => $pass,
3939 - 'key_type' => $type,
3940 4152 ),
3941 4153 array(
3942 4154 '%d',
3943 4155 '%s',
@@ -3945,20 +4157,29 @@
3945 4157 '%s',
3946 4158 '%s',
3947 4159 '%s',
3948 4160 '%d',
3949 - '%s',
3950 - '%d',
3951 4161 ),
3952 4162 );
3953 4163
3954 - return array(
3955 - 'key_id' => $this->wpdb->insert_id,
3956 - 'user_id' => $user_id,
3957 - 'consumer_key' => $consumer_key,
3958 - 'consumer_secret' => $consumer_secret,
3959 - 'key_permissions' => $permissions,
3960 - );
4164 + // wpdb->insert() returns false on failure. Without this guard the
4165 + // function would still hand back the plaintext consumer_secret plus
4166 + // $wpdb->insert_id, but that insert_id is the LAST successful insert
4167 + // on the connection (typically from earlier in the same request),
4168 + // not this row. The caller's empty-key_id check would pass and the
4169 + // operator would receive a credential that was never persisted.
4170 + // See MWP-1540 PR review feedback.
4171 + if ( false === $inserted ) {
4172 + return false;
4173 + }
4174 +
4175 + return array(
4176 + 'key_id' => $this->wpdb->insert_id,
4177 + 'user_id' => $user_id,
4178 + 'consumer_key' => $consumer_key,
4179 + 'consumer_secret' => $consumer_secret,
4180 + 'key_permissions' => $permissions,
4181 + );
3961 4182 }
3962 4183
3963 4184 /**
3964 4185 * Update rest api key.
@@ -3992,9 +4213,9 @@
3992 4213 * @return bool result.
3993 4214 */
3994 4215 public function is_existed_enabled_rest_key() {
3995 4216 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
3996 - $enabled = $this->wpdb->get_row( "SELECT * FROM {$table_name} WHERE enabled = 1 LIMIT 1" );
4217 + $enabled = $this->wpdb->get_row( "SELECT * FROM {$table_name} WHERE enabled = 1 LIMIT 1" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3997 4218 return $enabled ? true : false;
3998 4219 }
3999 4220
4000 4221 /**
@@ -4005,9 +4226,9 @@
4005 4226 * @return array
4006 4227 */
4007 4228 public function get_rest_api_key_by( $id ) {
4008 4229 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
4009 - return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table_name} WHERE key_id = %d", $id ) );
4230 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table_name} WHERE key_id = %d", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4010 4231 }
4011 4232
4012 4233 /**
4013 4234 * Method remove_rest_api_key().
@@ -4017,9 +4238,9 @@
4017 4238 * @return array
4018 4239 */
4019 4240 public function remove_rest_api_key( $id ) {
4020 4241 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
4021 - return $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE key_id = %s", $id ) );
4242 + return $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE key_id = %s", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4022 4243 }
4023 4244
4024 4245 /**
4025 4246 * Method get_rest_api_keys().
@@ -4027,9 +4248,9 @@
4027 4248 * @return array
4028 4249 */
4029 4250 public function get_rest_api_keys() {
4030 4251 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
4031 - return $this->wpdb->get_results( "SELECT * FROM {$table_name} ORDER BY key_id DESC" );
4252 + return $this->wpdb->get_results( "SELECT * FROM {$table_name} ORDER BY key_id DESC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4032 4253 }
4033 4254
4034 4255
4035 4256 /**
@@ -4099,9 +4320,9 @@
4099 4320 * @return mixed result
4100 4321 */
4101 4322 public function get_regular_process_by_item_id_type_slug( $item_id, $type, $process_slug ) {
4102 4323 $table_name = esc_sql( $this->table_name( 'schedule_processes' ) );
4103 - return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT pr.* FROM {$table_name} pr WHERE pr.item_id = %d AND pr.type = %s AND pr.process_slug = %s", $item_id, $type, $process_slug ) );
4324 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT pr.* FROM {$table_name} pr WHERE pr.item_id = %d AND pr.type = %s AND pr.process_slug = %s", $item_id, $type, $process_slug ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4104 4325 }
4105 4326
4106 4327 /**
4107 4328 * Log SQL queries for debugging via hook.
@@ -4131,6 +4352,50 @@
4131 4352 $params = apply_filters( 'mainwp_log_system_query_params', $params, $sql, $caller );
4132 4353 if ( is_array( $params ) && ! empty( $params['dev_log_query'] ) && ! empty( $sql ) ) {
4133 4354 do_action( 'mainwp_log_system_query', $params, $sql, $caller );
4134 4355 }
4356 + }
4357 +
4358 + /**
4359 + * Method get_core_tables().
4360 + *
4361 + * Returns the list of core tables used by MainWP for validation data integrity and other purposes.
4362 + *
4363 + * @return array Core tables info
4364 + */
4365 + public function get_core_tables() {
4366 +
4367 + $core_tables = array(
4368 + $this->table_name( 'wp_clients' ),
4369 + $this->table_name( 'wp_clients_fields' ),
4370 + $this->table_name( 'wp_clients_field_values' ),
4371 + $this->table_name( 'wp_clients_contacts' ),
4372 + $this->table_name( 'monitors' ),
4373 + $this->table_name( 'monitor_heartbeat' ),
4374 + $this->table_name( 'monitor_stat_hourly' ),
4375 + $this->table_name( 'wp' ),
4376 + $this->table_name( 'wp_sync' ),
4377 + $this->table_name( 'wp_options' ),
4378 + $this->table_name( 'wp_settings_backup' ),
4379 + $this->table_name( 'users' ),
4380 + $this->table_name( 'wp_status' ),
4381 + $this->table_name( 'group' ),
4382 + $this->table_name( 'wp_group' ),
4383 + $this->table_name( 'lookup_item_objects' ),
4384 + $this->table_name( 'wp_backup_progress' ),
4385 + $this->table_name( 'wp_backup' ),
4386 + $this->table_name( 'api_keys' ),
4387 + $this->table_name( 'action_log' ),
4388 + $this->table_name( 'request_log' ),
4389 + $this->table_name( 'schedule_processes' ),
4390 + $this->table_name( 'cost_tracker' ),
4391 + );
4392 +
4393 + if ( ! defined( 'MAINWP_MODULE_LOG_ENABLED' ) || MAINWP_MODULE_LOG_ENABLED ) {
4394 + $core_tables[] = $this->table_name( 'wp_logs' );
4395 + $core_tables[] = $this->table_name( 'wp_logs_meta' );
4396 + $core_tables[] = $this->table_name( 'wp_logs_meta_archive' );
4397 + }
4398 +
4399 + return apply_filters( 'mainwp_database_core_tables', $core_tables );
4135 4400 }
4136 4401 }