Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -166,5 +166,46 @@ public function output(): 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');

/*
* Defence-in-depth: if both as_inline_content() calls bailed silently
* (e.g. via a third-party `wu_template_switching_should_display`
* filter, or because the page was reached on an unsupported screen),
* the page would render with three empty meta-box columns and no
* explanation. Wrap the action in a buffer so we can detect
* "nothing printed" and emit a fallback notice. Priority 5 starts
* the buffer; priority 999 closes it and emits either the captured
* output or a fallback message.
*/
add_action(
'wu_dash_before_metaboxes',
static function (): void {
ob_start();
},
5
);

add_action(
'wu_dash_before_metaboxes',
static function (): void {
$captured = ob_get_clean();

if (false === $captured || '' === trim((string) $captured)) {
printf(
'<div class="wu-bg-yellow-100 wu-border wu-border-solid wu-border-yellow-300 wu-text-yellow-800 wu-p-4 wu-rounded">' .
'<p class="wu-m-0 wu-font-semibold">%1$s</p>' .
'<p class="wu-m-0 wu-mt-2 wu-text-sm">%2$s</p>' .
'</div>',
esc_html__('Template switching is not available right now.', 'ultimate-multisite'),
esc_html__('No template switching widgets are available on this page. If you reached this page expecting to switch your site template, please contact your network administrator.', 'ultimate-multisite')
);

return;
}

echo $captured; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- previously-buffered widget HTML, already escaped at source.
},
999
);
}
}
6 changes: 2 additions & 4 deletions inc/models/class-checkout-form.php
Original file line number Diff line number Diff line change
Expand Up @@ -1030,11 +1030,9 @@ public static function convert_steps_to_v2($steps, $old_settings = []) {
$templates[] = $site->get_id();
}

$old_template_list = is_array($old_template_list) ? $old_template_list : [];
$old_template_list = is_array($old_template_list) ? $old_template_list : [];

$template_list = array_flip($old_template_list);

$template_list = ! empty($template_list) ? $template_list : $templates;
$template_list = ! empty($old_template_list) ? $old_template_list : $templates;

$step['fields'] = [
'template_selection' => [
Expand Down
18 changes: 17 additions & 1 deletion inc/models/class-membership.php
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,23 @@ 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());

/*
* Reject the "no customer in context vs membership with no customer" case.
*
* See the matching note on Site::is_customer_allowed(). Treating
* `0 === 0` as "allowed" was a privilege-escalation surface: a
* logged-in user with no UM customer association would be granted
* access to memberships whose customer link is missing. Default to
* denied unless both sides are known and match.
*/
if (0 === $customer_id || 0 === $membership_customer_id) {
$allowed = false;
} else {
$allowed = $customer_id === $membership_customer_id;
}

return apply_filters('wu_membership_is_customer_allowed', $allowed, $customer_id, $this);
}
Expand Down
23 changes: 22 additions & 1 deletion inc/models/class-site.php
Original file line number Diff line number Diff line change
Expand Up @@ -1053,7 +1053,28 @@ 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());

/*
* Reject the "no customer in context vs site with no customer" case.
*
* Previously this method returned true when both ids were 0 (`0 === 0`),
* which silently granted access on sites that have not been linked to a
* customer (e.g. orphaned customer_owned sites or fixture/test sites
* with missing wu_customer_id meta). That was a privilege-escalation
* surface: a logged-in user with no UM customer association could be
* treated as the "owner" of any site whose customer link was missing.
*
* The correct interpretation is: only super admins (handled above) and
* the actual linked customer can be considered allowed. If either side
* is unknown, default to denied.
*/
if (0 === $customer_id || 0 === $site_customer_id) {
$allowed = false;
} else {
$allowed = $customer_id === $site_customer_id;
}

return apply_filters('wu_site_is_customer_allowed', $allowed, $customer_id, $this);
}
Expand Down
140 changes: 135 additions & 5 deletions inc/ui/class-template-switching-element.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ class Template_Switching_Element extends Base_Element {

use \WP_Ultimo\Traits\Singleton;

/**
* Permission state: site exists, customer is allowed, full switching UI.
*/
const STATE_OK = 'ok';

/**
* Permission state: site exists, customer is allowed, but no membership
* is linked. Switching is still permitted; the available templates fall
* back to whatever the site's limitations expose.
*/
const STATE_NO_MEMBERSHIP = 'no_membership';

/**
* Permission state: no site, or the site exists but the current user is
* not its customer (and not a network admin). UI shows a denial notice
* instead of the switching grid so the user is not left staring at an
* empty page wondering what went wrong.
*/
const STATE_NOT_ALLOWED = 'not_allowed';

/**
* The id of the element.
*
Expand Down Expand Up @@ -57,6 +77,18 @@ class Template_Switching_Element extends Base_Element {
*/
protected $products;

/**
* Permission state computed during setup().
*
* Used by output() to decide whether to render the full grid, the grid
* with a "no membership" notice, or a denial notice. Always set to one
* of the STATE_* constants by the time output() runs.
*
* @since 2.5.2
* @var string
*/
protected $permission_state = self::STATE_OK;

/**
* The icon of the UI element.
* e.g. return fa fa-search
Expand Down Expand Up @@ -227,17 +259,27 @@ public function setup() {

$this->site = wu_get_current_site();

/*
* Decide which UI state to render.
*
* Previously this method called $this->set_display(false) whenever the
* customer was not allowed or no site was found, which left the page
* with three empty meta-box columns and no explanation — a confusing
* dead-end for end users. We now always render something: either the
* full grid, the grid with a "no membership" notice, or a denial
* notice. The actual server-side authorization for AJAX switches
* stays in switch_template().
*/
if ( ! $this->site || ! $this->site->is_customer_allowed()) {
$this->set_display(false);
$this->permission_state = self::STATE_NOT_ALLOWED;
$this->membership = null;
$this->products = [];

return;
}

$this->membership = $this->site->get_membership();

$this->products = [];

$all_membership_products = [];
$this->products = [];

if ($this->membership) {
$all_membership_products = $this->membership->get_all_products();
Expand All @@ -247,6 +289,19 @@ public function setup() {
$this->products[] = $product['product']->get_id();
}
}

$this->permission_state = self::STATE_OK;
} else {
/*
* The customer owns this site but no membership is linked. This
* happens for sites created outside the normal checkout flow
* (manual admin creation, fixtures, legacy migrations, or after
* a membership is deleted but the site is preserved). The
* customer should still be able to switch templates — we simply
* skip the per-product template restriction and let the site's
* own limitations drive the available list.
*/
$this->permission_state = self::STATE_NO_MEMBERSHIP;
}
}

Expand Down Expand Up @@ -284,6 +339,21 @@ public function switch_template() {
return;
}

/*
* Authorization: confirm the requesting user owns this site.
*
* The wu-ajax-nonce check in class-light-ajax.php protects against
* CSRF, but the nonce is shared across all logged-in users on the
* install. Without this check, customer A could replay a valid nonce
* to switch the template (and overwrite content) on customer B's
* site by passing a forged site context. Network admins bypass this
* via the manage_network short-circuit inside is_customer_allowed().
*/
if ( ! $this->site->is_customer_allowed()) {
wp_send_json_error(new \WP_Error('not_authorized', __('You do not have permission to switch templates on this site.', 'ultimate-multisite')));
return;
}

$template_id = (int) wu_request('template_id', '');

// false means MODE_DEFAULT (no restriction) — all templates are allowed.
Expand Down Expand Up @@ -356,13 +426,55 @@ public function switch_template() {
*/
public function output($atts, $content = null) {

/*
* Render an explicit denial notice when the customer is not allowed
* to switch templates on this site (or there is no site context at
* all). Previously this branch produced an empty page with no
* explanation; users would see a "Switch Template" header and a
* blank body. Showing a notice keeps the UX informative.
*/
if (self::STATE_NOT_ALLOWED === $this->permission_state) {
?>
<div class="wu-bg-yellow-100 wu-border wu-border-solid wu-border-yellow-300 wu-text-yellow-800 wu-p-4 wu-rounded">
<p class="wu-m-0 wu-font-semibold">
<?php esc_html_e('Template switching is not available right now.', 'ultimate-multisite'); ?>
</p>
<p class="wu-m-0 wu-mt-2 wu-text-sm">
<?php esc_html_e('You do not have permission to switch templates on this site, or the site is not associated with your account. If you believe this is a mistake, please contact your network administrator.', 'ultimate-multisite'); ?>
</p>
</div>
<?php
return;
}

if ($this->site) {
$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);

/*
* When the customer's site has no linked membership we have an
* empty $atts['products']. The shared limits filter only
* populates $attributes['sites'] when products are non-empty
* (see Site_Template_Limits::maybe_filter_template_selection_options),
* so without intervention we'd render the friendly "no
* membership" banner above an empty grid — defeating the whole
* point of letting the customer switch templates anyway.
*
* Fall back to every registered site template (the same list
* defaults() builds via wu_get_site_templates()). The customer
* still cannot bypass per-site rules: server-side authorization
* lives in switch_template() which calls is_customer_allowed()
* before applying the chosen template.
*/
if (self::STATE_NO_MEMBERSHIP === $this->permission_state && ! isset($template_selection_field['sites'])) {
$default_sites = wu_get_site_templates(['fields' => 'ids']);

$template_selection_field['sites'] = is_array($default_sites) ? $default_sites : [];
}

if ( ! isset($template_selection_field['sites'])) {
$template_selection_field['sites'] = [];
}
Expand Down Expand Up @@ -489,6 +601,24 @@ public function output($atts, $content = null) {
],
];

/*
* Inform the customer when their site has no membership link.
* They can still switch templates, but pricing/product-tier
* restrictions don't apply, so the available list may differ
* from what they would normally see. Without this notice the UI
* looks identical to the normal flow but quietly behaves
* differently — better to be explicit.
*/
if (self::STATE_NO_MEMBERSHIP === $this->permission_state) {
?>
<div class="wu-bg-blue-100 wu-border wu-border-solid wu-border-blue-300 wu-text-blue-800 wu-p-4 wu-rounded wu-mb-4">
<p class="wu-m-0 wu-text-sm">
<?php esc_html_e('This site is not currently linked to a membership. You can still switch templates, but plan-specific template restrictions do not apply.', 'ultimate-multisite'); ?>
</p>
</div>
<?php
}

$section_slug = 'wu-template-switching-form';

$form = new Form(
Expand Down
49 changes: 49 additions & 0 deletions tests/WP_Ultimo/Models/Site_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,55 @@ 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 the "no customer in context vs site
* with no customer" case.
*
* Regression guard: previously, when both the requesting customer id and
* the site's stored customer id were 0 (e.g. an orphaned customer_owned
* site with missing wu_customer_id meta, queried by a logged-in user
* with no UM customer association), the method returned true because of
* the `0 === 0` comparison. That silently granted "owner" access on
* unlinked sites — used to render the customer-panel template-switching
* UI and (worse) to gate the AJAX switch handler indirectly. The fix
* defaults to denied when either side is unknown.
*/
public function test_is_customer_allowed_zero_versus_zero_denied(): void {
// Ensure the current user is NOT a network admin (manage_network short-circuits).
$subscriber_id = $this->factory()->user->create(['role' => 'subscriber']);
wp_set_current_user($subscriber_id);

$this->site->set_customer_id(0);

// Calling with explicit 0 customer id (the path used by the AJAX handler
// when no UM customer is in context).
$this->assertFalse(
$this->site->is_customer_allowed(0),
'is_customer_allowed must deny when both the requesting customer id and the site customer id are 0.'
);
}

/**
* Test is_customer_allowed denies a real customer when the site has no
* customer link.
*
* A site with customer_id == 0 has no owner. Even a real customer should
* not be considered "allowed" on it — only network admins (handled by
* the manage_network short-circuit) can act on unlinked sites.
*/
public function test_is_customer_allowed_known_customer_versus_unlinked_site_denied(): void {
$subscriber_id = $this->factory()->user->create(['role' => 'subscriber']);
wp_set_current_user($subscriber_id);

$this->site->set_customer_id(0);

$customer_id = $this->customer->get_id();
$this->assertFalse(
$this->site->is_customer_allowed($customer_id),
'is_customer_allowed must deny when the site has no linked customer, even if a real customer id is provided.'
);
}

/**
* Test get_customer returns false when customer_id is 0.
*/
Expand Down
Loading