From 8c8aa6dea3cea9566dc0da1303b269b586f573cb Mon Sep 17 00:00:00 2001 From: vuckro Date: Wed, 10 Jun 2026 11:42:43 +0200 Subject: [PATCH 1/2] fix(security): add capability checks to privileged network-admin AJAX endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several network-admin AJAX endpoints were registered on wp_ajax_* with no capability check, so any authenticated user (including a subscriber on a sub-site) could reach them. None of these are wired to customer-facing UI; they all back network-admin tools. This enforces manage_network on: - Ajax::search_models / search_all_models — returned network-wide objects and, for the 'user' model, WordPress logins and email addresses (user/email enumeration). - View_Logs_Admin_Page::handle_view_logs — also replaces the substring "is it under the logs folder?" check with realpath() containment so a crafted path can no longer traverse out of the logs directory and read arbitrary files (e.g. wp-config.php). - System_Info_Admin_Page::generate_text_file_system_info — system report. - Dashboard_Widgets::process_ajax_fetch_rss — also pins the outbound feed URL to the plugin's own community feed (filterable) so the endpoint can no longer be used as an SSRF probe; and handle_table_csv. - Domain_Manager::get_dns_records and ::test_integration — DNS lookups and hosting-provider connection tests. - Site_Manager::get_site_screenshot — screenshot scraper. - Template_Placeholders::save_placeholders / serve_placeholders_via_ajax. - Base_Customer_Facing_Admin_Page customize form: capability 'exist' (any logged-in user) raised to 'manage_network'. Co-Authored-By: Claude Fable 5 --- .../class-base-customer-facing-admin-page.php | 8 ++++- .../class-system-info-admin-page.php | 4 +++ .../class-view-logs-admin-page.php | 31 ++++++++++++++++--- inc/class-ajax.php | 13 ++++++++ inc/class-dashboard-widgets.php | 21 ++++++++++++- inc/managers/class-domain-manager.php | 8 +++++ inc/managers/class-site-manager.php | 4 +++ .../class-template-placeholders.php | 15 ++++++++- 8 files changed, 96 insertions(+), 8 deletions(-) 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', From 02a06edbaa79f62696ee7db4860f7061cdff977f Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 16 Jun 2026 20:15:37 -0600 Subject: [PATCH 2/2] fix: add AJAX nonce checks --- assets/js/dns-table.js | 13 +++++++------ assets/js/integration-test.js | 2 ++ assets/js/screenshot-scraper.js | 3 ++- inc/admin-pages/class-domain-edit-admin-page.php | 1 + .../class-hosting-integration-wizard-admin-page.php | 1 + inc/admin-pages/class-site-edit-admin-page.php | 8 ++++++++ inc/managers/class-domain-manager.php | 12 ++++++++++-- inc/managers/class-site-manager.php | 6 +++++- 8 files changed, 36 insertions(+), 10 deletions(-) diff --git a/assets/js/dns-table.js b/assets/js/dns-table.js index a519b49e9..aa5aa3458 100644 --- a/assets/js/dns-table.js +++ b/assets/js/dns-table.js @@ -1,7 +1,7 @@ /* global ajaxurl, Vue, wu_dns_table_config, wu_ajax_error */ (function($) { - wu_dns_table = new Vue({ + window.wu_dns_table = new Vue({ el: '#wu-dns-table', data: { error: null, @@ -23,26 +23,27 @@ url: ajaxurl, data: { action: 'wu_get_dns_records', - domain: window.wu_dns_table_config.domain, + domain: wu_dns_table_config.domain, + _ajax_nonce: wu_dns_table_config.nonce, }, success(data) { - Vue.set(wu_dns_table, 'loading', false); + Vue.set(window.wu_dns_table, 'loading', false); if (data.success) { - Vue.set(wu_dns_table, 'results', data.data); + Vue.set(window.wu_dns_table, 'results', data.data); } else { - Vue.set(wu_dns_table, 'error', data.data); + Vue.set(window.wu_dns_table, 'error', data.data); } }, error(jqXHR) { - Vue.set(wu_dns_table, 'loading', false); + Vue.set(window.wu_dns_table, 'loading', false); wu_ajax_error(jqXHR); diff --git a/assets/js/integration-test.js b/assets/js/integration-test.js index 0c0dcec0f..60b487abb 100644 --- a/assets/js/integration-test.js +++ b/assets/js/integration-test.js @@ -1,3 +1,4 @@ +/* global ajaxurl, Vue, wu_integration_test_data */ (function($) { $(document).ready(function() { new Vue({ @@ -18,6 +19,7 @@ data: { action: 'wu_test_hosting_integration', integration: wu_integration_test_data.integration_id, + _ajax_nonce: wu_integration_test_data.nonce, }, success(response) { that.loading = false; diff --git a/assets/js/screenshot-scraper.js b/assets/js/screenshot-scraper.js index 6c84f2013..b930b933b 100644 --- a/assets/js/screenshot-scraper.js +++ b/assets/js/screenshot-scraper.js @@ -1,4 +1,4 @@ -/* global wu_block_ui, wu_ajax_error */ +/* global wu_block_ui, wu_ajax_error, wu_screenshot_scraper */ (function($) { $(document).ready(function() { @@ -18,6 +18,7 @@ data: { action: 'wu_get_screenshot', site_id: $('#id').val(), + _ajax_nonce: wu_screenshot_scraper.nonce, }, error(jqXHR) { diff --git a/inc/admin-pages/class-domain-edit-admin-page.php b/inc/admin-pages/class-domain-edit-admin-page.php index eb3af0ba1..bc56628a3 100644 --- a/inc/admin-pages/class-domain-edit-admin-page.php +++ b/inc/admin-pages/class-domain-edit-admin-page.php @@ -170,6 +170,7 @@ public function register_scripts(): void { 'wu_dns_table_config', [ 'domain' => $this->get_object()->get_domain(), + 'nonce' => wp_create_nonce('wu_get_dns_records'), ] ); diff --git a/inc/admin-pages/class-hosting-integration-wizard-admin-page.php b/inc/admin-pages/class-hosting-integration-wizard-admin-page.php index a888d067e..2f2dd5e01 100644 --- a/inc/admin-pages/class-hosting-integration-wizard-admin-page.php +++ b/inc/admin-pages/class-hosting-integration-wizard-admin-page.php @@ -406,6 +406,7 @@ public function section_test(): void { 'wu-integration-test', 'var wu_integration_test_data = { integration_id: "' . esc_js($this->integration->get_id()) . '", + nonce: "' . esc_js(wp_create_nonce('wu_test_hosting_integration')) . '", waiting_message: "' . esc_js(__('Waiting for results...', 'ultimate-multisite')) . '", error_message: "' . esc_js(__('Connection test failed. Please try again.', 'ultimate-multisite')) . '" };', diff --git a/inc/admin-pages/class-site-edit-admin-page.php b/inc/admin-pages/class-site-edit-admin-page.php index 7e3fc6ed4..3c8ab8396 100644 --- a/inc/admin-pages/class-site-edit-admin-page.php +++ b/inc/admin-pages/class-site-edit-admin-page.php @@ -95,6 +95,14 @@ public function register_scripts(): void { wp_enqueue_script('wu-screenshot-scraper'); + wp_localize_script( + 'wu-screenshot-scraper', + 'wu_screenshot_scraper', + [ + 'nonce' => wp_create_nonce('wu_get_screenshot'), + ] + ); + wp_enqueue_media(); wp_enqueue_editor(); diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 704a79b33..f5fbafd6a 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -1227,7 +1227,11 @@ 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'))); + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite')), 403); + } + + if ( ! check_ajax_referer('wu_get_dns_records', false, false)) { + wp_send_json_error(new \WP_Error('invalid_nonce', __('Security check failed. Please try again.', 'ultimate-multisite')), 403); } $domain = wu_request('domain'); @@ -1496,7 +1500,11 @@ 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'))); + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite')), 403); + } + + if ( ! check_ajax_referer('wu_test_hosting_integration', false, false)) { + wp_send_json_error(new \WP_Error('invalid_nonce', __('Security check failed. Please try again.', 'ultimate-multisite')), 403); } $integration_id = wu_request('integration', 'none'); diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 86ae886c2..9b315c2a2 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -540,7 +540,11 @@ 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'))); + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite')), 403); + } + + if ( ! check_ajax_referer('wu_get_screenshot', false, false)) { + wp_send_json_error(new \WP_Error('invalid_nonce', __('Security check failed. Please try again.', 'ultimate-multisite')), 403); } $site_id = wu_request('site_id');