diff --git a/inc/admin-pages/class-base-customer-facing-admin-page.php b/inc/admin-pages/class-base-customer-facing-admin-page.php index 5740ee1a8..8a348b1f3 100644 --- a/inc/admin-pages/class-base-customer-facing-admin-page.php +++ b/inc/admin-pages/class-base-customer-facing-admin-page.php @@ -74,12 +74,18 @@ public function init(): void { add_action('updated_user_meta', [$this, 'save_settings'], 10, 4); + /* + * This form customizes the network-admin menu title/position/icon of + * the customer-facing pages and persists a network-level setting, so it + * must require network-admin rights. The previous 'exist' capability + * allowed any logged-in user to submit it. + */ wu_register_form( "edit_admin_page_$this->id", [ 'render' => [$this, 'render_edit_page'], 'handler' => [$this, 'handle_edit_page'], - 'capability' => 'exist', + 'capability' => 'manage_network', ] ); diff --git a/inc/admin-pages/class-system-info-admin-page.php b/inc/admin-pages/class-system-info-admin-page.php index 26aae0ded..451520be2 100644 --- a/inc/admin-pages/class-system-info-admin-page.php +++ b/inc/admin-pages/class-system-info-admin-page.php @@ -561,6 +561,10 @@ public function get_data() { */ public function generate_text_file_system_info(): void { + if ( ! current_user_can('manage_network')) { + wp_die(esc_html__('You do not have permission to access this resource.', 'ultimate-multisite'), 403); + } + $file_name = sprintf("$this->id-%s.txt", gmdate('Y-m-d')); header('Content-Description: File Transfer'); diff --git a/inc/admin-pages/class-view-logs-admin-page.php b/inc/admin-pages/class-view-logs-admin-page.php index b9fc47359..e05c0eaf1 100644 --- a/inc/admin-pages/class-view-logs-admin-page.php +++ b/inc/admin-pages/class-view-logs-admin-page.php @@ -141,15 +141,21 @@ public function get_menu_title() { */ public function handle_view_logs() { + if ( ! current_user_can('manage_network')) { + wp_die(esc_html__('You do not have permission to access this resource.', 'ultimate-multisite'), 403); + } + + $logs_folder = Logger::get_logs_folder(); + $logs_list = list_files( - Logger::get_logs_folder(), + $logs_folder, 2, [ 'index.html', ] ); - $logs_list = array_combine(array_values($logs_list), array_map(fn($file) => str_replace(Logger::get_logs_folder(), '', (string) $file), $logs_list)); + $logs_list = array_combine(array_values($logs_list), array_map(fn($file) => str_replace($logs_folder, '', (string) $file), $logs_list)); if (empty($logs_list)) { $logs_list[''] = __('No log files found', 'ultimate-multisite'); @@ -161,9 +167,24 @@ public function handle_view_logs() { $contents = ''; - // Security check - if ($file && ! stristr((string) $file, Logger::get_logs_folder())) { - wp_die(esc_html__('You can see files that are not Ultimate Multisite\'s logs', 'ultimate-multisite')); + /* + * Security check: confine the requested file to the logs folder. + * + * realpath() resolves any '..' traversal so a crafted path cannot + * escape the logs directory (the previous substring check accepted + * any path that merely *contained* the logs folder, e.g. + * "/../../../wp-config.php"). The resolved path must also be a + * real file located under the resolved logs folder. + */ + if ($file) { + $real_file = realpath((string) $file); + $real_folder = realpath($logs_folder); + + if (false === $real_file || false === $real_folder || ! str_starts_with($real_file, trailingslashit($real_folder))) { + wp_die(esc_html__('You can only view Ultimate Multisite log files.', 'ultimate-multisite'), 403); + } + + $file = $real_file; } if ( ! $file && ! empty($logs_list)) { diff --git a/inc/class-ajax.php b/inc/class-ajax.php index 3ae6031a5..940c21f81 100644 --- a/inc/class-ajax.php +++ b/inc/class-ajax.php @@ -86,6 +86,19 @@ public function refresh_list_table(): void { */ public function search_models(): void { + /* + * The selectize search endpoint returns network-wide objects + * (customers, memberships, payments and — for the 'user' model — + * WordPress user logins and email addresses). It is only ever wired + * to network-admin forms, so restrict it to network administrators + * to prevent any logged-in user from enumerating that data. + */ + if ( ! current_user_can('manage_network')) { + wp_send_json([]); + + return; + } + /** * Fires before the processing of the search request. * diff --git a/inc/class-dashboard-widgets.php b/inc/class-dashboard-widgets.php index 05825c014..3a15406f8 100644 --- a/inc/class-dashboard-widgets.php +++ b/inc/class-dashboard-widgets.php @@ -320,10 +320,16 @@ public function output_widget_summary(): void { */ public function process_ajax_fetch_rss(): void { + if ( ! current_user_can('manage_network')) { + wp_die('', '', ['response' => 403]); + } + + $default_url = 'https://community.wpultimo.com/topics/feed'; + $atts = wp_parse_args( $_GET, // phpcs:ignore WordPress.Security.NonceVerification.Recommended [ - 'url' => 'https://community.wpultimo.com/topics/feed', + 'url' => $default_url, 'title' => __('Forum Discussions', 'ultimate-multisite'), 'items' => 3, 'show_summary' => 1, @@ -332,6 +338,15 @@ public function process_ajax_fetch_rss(): void { ] ); + /* + * Never let the request control the outbound URL. This widget only + * renders the plugin's own community feed; honouring a request-supplied + * URL would turn the endpoint into a server-side request forgery (SSRF) + * probe against internal hosts. Site owners can still override the feed + * server-side via the filter below. + */ + $atts['url'] = apply_filters('wu_dashboard_rss_feed_url', $default_url); + wp_widget_rss_output($atts); exit; @@ -377,6 +392,10 @@ public function process_ajax_fetch_events(): void { */ public function handle_table_csv(): void { + if ( ! current_user_can('manage_network')) { + wp_die('', '', ['response' => 403]); + } + $date_range = wu_request('date_range'); $headers = json_decode(stripslashes((string) wu_request('headers'))); $data = json_decode(stripslashes((string) wu_request('data'))); diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 702fe873f..704a79b33 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -1226,6 +1226,10 @@ public static function dns_get_record($domain) { */ public function get_dns_records(): void { + if ( ! current_user_can('manage_network')) { + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite'))); + } + $domain = wu_request('domain'); if ( ! $domain) { @@ -1491,6 +1495,10 @@ public function maybe_auto_promote_primary_domain($old_stage, $new_stage, $domai */ public function test_integration() { + if ( ! current_user_can('manage_network')) { + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite'))); + } + $integration_id = wu_request('integration', 'none'); // Try the new Integration Registry first diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index b8b4e6fa0..86ae886c2 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -539,6 +539,10 @@ public function async_get_site_screenshot($site_id) { */ public function get_site_screenshot(): void { + if ( ! current_user_can('manage_network')) { + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite'))); + } + $site_id = wu_request('site_id'); $site = wu_get_site($site_id); diff --git a/inc/site-templates/class-template-placeholders.php b/inc/site-templates/class-template-placeholders.php index 58ef28377..fea90db43 100644 --- a/inc/site-templates/class-template-placeholders.php +++ b/inc/site-templates/class-template-placeholders.php @@ -146,6 +146,10 @@ public function placeholder_replacer($content): string { */ public function serve_placeholders_via_ajax(): void { + if ( ! current_user_can('manage_network')) { + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite'))); + } + wp_send_json_success($this->placeholders_as_saved); } @@ -157,7 +161,16 @@ public function serve_placeholders_via_ajax(): void { */ public function save_placeholders(): void { - if ( ! check_ajax_referer('wu_edit_placeholders_editing')) { + if ( ! current_user_can('manage_network')) { + wp_send_json( + [ + 'code' => 'not-enough-permissions', + 'message' => __('You don\'t have permission to alter placeholders.', 'ultimate-multisite'), + ] + ); + } + + if ( ! check_ajax_referer('wu_edit_placeholders_editing', false, false)) { wp_send_json( [ 'code' => 'not-enough-permissions',