Skip to content
Merged
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
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Add wraning when protected owner email is edited.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ private function __construct() {

// Disable WordPress.com invitations when creating protected owner accounts
add_filter( 'jetpack_sso_invite_new_users_wpcom', array( $this, 'disable_wpcom_invite_for_protected_owner' ) );

// Add warning when editing the protected owner's email
add_action( 'admin_footer-profile.php', array( $this, 'add_owner_email_warning' ) );
add_action( 'admin_footer-user-edit.php', array( $this, 'add_owner_email_warning' ) );
}

/**
Expand Down Expand Up @@ -319,4 +323,160 @@ public function disable_wpcom_invite_for_protected_owner( $invite_new_users_wpco
// Disable invitations for protected owner creation
return false;
}

/**
* Get the protected owner match status for a user
*
* Returns information about how (or if) the user matches the protected owner:
* - 'email_match': User's email matches the APD owner email
* - 'token_match': User's email doesn't match, but their token matches the APD owner token
* - 'no_match': User is not the protected owner
*
* @param int $user_id User ID to check.
* @return array {
* @type string $match_type One of 'email_match', 'token_match', or 'no_match'.
* @type string|null $owner_email The owner email from APD (if available).
* }
*/
private function get_protected_owner_status( $user_id ) {
$default_status = array(
'match_type' => 'no_match',
'owner_email' => null,
);

if ( ! class_exists( \Atomic_Persistent_Data::class ) ) {
return $default_status;
}

$apd = new \Atomic_Persistent_Data(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$owner_email = $apd->JETPACK_CONNECTION_OWNER_EMAIL; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$owner_secret = $apd->JETPACK_CONNECTION_OWNER_TOKEN_SECRET; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

// Need at least email or secret to identify the owner
if ( ! $owner_email && ! $owner_secret ) {
return $default_status;
}

$user = get_user_by( 'id', $user_id );
if ( ! $user ) {
return $default_status;
}

// First, try to match by email
if ( $owner_email && strtolower( $user->user_email ) === strtolower( $owner_email ) ) {
return array(
'match_type' => 'email_match',
'owner_email' => $owner_email,
);
}

// If email doesn't match, try to match by token
// This handles the case where the user already changed their local email
if ( $owner_secret && $this->user_has_owner_token( $user_id, $owner_secret ) ) {
return array(
'match_type' => 'token_match',
'owner_email' => $owner_email,
);
}

return $default_status;
}

/**
* Check if a user has the owner token from APD
*
* @param int $user_id User ID to check.
* @param string $owner_secret The owner token secret from APD (format: token_key.secret).
* @return bool True if user has a token matching the owner secret.
*/
private function user_has_owner_token( $user_id, $owner_secret ) {
if ( ! class_exists( 'Jetpack_Options' ) ) {
return false;
}

$private_options = \Jetpack_Options::get_raw_option( 'jetpack_private_options', array() );
if ( ! isset( $private_options['user_tokens'] ) || ! is_array( $private_options['user_tokens'] ) ) {
return false;
}

$user_tokens = $private_options['user_tokens'];
if ( ! isset( $user_tokens[ $user_id ] ) || ! is_string( $user_tokens[ $user_id ] ) ) {
return false;
}

$user_token = $user_tokens[ $user_id ];

// User token format: token_key.secret.user_id
// Owner secret format: token_key.secret
// Match if user's token starts with owner_secret followed by a dot
return strpos( $user_token, $owner_secret . '.' ) === 0;
}

/**
* Add warning notice for protected owner email field
*
* Displays a warning when editing a user profile if the user is the WordPress.com
* plan owner, cautioning against changing their email address.
*/
public function add_owner_email_warning() {
// Determine which user is being edited
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check for display purposes
$user_id = isset( $_GET['user_id'] ) ? (int) $_GET['user_id'] : get_current_user_id();

$status = $this->get_protected_owner_status( $user_id );

if ( 'no_match' === $status['match_type'] ) {
return;
}

// Define allowed HTML for the warning message (only safe link attributes)
$allowed_html = array(
'a' => array(
'href' => array(),
'target' => array(),
),
);

$wpcom_account_link = '<a href="https://wordpress.com/me/account" target="_blank">WordPress.com account</a>';

if ( 'email_match' === $status['match_type'] ) {
// Emails are in sync - show preventive warning
$warning_text = sprintf(
/* translators: %s is a link to the WordPress.com account settings page */
__(
'This account is the WordPress.com plan owner. Changing the email address here can affect the connection between the site and WordPress.com. If you need to change your email, update it on both your %s and here to keep them synchronized.',
'wpcomsh'
),
$wpcom_account_link
);
} else {
// Token match but emails differ - show sync warning
$warning_text = sprintf(
/* translators: 1: The expected WordPress.com email address, 2: A link to the WordPress.com account settings page */
__(
'This account is the WordPress.com plan owner, but the email address here does not match your WordPress.com account email (%1$s). This mismatch may cause connection issues. Please update this email to match your %2$s, or update both if you need to change your email.',
'wpcomsh'
),
esc_html( $status['owner_email'] ),
$wpcom_account_link
);
}

// Sanitize HTML to only allow safe tags
$warning_text = wp_kses( $warning_text, $allowed_html );
$warning_label = __( 'Warning:', 'wpcomsh' );
?>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
var emailCell = document.querySelector('.user-email-wrap')?.querySelector('td');
if (emailCell) {
var warning = document.createElement('p');
warning.className = 'description';
warning.innerHTML = '<span style="color: #d63638; font-weight: 600;">⚠️ ' + <?php echo wp_json_encode( $warning_label, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?> + '</span> ' + <?php echo wp_json_encode( $warning_text, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
emailCell.insertBefore(warning, emailCell.firstChild);
}
});
</script>
<?php
}
}
182 changes: 182 additions & 0 deletions projects/plugins/wpcomsh/tests/ProtectedOwnerErrorHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,186 @@ public function test_disable_wpcom_invite_for_protected_owner_without_email() {
$result = $this->handler->disable_wpcom_invite_for_protected_owner( false );
$this->assertFalse( $result );
}

/**
* Test get_protected_owner_status returns no_match when APD class doesn't exist.
*/
public function test_get_protected_owner_status_no_apd_class() {
// Skip if APD class exists (we can't test the "no class" scenario)
if ( class_exists( \Atomic_Persistent_Data::class ) ) {
$this->markTestSkipped( 'Test requires Atomic_Persistent_Data class to NOT exist.' );
}

$user_id = $this->factory()->user->create( array( 'user_email' => 'test@example.com' ) );

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_protected_owner_status' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}

$result = $method->invoke( $this->handler, $user_id );

$this->assertEquals( 'no_match', $result['match_type'] );
$this->assertNull( $result['owner_email'] );
}

/**
* Test get_protected_owner_status returns no_match for non-existent user.
*/
public function test_get_protected_owner_status_nonexistent_user() {
// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_protected_owner_status' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}

// Use a user ID that doesn't exist
$result = $method->invoke( $this->handler, 999999 );

$this->assertEquals( 'no_match', $result['match_type'] );
}

/**
* Test user_has_owner_token returns false when Jetpack_Options class doesn't exist.
*/
public function test_user_has_owner_token_no_jetpack_options() {
// Skip if Jetpack_Options exists (most test environments have it)
if ( class_exists( 'Jetpack_Options' ) ) {
$this->markTestSkipped( 'Test requires Jetpack_Options class to NOT exist.' );
}

$user_id = $this->factory()->user->create();

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'user_has_owner_token' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}

$result = $method->invoke( $this->handler, $user_id, 'some.secret' );

$this->assertFalse( $result );
}

/**
* Test user_has_owner_token returns false when user has no token.
*/
public function test_user_has_owner_token_no_token() {
// Skip if Jetpack_Options doesn't exist
if ( ! class_exists( 'Jetpack_Options' ) ) {
$this->markTestSkipped( 'Test requires Jetpack_Options class.' );
}

$user_id = $this->factory()->user->create();

// Ensure no tokens are set for this user
$private_options = \Jetpack_Options::get_raw_option( 'jetpack_private_options', array() );
$private_options['user_tokens'] = array();
\Jetpack_Options::update_raw_option( 'jetpack_private_options', $private_options, false );

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'user_has_owner_token' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}

$result = $method->invoke( $this->handler, $user_id, 'some.secret' );

$this->assertFalse( $result );
}

/**
* Test user_has_owner_token returns true when token matches.
*/
public function test_user_has_owner_token_matching_token() {
// Skip if Jetpack_Options doesn't exist
if ( ! class_exists( 'Jetpack_Options' ) ) {
$this->markTestSkipped( 'Test requires Jetpack_Options class.' );
}

$user_id = $this->factory()->user->create();
$owner_secret = 'token_key.secret_value';
$user_token = $owner_secret . '.' . $user_id; // token_key.secret_value.user_id

// Set the user token
$private_options = \Jetpack_Options::get_raw_option( 'jetpack_private_options', array() );
$private_options['user_tokens'] = array();
$private_options['user_tokens'][ $user_id ] = $user_token;
\Jetpack_Options::update_raw_option( 'jetpack_private_options', $private_options, false );

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'user_has_owner_token' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}

$result = $method->invoke( $this->handler, $user_id, $owner_secret );

$this->assertTrue( $result );

// Clean up
$private_options['user_tokens'] = array();
\Jetpack_Options::update_raw_option( 'jetpack_private_options', $private_options, false );
}

/**
* Test user_has_owner_token returns false when token doesn't match.
*/
public function test_user_has_owner_token_non_matching_token() {
// Skip if Jetpack_Options doesn't exist
if ( ! class_exists( 'Jetpack_Options' ) ) {
$this->markTestSkipped( 'Test requires Jetpack_Options class.' );
}

$user_id = $this->factory()->user->create();
$owner_secret = 'token_key.secret_value';
$user_token = 'different_key.different_secret.' . $user_id;

// Set a different user token
$private_options = \Jetpack_Options::get_raw_option( 'jetpack_private_options', array() );
$private_options['user_tokens'] = array();
$private_options['user_tokens'][ $user_id ] = $user_token;
\Jetpack_Options::update_raw_option( 'jetpack_private_options', $private_options, false );

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'user_has_owner_token' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}

$result = $method->invoke( $this->handler, $user_id, $owner_secret );

$this->assertFalse( $result );

// Clean up
$private_options['user_tokens'] = array();
\Jetpack_Options::update_raw_option( 'jetpack_private_options', $private_options, false );
}

/**
* Test add_owner_email_warning outputs nothing when user is not protected owner.
*/
public function test_add_owner_email_warning_not_protected_owner() {
// Create a regular user
$user_id = $this->factory()->user->create( array( 'user_email' => 'regular@example.com' ) );
$_GET['user_id'] = $user_id;

// Capture output
ob_start();
$this->handler->add_owner_email_warning();
$output = ob_get_clean();

// Should be empty since user is not the protected owner
$this->assertEmpty( $output );

// Clean up
unset( $_GET['user_id'] );
}
}