Found by hand-testing 4.9.0. This is #322 v2's headline control failing in a common flow, in exactly the way the code's own comment says it must not.
What a user sees
Users list → change a user's role → the challenge appears:
To continue: Change user role — please enter your password.
Target: users: 2, new_role: author
users: 2 is a bare database ID. new_role: author is a raw form field name. A human being asked to authorise something cannot tell from this which account is affected.
The tester's suggested wording is the right shape: "Demote someone to Author".
Why this is not cosmetic
Challenge::describe_stash_target() already knows this. Its own comment:
A bare numeric id ("user_id=5") tells an admin nothing about WHO they are authorizing — and this line is the only control left in the same-origin lure case, so resolve it to something a human can actually judge.
So the design explicitly identifies bare IDs as inadequate because the Target line is the last line of defence when an attacker lures a user into a gated request. In that scenario the user is shown users: 2 and has no way to judge it.
The whole argument for #322 v2 is that a coarse label is a blank cheque and naming the concrete target makes the password submit an informed decision. Here the target is named in a form only the database understands.
Root cause
The resolution is keyed to a single field name:
if ( 'user_id' === $key && current_user_can( 'list_users' ) ) {
$user = get_userdata( (int) $value );
if ( $user && ! empty( $user->user_login ) ) {
$value = $user->user_login . ' (#' . (int) $value . ')';
Only user_id is resolved. The Users-list role change posts users[], so $key is users and the branch never fires. user.promote's own stash allowlist confirms the field names:
'stash' => self::stash_allowlist( array( 'users', 'new_role', 'changeit' ) ),
So the resolution and the allowlist disagree about what a user identifier is called. The lookup was written for users.php?action=promote&user_id=N (the row-action path) and never extended to the bulk/dropdown path.
What a fix needs
- Resolve
users as well as user_id, including the multi-value case — the field is an array, so several accounts can be affected by one confirmation, and naming only the first would be worse than naming none.
- Render field names as human labels.
new_role: author should read as a role name, not a form key.
- Keep the existing
current_user_can( 'list_users' ) capability gate. It is there because resolving an ID to a login is a server-side lookup, not an echo of what the requester supplied — without the gate the challenge page becomes a username-enumeration oracle for a caller who cannot otherwise list users. Any fix must preserve that.
The stated goal is a sentence a non-technical administrator can act on. Demote alice (#2) to Author is judgeable; users: 2, new_role: author is not.
Related observation, filed as a question rather than a defect
The action was a demotion (Administrator → Author) and it was still gated. user.promote's admin matcher returns true for any changeit + new_role with no escalation test:
return isset( $_REQUEST['changeit'] ) && isset( $_REQUEST['new_role'] );
Gating demotions may well be deliberate for this plugin — removing an administrator is consequential too, and the rule id being promote may simply be a naming artefact. Flagging it only because the label then reads oddly: the challenge says "Change user role", which is accurate, while the rule that fired is called promote. If demotions are intended to gate, no change is needed beyond the wording above.
Severity
Not a security hole — the gate fires correctly and nothing is replayed that should not be. But it defeats the purpose of the control 4.9.0 is built around, in a flow administrators use routinely. Release-owner call; see #429, which is in the same lane and already pulled the tag.
Found by hand-testing 4.9.0. This is #322 v2's headline control failing in a common flow, in exactly the way the code's own comment says it must not.
What a user sees
Users list → change a user's role → the challenge appears:
users: 2is a bare database ID.new_role: authoris a raw form field name. A human being asked to authorise something cannot tell from this which account is affected.The tester's suggested wording is the right shape: "Demote someone to Author".
Why this is not cosmetic
Challenge::describe_stash_target()already knows this. Its own comment:So the design explicitly identifies bare IDs as inadequate because the Target line is the last line of defence when an attacker lures a user into a gated request. In that scenario the user is shown
users: 2and has no way to judge it.The whole argument for #322 v2 is that a coarse label is a blank cheque and naming the concrete target makes the password submit an informed decision. Here the target is named in a form only the database understands.
Root cause
The resolution is keyed to a single field name:
Only
user_idis resolved. The Users-list role change postsusers[], so$keyisusersand the branch never fires.user.promote's own stash allowlist confirms the field names:So the resolution and the allowlist disagree about what a user identifier is called. The lookup was written for
users.php?action=promote&user_id=N(the row-action path) and never extended to the bulk/dropdown path.What a fix needs
usersas well asuser_id, including the multi-value case — the field is an array, so several accounts can be affected by one confirmation, and naming only the first would be worse than naming none.new_role: authorshould read as a role name, not a form key.current_user_can( 'list_users' )capability gate. It is there because resolving an ID to a login is a server-side lookup, not an echo of what the requester supplied — without the gate the challenge page becomes a username-enumeration oracle for a caller who cannot otherwise list users. Any fix must preserve that.The stated goal is a sentence a non-technical administrator can act on.
Demote alice (#2) to Authoris judgeable;users: 2, new_role: authoris not.Related observation, filed as a question rather than a defect
The action was a demotion (Administrator → Author) and it was still gated.
user.promote's admin matcher returns true for anychangeit+new_rolewith no escalation test:Gating demotions may well be deliberate for this plugin — removing an administrator is consequential too, and the rule id being
promotemay simply be a naming artefact. Flagging it only because the label then reads oddly: the challenge says "Change user role", which is accurate, while the rule that fired is calledpromote. If demotions are intended to gate, no change is needed beyond the wording above.Severity
Not a security hole — the gate fires correctly and nothing is replayed that should not be. But it defeats the purpose of the control 4.9.0 is built around, in a flow administrators use routinely. Release-owner call; see #429, which is in the same lane and already pulled the tag.