Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<p align="center">
<a href="https://github.com/superdav42/wp-multisite-waas/actions/workflows/tests.yml"><img src="https://github.com/superdav42/wp-multisite-waas/actions/workflows/tests.yml/badge.svg" alt="Unit & Integration Tests"></a>
<a href="https://github.com/superdav42/wp-multisite-waas/actions/workflows/e2e.yml"><img src="https://github.com/superdav42/wp-multisite-waas/actions/workflows/e2e.yml/badge.svg" alt="E2E Tests"></a>
<a href="https://img.shields.io/coderabbit/prs/github/superdav42/wp-multisite-waas?utm_source=oss&utm_medium=github&utm_campaign=superdav42%2Fwp-multisite-waas&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews"><img src="https://img.shields.io/coderabbit/prs/github/superdav42/wp-multisite-waas?utm_source=oss&utm_medium=github&utm_campaign=superdav42%2Fwp-multisite-waas&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews" alt="E2E Tests"></a>
<a href="https://img.shields.io/coderabbit/prs/github/Multisite-Ultimate/multisite-ultimate?utm_source=oss&utm_medium=github&utm_campaign=Multisite-Ultimate%2Fmultisite-ultimate&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews"><img src="https://img.shields.io/coderabbit/prs/github/Multisite-Ultimate/multisite-ultimate?utm_source=oss&utm_medium=github&utm_campaign=Multisite-Ultimate%2Fmultisite-ultimate&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews" alt="Code Rabbit"></a>
</p>

## 🌟 Overview
Expand Down
6 changes: 5 additions & 1 deletion inc/limits/class-customer-user-role-limits.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ public function update_site_user_roles($membership_id): void {
$role = $membership->get_limitations()->customer_user_role->get_limit();

foreach ($sites as $site) {
add_user_to_blog($site->get_id(), $customer->get_user_id(), $role);
// only add user to blog if they are not already a member, or we are downgrading their role.
// Without this check the user could lose additional roles added manually or with hooks.
if ('administrator' !== $role || ! is_user_member_of_blog($customer->get_user_id(), $site->get_id())) {
add_user_to_blog($site->get_id(), $customer->get_user_id(), $role);
}
Comment on lines +138 to +142
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid role update when customer has no WP user_id.

Prevent accidental calls with a falsy user_id; minor perf win by not repeating get_user_id().

Apply this diff:

- // only add user to blog if they are not already a member, or we are downgrading their role.
- // Without this check the user could lose additional roles added manually or with hooks.
- if ('administrator' !== $role || ! is_user_member_of_blog($customer->get_user_id(), $site->get_id())) {
-     add_user_to_blog($site->get_id(), $customer->get_user_id(), $role);
- }
+ // only add user to blog if they are not already a member, or we are downgrading their role.
+ // Without this check the user could lose additional roles added manually or with hooks.
+ $uid = (int) $customer->get_user_id();
+ if ($uid && ('administrator' !== $role || ! is_user_member_of_blog($uid, $site->get_id()))) {
+     add_user_to_blog($site->get_id(), $uid, $role);
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// only add user to blog if they are not already a member, or we are downgrading their role.
// Without this check the user could lose additional roles added manually or with hooks.
if ('administrator' !== $role || ! is_user_member_of_blog($customer->get_user_id(), $site->get_id())) {
add_user_to_blog($site->get_id(), $customer->get_user_id(), $role);
}
// only add user to blog if they are not already a member, or we are downgrading their role.
// Without this check the user could lose additional roles added manually or with hooks.
$uid = (int) $customer->get_user_id();
if ( $uid && ( 'administrator' !== $role || ! is_user_member_of_blog( $uid, $site->get_id() ) ) ) {
add_user_to_blog( $site->get_id(), $uid, $role );
}
🤖 Prompt for AI Agents
In inc/limits/class-customer-user-role-limits.php around lines 138 to 142, avoid
calling add_user_to_blog or is_user_member_of_blog when the customer has no WP
user_id and avoid repeated get_user_id() calls: retrieve the customer user_id
once into a local variable, return/skip early if it's falsy, then perform the
existing check using that cached user_id and only call add_user_to_blog when
appropriate.

}
}
}
Expand Down
150 changes: 150 additions & 0 deletions inc/managers/class-domain-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -904,4 +904,154 @@ public function load_integrations(): void {
*/
do_action('wp_ultimo_host_providers_load');
}

/**
* Register the domain verification endpoint.
*
* @since 2.0.0
* @return void
*/
public function register_domain_verification_endpoint(): void {

register_rest_route(
'wu/v1',
'/domain-verification/(?P<domain>[a-zA-Z0-9.-]+)',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array($this, 'domain_verification_endpoint'),
'permission_callback' => '__return_true',
'args' => array(
'domain' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Ensure the endpoint is actually registered

I don’t see a hook here. If the Rest_Api trait doesn’t auto-register this, add a rest_api_init hook.

Add in init():

   public function init(): void {
     $this->enable_rest_api();
+    add_action('rest_api_init', [$this, 'register_domain_verification_endpoint']);
     $this->enable_wp_cli();

To verify:


🏁 Script executed:

#!/bin/bash
# Confirm route is registered and where it's hooked
rg -nP -C2 "register_rest_route\(\s*['\"]wu\/v1['\"].*domain-verification" 
rg -n "rest_api_init" -C2

Length of output: 1102


Register your endpoint on rest_api_init
The register_domain_verification_endpoint() method exists but isn’t hooked anywhere—neither via your init() nor by the Rest_Api trait’s register_routes_* methods. You must add:

 public function init(): void {
   $this->enable_rest_api();
+  add_action('rest_api_init', [$this, 'register_domain_verification_endpoint']);
   $this->enable_wp_cli();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Register the domain verification endpoint.
*
* @since 2.0.0
* @return void
*/
public function register_domain_verification_endpoint(): void {
register_rest_route(
'wu/v1',
'/domain-verification/(?P<domain>[a-zA-Z0-9.-]+)',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array($this, 'domain_verification_endpoint'),
'permission_callback' => '__return_true',
'args' => array(
'domain' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
public function init(): void {
$this->enable_rest_api();
add_action( 'rest_api_init', [ $this, 'register_domain_verification_endpoint' ] );
$this->enable_wp_cli();
}
🤖 Prompt for AI Agents
In inc/managers/class-domain-manager.php around lines 907 to 931, the
register_domain_verification_endpoint() method is defined but never hooked into
WordPress so the route is never registered; wire this method into the REST
bootstrap by hooking it to rest_api_init (or by calling it from the class init()
or the Rest_Api trait's route registration flow) so the endpoint is registered
at runtime—add the appropriate add_action('rest_api_init', ...) registration or
invoke the method from the existing route registration entry point to ensure the
route is registered.


/**
* Domain verification endpoint that returns a shared secret.
*
* @since 2.0.0
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response|\WP_Error
*/
public function domain_verification_endpoint($request) {

$domain = $request->get_param('domain');

if ( empty($domain) ) {
return new \WP_Error('missing_domain', __('Domain parameter is required.', 'multisite-ultimate'), array('status' => 400));
}

$secret = $this->get_domain_verification_secret($domain);

return rest_ensure_response(
array(
'domain' => $domain,
'secret' => $secret,
)
);
}

/**
* Generate and store a verification secret for a domain.
*
* @since 2.0.0
*
* @param string $domain The domain to generate a secret for.
* @return string The generated secret.
*/
private function generate_domain_verification_secret($domain) {

$secret = wp_generate_password(32, false);

set_site_transient("wu_domain_verification_{$domain}", $secret, 5 * MINUTE_IN_SECONDS);

return $secret;
}

/**
* Get the verification secret for a domain.
*
* @since 2.0.0
*
* @param string $domain The domain to get the secret for.
* @return string The verification secret.
*/
private function get_domain_verification_secret($domain) {

$secret = get_site_transient("wu_domain_verification_{$domain}");

if ( false === $secret ) {
$secret = $this->generate_domain_verification_secret($domain);
}

return $secret;
}

/**
* Verify domain ownership using the secret-based method.
*
* @since 2.0.0
*
* @param string $domain The domain to verify.
* @return bool True if verification succeeds, false otherwise.
*/
public function verify_domain_with_secret($domain) {

$expected_secret = $this->generate_domain_verification_secret($domain);

$verification_url = "https://{$domain}/wp-json/wu/v1/domain-verification/{$domain}";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid regenerating the secret on each verification attempt

Regenerating can cause race conditions; fetch the current secret instead.

Apply:

-    $expected_secret = $this->generate_domain_verification_secret($domain);
+    $expected_secret = $this->get_domain_verification_secret($domain);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function verify_domain_with_secret($domain) {
$expected_secret = $this->generate_domain_verification_secret($domain);
$verification_url = "https://{$domain}/wp-json/wu/v1/domain-verification/{$domain}";
public function verify_domain_with_secret($domain) {
$expected_secret = $this->get_domain_verification_secret($domain);
$verification_url = "https://{$domain}/wp-json/wu/v1/domain-verification/{$domain}";
🤖 Prompt for AI Agents
In inc/managers/class-domain-manager.php around lines 1003 to 1007, do not call
generate_domain_verification_secret($domain) during verification because it
re-creates the secret and can cause race conditions; instead retrieve the
already-stored/current secret (e.g. from the option/metadata or a dedicated
getter like get_stored_domain_verification_secret($domain)) and compare that
stored value to the secret presented during verification, falling back to a safe
behaviour if no stored secret exists; update the verification logic to use the
fetched secret and remove the regeneration call.


$response = wp_remote_get(
$verification_url,
array(
'timeout' => 10,
'sslverify' => false,
)
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not disable TLS verification on remote requests

Disabling sslverify invites MITM and weakens transport security.

Apply:

-        'timeout'   => 10,
-        'sslverify' => false,
+        'timeout'     => 10,
+        'sslverify'   => apply_filters('wu_verify_domain_secret_sslverify', true),
+        'redirection' => 3,
+        'headers'     => array('Accept' => 'application/json'),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$verification_url,
array(
'timeout' => 10,
'sslverify' => false,
)
);
$verification_url,
array(
- 'timeout' => 10,
'timeout' => 10,
'sslverify' => apply_filters( 'wu_verify_domain_secret_sslverify', true ),
'redirection' => 3,
'headers' => array(
'Accept' => 'application/json',
),
)
);
🤖 Prompt for AI Agents
In inc/managers/class-domain-manager.php around lines 1010 to 1015, the call to
wp_remote_get currently disables TLS verification by setting 'sslverify' =>
false; remove that option or set 'sslverify' => true so remote requests use
proper certificate validation, and handle valid certificate errors explicitly
(e.g. catch wp_remote_get errors and log/return a clear error if verification
fails, or allow specifying a CA bundle if you must trust a custom CA). Ensure
you do not ship code that disables TLS verification and add a comment noting why
sslverify must remain true.


if ( is_wp_error($response) ) {
wu_log_add(
"domain-verification-{$domain}",
// translators: %s url of the endpoint.
sprintf(__('Failed to connect to verification endpoint: %s', 'multisite-ultimate'), $response->get_error_message()),
LogLevel::WARNING
);
return false;
}

$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);

if ( ! isset($data['secret']) ) {
wu_log_add(
"domain-verification-{$domain}",
__('Verification endpoint did not return a secret.', 'multisite-ultimate'),
LogLevel::WARNING
);
return false;
}

$received_secret = $data['secret'];

if ( hash_equals($expected_secret, $received_secret) ) {
wu_log_add(
"domain-verification-{$domain}",
__('Domain verification successful using secret method.', 'multisite-ultimate')
);
return true;
}

wu_log_add(
"domain-verification-{$domain}",
__('Domain verification failed: secrets do not match.', 'multisite-ultimate'),
LogLevel::WARNING
);

return false;
}
}
12 changes: 12 additions & 0 deletions inc/models/class-domain.php
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,18 @@ public function has_correct_dns() {

$domain_url = $this->get_domain();

$domain_manager = \WP_Ultimo\Managers\Domain_Manager::get_instance();

$secret_verification_enabled = apply_filters('wu_enable_secret_domain_verification', true, $this);

if ( $secret_verification_enabled ) {
$secret_result = $domain_manager->verify_domain_with_secret($domain_url);

if ( $secret_result ) {
return true;
}
}

Comment on lines +400 to +405
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Loopback short-circuit is fine; ensure TLS verification is actually enforced

This path depends on Domain_Manager::verify_domain_with_loopback_request(). In that method, the “HTTPS with SSL verification” variant currently uses sslverify tied to https_local_ssl_verify with a default false. That disables certificate verification by default and invites MITM.

Proposed fix in inc/managers/class-domain-manager.php:

- 'sslverify' => apply_filters('https_local_ssl_verify', false),
+ 'sslverify' => apply_filters('https_local_ssl_verify', true),

Also ensure the log label matches behavior (i.e., only claim “with SSL verification” when sslverify is true).

$network_ip_address = Helper::get_network_public_ip();

$results = \WP_Ultimo\Managers\Domain_Manager::dns_get_record($domain_url);
Expand Down
6 changes: 5 additions & 1 deletion inc/models/class-site.php
Original file line number Diff line number Diff line change
Expand Up @@ -1679,7 +1679,11 @@ public function save() {

$user_id = $customer->get_user_id();

add_user_to_blog($this->get_id(), $user_id, $role);
// only add user to blog if they are not already a member, or we are downgrading their role.
// Without this check the user could lose additional roles added manually or with hooks.
if ('administrator' !== $role || ! is_user_member_of_blog($user_id, $this->get_id())) {
add_user_to_blog($this->get_id(), $user_id, $role);
}
Comment on lines +1682 to +1686
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard add_user_to_blog when user_id is empty.

If get_user_id() returns 0/false, add_user_to_blog may misbehave. Add a truthy check.

Apply this diff:

- if ('administrator' !== $role || ! is_user_member_of_blog($user_id, $this->get_id())) {
-     add_user_to_blog($this->get_id(), $user_id, $role);
- }
+ if ($user_id && ('administrator' !== $role || ! is_user_member_of_blog($user_id, $this->get_id()))) {
+     add_user_to_blog($this->get_id(), $user_id, $role);
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// only add user to blog if they are not already a member, or we are downgrading their role.
// Without this check the user could lose additional roles added manually or with hooks.
if ('administrator' !== $role || ! is_user_member_of_blog($user_id, $this->get_id())) {
add_user_to_blog($this->get_id(), $user_id, $role);
}
// only add user to blog if they are not already a member, or we are downgrading their role.
// Without this check the user could lose additional roles added manually or with hooks.
if ( $user_id && ( 'administrator' !== $role || ! is_user_member_of_blog( $user_id, $this->get_id() ) ) ) {
add_user_to_blog( $this->get_id(), $user_id, $role );
}
🤖 Prompt for AI Agents
In inc/models/class-site.php around lines 1682 to 1686, add a truthy check for
$user_id before calling is_user_member_of_blog() or add_user_to_blog(), because
get_user_id() can return 0/false; modify the conditional to first ensure
$user_id is truthy (e.g. $user_id) and then perform the existing role/member
checks so add_user_to_blog is only invoked when a valid user ID exists.

} elseif ($this->get_type() !== Site_Type::CUSTOMER_OWNED && $original_customer_id) {
$user_id = wu_get_customer($original_customer_id)->get_user_id();

Expand Down
41 changes: 11 additions & 30 deletions inc/objects/class-limitations.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class Limitations {
*/
public function __construct($modules_data = []) {

$this->build_modules($modules_data);
$this->raw_module_data = $modules_data;
}

/**
Expand All @@ -83,16 +83,10 @@ public function __get($name) {
$module = wu_get_isset($this->modules, $name, false);

if (false === $module) {
$repo = self::repository();

$class_name = wu_get_isset($repo, $name, false);

if (class_exists($class_name)) {
$module = new $class_name($this->raw_module_data[ $name ] ?? []);

$module = self::build($this->raw_module_data[ $name ] ?? [], $name);
if ($module) {
$this->modules[ $name ] = $module;

return $module;
return $this->modules[ $name ];
}
}

Expand Down Expand Up @@ -121,7 +115,7 @@ public function __serialize() {
* @return void
*/
public function __unserialize($modules_data) {
$this->build_modules($modules_data);
$this->raw_module_data = $modules_data;
}

/**
Expand All @@ -136,14 +130,6 @@ public function build_modules($modules_data) {

$this->raw_module_data = $modules_data;

foreach ($modules_data as $type => $data) {
$module = self::build($data, $type);

if ($module) {
$this->modules[ $type ] = $module;
}
}

return $this;
}

Expand Down Expand Up @@ -181,7 +167,7 @@ public static function build($data, $module_name) {
*/
public function exists($module) {

return wu_get_isset($this->modules, $module, false);
return (bool) wu_get_isset($this->raw_module_data, $module, false);
}

/**
Expand All @@ -192,15 +178,13 @@ public function exists($module) {
*/
public function has_limitations() {

$has_limitations = false;

foreach ($this->modules as $module) {
if ($module->is_enabled()) {
foreach ($this->raw_module_data as $module) {
if ($module['enabled']) {
return true;
}
}

return $has_limitations;
return false;
}

/**
Expand All @@ -213,9 +197,7 @@ public function has_limitations() {
*/
public function is_module_enabled($module_name) {

$module = $this->{$module_name};

return $module ? $module->is_enabled() : false;
return $this->raw_module_data[ $module_name ]['enabled'] ?? false;
}

/**
Expand Down Expand Up @@ -374,8 +356,7 @@ protected function merge_recursive(array &$array1, array &$array2, $should_sum =
* @since 2.0.0
*/
public function to_array(): array {

return array_map(fn($module) => method_exists($module, 'to_array') ? $module->to_array() : (array) $module, $this->modules);
return $this->raw_module_data;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions tests/WP_Ultimo/Objects/Limitations_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public function test_constructor(array $modules_data, int $expected_modules_coun

// Use reflection to access protected modules property
$reflection = new \ReflectionClass($limitations);
$modules_property = $reflection->getProperty('modules');
$modules_property = $reflection->getProperty('raw_module_data');
$modules_property->setAccessible(true);
$modules = $modules_property->getValue($limitations);

Expand Down Expand Up @@ -199,11 +199,11 @@ public function test_build_modules(array $modules_data, int $expected_count): vo

// Use reflection to access protected modules property
$reflection = new \ReflectionClass($limitations);
$modules_property = $reflection->getProperty('modules');
$modules_property = $reflection->getProperty('raw_module_data');
$modules_property->setAccessible(true);
$modules = $modules_property->getValue($limitations);

$this->assertCount($expected_count, $modules);
$this->assertEquals($modules_data, $modules);
}

/**
Expand Down Expand Up @@ -284,11 +284,11 @@ public function existsMethodDataProvider(): array {
*/
public function test_exists_method(array $modules_data, string $module_name, bool $should_exist): void {
$limitations = new Limitations($modules_data);
$result = $limitations->exists($module_name);
$result = $limitations->exists($module_name);

if ($should_exist) {
$this->assertNotFalse($result);
$this->assertIsObject($result);
$this->assertTrue($result);
} else {
$this->assertFalse($result);
}
Expand Down
2 changes: 1 addition & 1 deletion views/admin-notices.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<?php else : ?>

<p class="wu-py-2"><?php echo esc_html($notice['message']); ?></p>
<p class="wu-py-2"><?php echo wp_kses_post($notice['message']); ?></p>

<?php endif; ?>

Expand Down
5 changes: 5 additions & 0 deletions views/domain/dns-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@
<td colspan="2" class="wu-text-left">{{ results.network_ip }}</td>
</tr>

<tr>
<td colspan="2"><?php esc_html_e('Domain connected to site?', 'multisite-ultimate'); ?></td>
<td colspan="2" class="wu-text-left">{{ results.has_correct_dns }}</td>
</tr>

</tbody>

</table>
Expand Down
Loading