Resumen
Los sitios locales de WordPress.org (Rosetta, del tipo xx.wordpress.org/team/) delegan parte de la gestión de usuarios en los Editores: pueden invitar al sitio a cuentas que ya existen en la red (promote_users), pero no deben poder darles el rol de Administrador. Rosetta impone ese límite con el mecanismo oficial de WordPress, el filtro editable_roles, del que quita administrator cuando el usuario no es superadministrador:
// 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;
}
El fallo estaba en el mu-plugin propio de wordpress.org wporg-add-existing-users-via-username.php, que atiende la acción admin_action_adduser. Para quien no es superadministrador, guardaba la invitación con el rol leído directamente de $_REQUEST['role'] y solo después consultaba get_editable_roles(), y únicamente para poner el nombre del rol en el correo:
// 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'] ) … )
Si administrator no está en get_editable_roles(), como mucho el correo sale con un aviso o con el nombre del rol vacío, pero add_option ya se ha ejecutado.
Cuando se acepta la invitación (ruta /newbloguser/{key}/), el núcleo de WordPress aplica el rol guardado sin volver a comprobar si quien invitó podía asignarlo:
// 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()
La lista blanca de roles existía, pero el camino de las invitaciones no la usaba. Desde WordPress 6.8 el núcleo ofrece wp_ensure_editable_role() justo para cortar la ejecución (con un 403) si el usuario actual no puede asignar ese rol, y se usa ya en otras rutas:
wp_ensure_editable_role( $_REQUEST['role'] ); // used on some create / noconfirmation paths
Este mu-plugin no la llamaba antes de guardar la invitación. Así, un Editor podía enviar role=administrator (valor que el desplegable de la interfaz no ofrece), crear una invitación válida y convertir la cuenta invitada en Administrador del sitio.
El investigador aclara que el caso no se aplica a Learn (workshop_reviewer): allí Administrador no se quita de editable_roles y, por tanto, no hay límite que saltarse. Rosetta sí lo quita.
Pasos de reproducción
El investigador lo comprobó revisando el código y en un laboratorio Multisite propio, no contra wordpress.org en producción:
- Montar un WordPress Multisite con una copia del mu-plugin
wporg-add-existing-users-via-username.phpde producción, un filtroeditable_rolesequivalente al de Rosetta (sinadministrator) y la capacidadpromote_userspara los Editores. - Crear una cuenta Editor (
lab_editor) y otra cuenta (lab_victim) que todavía no pertenece al sitio. - Guardar una invitación con
role=administrator, siguiendo el mismo orden que el mu-plugin (primeroadd_option, después la consulta de roles). - Llamar a
add_existing_user_to_blog()del núcleo, que es lo que se ejecuta al aceptar la invitación. - Comprobar en la base de datos que la cuenta invitada ha quedado como Administrador del sitio:
lab_victim wp_capabilities a:1:{s:13:"administrator";b:1;}
La cuenta tenía además manage_options = true. La clave de la invitación coincide con la forma que usa el mu-plugin: substr( md5( user_id ), 0, 5 ).
Impacto
Un Editor autenticado de un sitio local de Rosetta podía convertir en Administrador de ese sitio a otra cuenta de la red, incluida una suya, saltándose la exclusión explícita del rol en editable_roles. Eso da el control completo del sitio (manage_options), aunque no el de superadministrador de toda la red. El investigador lo valoró como gravedad alta por exigir ser Editor, y apuntó que podría considerarse crítica al tratarse de tomar el control de sitios de wordpress.org. El programa pagó recompensa, de importe no público.
Remediación
El reporte está marcado como resuelto, pero no detalla el cambio aplicado. Lo que propuso el investigador:
- Llamar a
wp_ensure_editable_role( $_REQUEST['role'] )(o algo equivalente) antes deadd_option. - Guardar solo un rol ya validado contra
get_editable_roles(). - Como defensa en profundidad, volver a validar en
add_existing_user_to_blog()del núcleo antes deset_role. - Usar claves de invitación no deterministas en lugar de
substr(md5(user_id),0,5), como ya hace la invitación del núcleo con claves aleatorias más largas.
Qué aprender de este caso
- Cuando un formulario limita las opciones solo en el desplegable (aquí, los roles), prueba a enviar a mano el valor que no aparece: lo que cuenta es si el servidor lo comprueba al recibirlo.
- En flujos de dos tiempos (invitar y luego aceptar), mira en qué momento se valida el permiso. Si se guarda un dato sin validar y se aplica más tarde en otro contexto, nadie lo vuelve a mirar.
- En WordPress, el código propio que recibe un rol de la petición debería pasarlo por
wp_ensure_editable_role()o comprobarlo contraget_editable_roles()antes de guardarlo, no solo usarlo para mostrar texto. - Las claves de invitación derivadas de un dato conocido, como cinco caracteres del
md5del ID de usuario, son predecibles; mejor generarlas al azar y con longitud suficiente.