From 6e7ab40f93846132de3f023740a4940cdb847cdf Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 5 May 2026 11:38:02 -0600 Subject: [PATCH] fix: secure template switching permission states --- .../class-template-switching-admin-page.php | 27 +++++- inc/models/class-membership.php | 5 +- inc/models/class-site.php | 5 +- inc/ui/class-template-switching-element.php | 88 ++++++++++++++++++- tests/WP_Ultimo/Models/Site_Test.php | 24 +++++ .../UI/Template_Switching_Element_Test.php | 62 +++++++++++++ 6 files changed, 205 insertions(+), 6 deletions(-) diff --git a/inc/admin-pages/customer-panel/class-template-switching-admin-page.php b/inc/admin-pages/customer-panel/class-template-switching-admin-page.php index 071691702..912fd00ce 100644 --- a/inc/admin-pages/customer-panel/class-template-switching-admin-page.php +++ b/inc/admin-pages/customer-panel/class-template-switching-admin-page.php @@ -164,7 +164,30 @@ public function output(): void { * @return void */ public function register_widgets(): void { - \WP_Ultimo\UI\Simple_Text_Element::get_instance()->as_inline_content(get_current_screen()->id, 'wu_dash_before_metaboxes'); - \WP_Ultimo\UI\Template_Switching_Element::get_instance()->as_inline_content(get_current_screen()->id, 'wu_dash_before_metaboxes'); + add_action( + 'wu_dash_before_metaboxes', + function () { + $screen_id = get_current_screen()->id; + + ob_start(); + + \WP_Ultimo\UI\Simple_Text_Element::get_instance()->as_inline_content($screen_id, 'wu_template_switching_content'); + \WP_Ultimo\UI\Template_Switching_Element::get_instance()->as_inline_content($screen_id, 'wu_template_switching_content'); + + do_action('wu_template_switching_content'); + + $content = ob_get_clean(); + + if (trim($content)) { + echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Buffered widget output is escaped by each element. + return; + } + + printf( + '

%s

', + esc_html__('Template switching is not available right now. Please contact your network administrator.', 'ultimate-multisite') + ); + } + ); } } diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index 7b746c0e4..a25d00c8f 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -467,7 +467,10 @@ public function is_customer_allowed($customer_id = false) { $customer_id = $customer ? $customer->get_id() : 0; } - $allowed = absint($customer_id) === absint($this->get_customer_id()); + $customer_id = absint($customer_id); + $membership_customer_id = absint($this->get_customer_id()); + + $allowed = $customer_id && $membership_customer_id && $membership_customer_id === $customer_id; return apply_filters('wu_membership_is_customer_allowed', $allowed, $customer_id, $this); } diff --git a/inc/models/class-site.php b/inc/models/class-site.php index 3dceaa8a1..cef45e255 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1053,7 +1053,10 @@ public function is_customer_allowed($customer_id = false) { $customer_id = $customer ? $customer->get_id() : 0; } - $allowed = absint($customer_id) === absint($this->get_customer_id()); + $customer_id = absint($customer_id); + $site_customer_id = absint($this->get_customer_id()); + + $allowed = $customer_id && $site_customer_id && $site_customer_id === $customer_id; return apply_filters('wu_site_is_customer_allowed', $allowed, $customer_id, $this); } diff --git a/inc/ui/class-template-switching-element.php b/inc/ui/class-template-switching-element.php index a49a33dc6..968d2b943 100644 --- a/inc/ui/class-template-switching-element.php +++ b/inc/ui/class-template-switching-element.php @@ -23,6 +23,30 @@ class Template_Switching_Element extends Base_Element { use \WP_Ultimo\Traits\Singleton; + /** + * Permission state when the current customer can switch templates. + * + * @since 2.9.3 + * @var string + */ + const STATE_OK = 'ok'; + + /** + * Permission state when the current site is not linked to a membership. + * + * @since 2.9.3 + * @var string + */ + const STATE_NO_MEMBERSHIP = 'no_membership'; + + /** + * Permission state when the current customer is not allowed to manage the site. + * + * @since 2.9.3 + * @var string + */ + const STATE_NOT_ALLOWED = 'not_allowed'; + /** * The id of the element. * @@ -57,6 +81,14 @@ class Template_Switching_Element extends Base_Element { */ protected $products; + /** + * Current template-switching permission state. + * + * @since 2.9.3 + * @var string + */ + protected $permission_state = self::STATE_NOT_ALLOWED; + /** * The icon of the UI element. * e.g. return fa fa-search @@ -228,12 +260,13 @@ public function setup() { $this->site = wu_get_current_site(); if ( ! $this->site || ! $this->site->is_customer_allowed()) { - $this->set_display(false); + $this->permission_state = self::STATE_NOT_ALLOWED; return; } $this->membership = $this->site->get_membership(); + $this->permission_state = $this->membership ? self::STATE_OK : self::STATE_NO_MEMBERSHIP; $this->products = []; @@ -284,6 +317,11 @@ public function switch_template() { return; } + if ( ! $this->site->is_customer_allowed()) { + wp_send_json_error(new \WP_Error('not_authorized', __('You are not allowed to switch templates for this site.', 'ultimate-multisite'))); + return; + } + $template_id = (int) wu_request('template_id', ''); // false means MODE_DEFAULT (no restriction) — all templates are allowed. @@ -356,12 +394,30 @@ public function switch_template() { */ public function output($atts, $content = null) { + if ( ! $this->site || self::STATE_NOT_ALLOWED === $this->permission_state) { + $this->render_not_allowed_notice(); + + return; + } + if ($this->site) { + if (self::STATE_NO_MEMBERSHIP === $this->permission_state) { + $this->render_no_membership_notice(); + + $atts['template_selection_sites'] = $this->defaults()['template_selection_sites']; + } + $filter_template_limits = \WP_Ultimo\Limits\Site_Template_Limits::get_instance(); $atts['products'] = $this->products; - $template_selection_field = $filter_template_limits->maybe_filter_template_selection_options($atts); + if (self::STATE_NO_MEMBERSHIP === $this->permission_state) { + $template_selection_field = [ + 'sites' => array_filter(array_map('absint', explode(',', $atts['template_selection_sites']))), + ]; + } else { + $template_selection_field = $filter_template_limits->maybe_filter_template_selection_options($atts); + } if ( ! isset($template_selection_field['sites'])) { $template_selection_field['sites'] = []; @@ -505,6 +561,34 @@ public function output($atts, $content = null) { } } + /** + * Render the not-authorized notice. + * + * @since 2.9.3 + * @return void + */ + protected function render_not_allowed_notice() { + + printf( + '

%s

', + esc_html__('Template switching is not available right now. Please contact your network administrator for help with this site.', 'ultimate-multisite') + ); + } + + /** + * Render the no-membership informational notice. + * + * @since 2.9.3 + * @return void + */ + protected function render_no_membership_notice() { + + printf( + '

%s

', + esc_html__('This site is not currently linked to a membership, so plan-specific template restrictions do not apply.', 'ultimate-multisite') + ); + } + /** * Returns the list of available pricing table templates. * diff --git a/tests/WP_Ultimo/Models/Site_Test.php b/tests/WP_Ultimo/Models/Site_Test.php index f30d90b85..df4aa2b0e 100644 --- a/tests/WP_Ultimo/Models/Site_Test.php +++ b/tests/WP_Ultimo/Models/Site_Test.php @@ -996,6 +996,30 @@ public function test_is_customer_allowed_different_customer(): void { $this->assertFalse($result, 'is_customer_allowed should return false for a different customer ID.'); } + /** + * Test is_customer_allowed denies zero requester versus zero owner. + */ + public function test_is_customer_allowed_zero_versus_zero_denied(): void { + wp_set_current_user(0); + $this->site->set_customer_id(0); + + $result = $this->site->is_customer_allowed(0); + + $this->assertFalse($result, 'is_customer_allowed should deny access when neither side has a known customer ID.'); + } + + /** + * Test is_customer_allowed denies known requester versus unlinked site. + */ + public function test_is_customer_allowed_known_customer_versus_unlinked_site_denied(): void { + $customer_id = $this->customer->get_id(); + $this->site->set_customer_id(0); + + $result = $this->site->is_customer_allowed($customer_id); + + $this->assertFalse($result, 'is_customer_allowed should deny access when the site has no linked customer ID.'); + } + /** * Test get_customer returns false when customer_id is 0. */ diff --git a/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php b/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php index 7f4711af5..739991a8a 100644 --- a/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php +++ b/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php @@ -53,6 +53,7 @@ public function tear_down(): void { } $ref->setValue( $element, null ); + WP_Ultimo()->currents->set_customer( false ); parent::tear_down(); } @@ -167,6 +168,9 @@ public function test_switch_template_missing_site_emits_json_error(): void { * Empty template_id must yield a JSON error body, not silence. */ public function test_switch_template_missing_template_id_emits_json_error(): void { + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + grant_super_admin( $user_id ); + wp_set_current_user( $user_id ); // Force a fake site object so the "missing site context" guard is bypassed // and we hit the template_id check. @@ -196,4 +200,62 @@ public function test_switch_template_missing_template_id_emits_json_error(): voi $this->assertSame( false, $decoded['success'] ); } + /** + * Unauthorized callers must be rejected before template override runs. + */ + public function test_switch_template_rejects_unauthorized_caller(): void { + + $owner_user_id = $this->factory()->user->create( [ 'role' => 'subscriber' ] ); + $other_user_id = $this->factory()->user->create( [ 'role' => 'subscriber' ] ); + + $owner_customer = wu_create_customer( + [ + 'user_id' => $owner_user_id, + 'email_address' => 'template-owner@example.com', + ] + ); + + $other_customer = wu_create_customer( + [ + 'user_id' => $other_user_id, + 'email_address' => 'template-other@example.com', + ] + ); + + if ( is_wp_error( $owner_customer ) || is_wp_error( $other_customer ) ) { + $this->markTestSkipped( 'Customer creation failed.' ); + } + + $site_id = $this->factory()->blog->create(); + $site = wu_get_site( $site_id ); + $site->set_type( 'customer_owned' ); + $site->set_customer_id( $owner_customer->get_id() ); + $site->save(); + + WP_Ultimo()->currents->set_customer( $other_customer ); + wp_set_current_user( $other_user_id ); + $_REQUEST['template_id'] = '1'; + + $element = Template_Switching_Element::get_instance(); + $ref = new \ReflectionProperty( $element, 'site' ); + + if ( PHP_VERSION_ID < 80100 ) { + $ref->setAccessible( true ); + } + + $ref->setValue( $element, $site ); + + $result = $this->call_switch_template(); + + $this->assertTrue( + $result['exception'], + 'wp_send_json_error must be reached for unauthorized template switching.' + ); + + $decoded = $this->decode_json( $result['output'] ); + + $this->assertSame( false, $decoded['success'] ); + $this->assertSame( 'not_authorized', $decoded['data'][0]['code'] ); + } + }