WordPress · Report #3942522
wordpress.org Rosetta: invite stores unvalidated role — Editor can assign site Administrator despite editable_roles
Summary
On Rosetta locale sites, Editors can invite users but are not supposed to make anyone a site Administrator — WordPress normally blocks that via editable_roles. A wordpress.org mu-plugin saved the invited user’s role from the HTTP request without checking that list, so an Editor could force role=administrator and elevate an account when the invite was accepted.
How it was found
Summary
On Rosetta locale sites (e.g. xx.wordpress.org/team/), Editors are intentionally allowed to invite existing network users (promote_users) but are blocked from assigning the Administrator role via the official WordPress editable_roles filter (unset( $roles['administrator'] ) for non–super-admins).
The wordpress.org mu-plugin wporg-add-existing-users-via-username.php breaks that boundary: on admin_action_adduser it persists the invite option with role taken directly from $_REQUEST['role'] before consulting get_editable_roles(). Core’s /newbloguser/{key}/ path then applies the stored role via add_existing_user_to_blog() → set_role() with no wp_ensure_editable_role() re-check.
An Editor can therefore POST role=administrator (not offered in the UI dropdown), create a valid invite, and elevate an account to site Administrator (manage_options on that blog).
This is not by design. Official WordPress documentation states that get_editable_roles() / editable_roles exist specifically so that delegated user managers (editors/authors) cannot make others administrators. Rosetta implements that filter. The invite path ignores it. Since WordPress 6.8, Core also provides wp_ensure_editable_role() to enforce the allowlist server-side — but that check is not applied before invite persistence in this mu-plugin (and Core’s own invite branch still lacks it before add_option in some paths).
This is not the Learn / workshop_reviewer case: Learn does not remove Administrator from editable_roles, so there is no privilege-boundary bypass there. Rosetta does remove it.
Official documentation (intended security model)
From WordPress Developer Resources — get_editable_roles():
Main purpose is to allow filtering of roles so that plugins can remove inappropriate ones… Specifically because without filtering anyone with the edit_users capability can edit others to be administrators, even if they are only editors or authors. This filter allows admins to delegate user management.
editable_roles — list of roles a user can assign to others.
wp_ensure_editable_role() (since 6.8.0) — stop execution if the role cannot be assigned by the current user (403).
Rosetta relies on that model; the invite mu-plugin does not enforce it.
Affected Asset / Component
- Program asset: WordPress (HackerOne) — wordpress.org custom code / Rosetta
- Sites: Rosetta locale team / main (path
/team/and locale main customizations) - Mu-plugin:
wordpress.org/public_html/wp-content/mu-plugins/pub/wporg-add-existing-users-via-username.php - Rosetta boundary:
…/plugins/rosetta/inc/site/class-locale-team.php(alsoclass-locale-main.php) - Core apply path:
wp-includes/ms-functions.php—maybe_add_existing_user_to_blog()/add_existing_user_to_blog()/add_user_to_blog()
Vulnerable Code
1) Rosetta — intentional boundary (Editors may invite; must NOT assign Administrator)
// rosetta/inc/site/class-locale-team.php
public function extend_editors_capabilities( $allcaps, $caps, $args, $user ) {
if ( ! in_array( 'editor', $user->roles ) ) {
return $allcaps;
}
$allcaps['list_users'] = true;
$allcaps['promote_users'] = true;
$allcaps['remove_users'] = true;
return $allcaps;
}
public function remove_administrator_from_editable_roles( $roles ) {
if ( ! is_super_admin() ) {
unset( $roles['administrator'] );
}
return $roles;
}
2) Mu-plugin — persists unvalidated $_REQUEST['role'] BEFORE editable check
// wporg-add-existing-users-via-username.php (non–super-admin path)
$new_user_key = substr( md5( $user_id ), 0, 5 );
add_option( 'new_user_' . $new_user_key, array(
'user_id' => $user_id,
'email' => $user_details->user_email,
'role' => $_REQUEST['role'], // UNVALIDATED
) );
$roles = get_editable_roles(); // too late — only used for email text
$role = $roles[ $_REQUEST['role'] ];
// wp_mail( … translate_user_role( $role['name'] ) … )
If role=administrator is not in get_editable_roles(), email rendering may warn / show empty role name, but add_option already succeeded.
3) Core — applies stored role with no editable re-check
// wp-includes/ms-functions.php
function add_existing_user_to_blog( $details = false ) {
if ( is_array( $details ) ) {
$result = add_user_to_blog( get_current_blog_id(), $details['user_id'], $details['role'] );
// …
}
}
// add_user_to_blog() → $user->set_role( $role ); // no wp_ensure_editable_role()
Correct pattern (already in Core 6.8 for other paths)
wp_ensure_editable_role( $_REQUEST['role'] ); // used on some create / noconfirmation paths
Invite persistence in this mu-plugin never calls it.
Local validation (source-faithful lab)
Authorized source review + local Multisite lab (not production wordpress.org):
WordPress Multisite + production copy of wporg-add-existing-users-via-username.php
Rosetta-equivalent editable_roles filter (unset administrator) + Editor promote_users
Created lab_editor (Editor) and lab_victim (not yet on blog)
Stored invite with role=administrator (same order as mu-plugin)
Called Core add_existing_user_to_blog()
DB proof:
lab_victim wp_capabilities a:1:{s:13:"administrator";b:1;}
Victim had manage_options = true. Invite key shape matches mu-plugin: substr( md5( user_id ), 0, 5 ).
Suggested fix
Call wp_ensure_editable_role( $_REQUEST['role'] ) (or equivalent) before add_option.
Persist only the validated role string from get_editable_roles().
Defense in depth: re-validate in Core add_existing_user_to_blog() before set_role.
Prefer non-deterministic invite keys (Core invite already uses longer random keys; mu-plugin uses substr(md5(user_id),0,5)).
Researcher: 0xbs0d27
## Impact
Authenticated Editor on a Rosetta locale site can escalate another network account (including one they control) to **site Administrator**, bypassing the explicit `editable_roles` removal of Administrator. Impact is full control of that site (`manage_options`), not network super-admin. Severity assessed **High** given Editor prerequisite; Critical may be considered for site takeover on wordpress.org properties.