Skip to content
Open
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
7 changes: 6 additions & 1 deletion src/wp-admin/options-general.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,15 +305,20 @@ class="<?php echo esc_attr( $classes_for_button ); ?>"
<th scope="row"><label for="default_role"><?php _e( 'New User Default Role' ); ?></label></th>
<td>
<?php
/** This filter is documented in wp-includes/functions.php */
$roles_to_exclude = apply_filters( 'default_role_excluded_roles', array( 'administrator', 'editor' ) );

/**
* Filters the roles to be excluded from the default_role option.
*
* See also the {@see 'default_role_excluded_roles'} filter.
*
* @since 7.0.0
*
* @param string[] $roles_to_exclude Array of roles to exclude from the dropdown.
* Defaults to administrator and editor.
*/
$excluded_roles = (array) apply_filters( 'default_role_dropdown_excluded_roles', array( 'administrator', 'editor' ) );
$excluded_roles = (array) apply_filters( 'default_role_dropdown_excluded_roles', $roles_to_exclude );

$editable_roles = array_reverse( get_editable_roles() );

Expand Down
1 change: 1 addition & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@
add_filter( 'option_blog_charset', '_canonical_charset' );
add_filter( 'option_home', '_config_wp_home' );
add_filter( 'option_siteurl', '_config_wp_siteurl' );
add_filter( 'option_default_role', 'filter_default_role' );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Filtering the option on read rather than clamping it on write has three consequences downstream that I don't think are intended. I verified each against trunk:

1. The companion Site Health check can no longer fire. WP_Site_Health::get_test_insecure_registration() — added in 7.0.0 for this same ticket — does:

if ( $users_can_register && in_array( $default_role, array( 'editor', 'administrator' ), true ) ) {

It reads get_option( 'default_role' ), which is now filtered. Its critical branch requires users_can_register to be truthy, which is exactly the condition under which filter_default_role() has already rewritten the value to subscriber. So the branch becomes unreachable: the one screen that warned owners about this configuration reports "good" while the database still holds administrator.

2. An admin can no longer save a corrected value. update_option() at option.php:887 does $old_value = get_option( $option ); — the filtered value — and then returns early:

if ( $value === $old_value || maybe_serialize( $value ) === maybe_serialize( $old_value ) ) {
	return false;
}

With administrator stored and registration open, get_option() returns subscriber, so selecting "Subscriber" in Settings > General writes nothing. "Settings saved", database unchanged. The privileged value stays until someone turns registration off, at which point the clamp lifts and the site silently resumes creating administrators.

3. WP_Roles::remove_role() breaks on the same comparisonclass-wp-roles.php:217 uses if ( get_option( 'default_role' ) === $role ) to reset the default when the role is removed, and that can no longer match the stored value.

All three go away if enforcement moves to write time. sanitize_option() already has a default_role case at formatting.php:5165 handling the missing-role fallback, which looks like the natural home; pre_update_option_default_role would also work. Stored and effective values then agree, Site Health keeps working, and the settings screen stops disagreeing with the database.

add_filter( 'tiny_mce_before_init', '_mce_set_direction' );
add_filter( 'teeny_mce_before_init', '_mce_set_direction' );
add_filter( 'pre_kses', 'wp_pre_kses_less_than' );
Expand Down
40 changes: 40 additions & 0 deletions src/wp-includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -9415,3 +9415,43 @@ function wp_verify_fast_hash(

return hash_equals( $hash, wp_fast_hash( $message ) );
}

/**
* Filters the default role for new user registrations.
*
* This ensures that privileged roles are not made available as the default role when user registration is enabled.
*
* @since 7.2.0
*
* @param string $default_role The default role for new user registrations.
* @return string The filtered default role for new user registrations.
*/
function filter_default_role( $default_role ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

filter_default_role() is a new unprefixed global in wp-includes/functions.php. Core convention is wp_ or a leading underscore — _config_wp_home() and _mce_set_direction() nearby are the pattern.

This isn't only style. It's declared unconditionally and functions.php loads before plugins, so any plugin already defining a global filter_default_role() is a fatal "Cannot redeclare" on upgrade. That's the same shape as the wp_set_cookie() collision with WP Consent API on #12444, so a plugin-directory search on the final name seems worth doing before commit.

Also: @since 7.2.0 here versus 7.0.0 on the dropdown filter in options-general.php — worth a check that both are right for the release this actually lands in.

static $filtering = false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why Is there a static variable in use here? It feels unnecessary


if ( $filtering ) {
return $default_role;
}

$filtering = true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Following on from @aaronjorbin's question about whether this guard is needed — whatever the answer, it currently fails open.

$filtering = false; at 9454 is only reached on the normal path. get_option( 'users_can_register' ) and the default_role_excluded_roles filter both run third-party callbacks in between, so if any of them throws and something upstream catches it, $filtering stays true for the rest of the request and every subsequent call returns the raw stored value — including administrator.

A try/finally around the body fixes it, and the re-entrant short circuit at 9433 returning 'subscriber' rather than $default_role would make that path fail closed too.

No test covers this. Nothing in the suite exercises a nested or throwing call, so a broken guard would surface in production rather than CI.


if ( get_option( 'users_can_register' ) ) {
/**
* Filters the roles that are excluded from being available as the default role for new user registrations.
*
* @since 7.2.0
*
* @param string[] $roles Roles that are excluded from being available.
*/
$excluded = apply_filters( 'default_role_excluded_roles', array( 'administrator', 'editor' ) );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A slug list means a custom role carrying manage_options, promote_users, edit_users or edit_plugins passes straight through — and with the read-filter approach, Site Health now reports "good" for it too. Administrator clones from role-editor, membership and LMS plugins are exactly the population that ends up here.

A capability probe over wp_roles()->roles would produce the default set, still passed through default_role_excluded_roles so it stays adjustable. Worth checking wp_roles() availability at this point in the bootstrap first, since the filter is registered from default-filters.php.

Separately on the hook itself: it's currently removable, and the PR's own test_excluded_roles_can_be_removed uses __return_empty_array to restore administrator as the default role. If the intent is a floor rather than a suggestion, array_unique( array_merge( array( 'administrator', 'editor' ), (array) apply_filters( ... ) ) ) keeps it additive. If a genuine opt-out is wanted, that's fine — but the docblock should say that emptying the list re-enables self-registration into privileged roles.


// Don't allow a privileged default role if users can register.
if ( in_array( $default_role, $excluded, true ) ) {
$default_role = 'subscriber';
}
}

$filtering = false;

return $default_role;
}
213 changes: 213 additions & 0 deletions tests/phpunit/tests/functions/filterDefaultRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<?php

/**
* Tests for the filtering of the `default_role` option.
*
* @group option
* @group user
*
* @covers ::filter_default_role
*/
class Tests_Functions_FilterDefaultRole extends WP_UnitTestCase {

/**
* Opens or closes user registration.
*/
private function set_users_can_register( bool $can_register ) {
if ( is_multisite() ) {
update_site_option( 'registration', $can_register ? 'user' : 'none' );
} else {
update_option( 'users_can_register', $can_register ? 1 : 0 );
}
}

/**
* Ensures privileged roles are left alone when user registration is closed.
*
* @dataProvider data_excluded_roles
*/
public function test_excluded_role_is_unchanged_when_registration_is_closed( string $role ) {
$this->set_users_can_register( false );

$this->assertSame(
$role,
filter_default_role( $role ),
'The default role was changed while user registration was closed.'
);
}

/**
* Ensures privileged roles are replaced with the subscriber role when user registration is open.
*
* @dataProvider data_excluded_roles
*/
public function test_excluded_role_is_replaced_when_registration_is_open( string $role ) {
$this->set_users_can_register( true );

$this->assertSame(
'subscriber',
filter_default_role( $role ),
'The privileged default role was not replaced with the subscriber role.'
);
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_excluded_roles() {
return array(
'administrator' => array( 'administrator' ),
'editor' => array( 'editor' ),
);
}

/**
* Ensures roles that are not excluded are left alone when user registration is open.
*
* @dataProvider data_allowed_roles
*/
public function test_allowed_role_is_unchanged_when_registration_is_open( string $role ) {
$this->set_users_can_register( true );

$this->assertSame(
$role,
filter_default_role( $role ),
'A role which is not excluded was changed.'
);
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_allowed_roles() {
return array(
'author' => array( 'author' ),
'contributor' => array( 'contributor' ),
'subscriber' => array( 'subscriber' ),
'unknown role' => array( 'this-role-does-not-exist' ),
'empty string' => array( '' ),
);
}

/**
* Ensures a role can be added to the list of excluded roles.
*/
public function test_excluded_roles_can_be_added_to() {
$this->set_users_can_register( true );

add_filter(
'default_role_excluded_roles',
static function ( $excluded ) {
$excluded[] = 'author';
return $excluded;
}
);

$this->assertSame(
'subscriber',
filter_default_role( 'author' ),
'A role added to the excluded roles was not replaced with the subscriber role.'
);
}

/**
* Ensures a role can be removed from the list of excluded roles.
*/
public function test_excluded_roles_can_be_removed() {
$this->set_users_can_register( true );

add_filter( 'default_role_excluded_roles', '__return_empty_array' );

$this->assertSame(
'administrator',
filter_default_role( 'administrator' ),
'A role removed from the excluded roles was replaced.'
);
}

/**
* Ensures a new user is not assigned a privileged role when user registration is open.
*/
public function test_new_user_is_not_assigned_a_privileged_role() {
$this->set_users_can_register( true );
update_option( 'default_role', 'administrator' );

$user_id = wp_insert_user(
array(
'user_login' => 'test_default_role',
'user_pass' => 'password',
'user_email' => 'test_default_role@example.org',
)
);

$this->assertNotWPError( $user_id, 'The user was not created.' );
$this->assertSame(
array( 'subscriber' ),
get_userdata( $user_id )->roles,
'The new user was assigned a privileged role.'
);
}

/**
* Ensures the network registration setting governs the default role on Multisite, even when
* the `users_can_register` option of the current site is closed.
*
* @ticket 46744
* @group ms-required
*/
public function test_network_registration_governs_when_site_option_is_closed() {
update_site_option( 'registration', 'user' );
update_option( 'users_can_register', 0 );

$this->assertSame(
'subscriber',
filter_default_role( 'administrator' ),
'The privileged default role was not replaced while network registration was open.'
);
}

/**
* Ensures the network registration setting governs the default role on Multisite, even when
* the `users_can_register` option of the current site is open.
*
* @ticket 46744
* @group ms-required
*/
public function test_network_registration_governs_when_site_option_is_open() {
update_site_option( 'registration', 'none' );
update_option( 'users_can_register', 1 );

$this->assertSame(
'administrator',
filter_default_role( 'administrator' ),
'The default role was changed while network registration was closed.'
);
}

/**
* Ensures a new user is assigned the stored role when user registration is closed.
*/
public function test_new_user_is_assigned_the_stored_role_when_registration_is_closed() {
$this->set_users_can_register( false );
update_option( 'default_role', 'editor' );

$user_id = wp_insert_user(
array(
'user_login' => 'test_default_role',
'user_pass' => 'password',
'user_email' => 'test_default_role@example.org',
)
);

$this->assertNotWPError( $user_id, 'The user was not created.' );
$this->assertSame(
array( 'editor' ),
get_userdata( $user_id )->roles,
'The new user was not assigned the stored default role.'
);
}
}
Loading