Make WordPress Core

Opened 5 weeks ago

Last modified 3 weeks ago

#65777 new defect (bug)

Login form aria-describedby ignores one of the two notices login_header() can render

Reported by: khokansardar Owned by:
Priority: normal Milestone: 7.2
Component: Login and Registration Version: 6.1
Severity: normal Keywords: has-patch has-unit-tests
Cc: Focuses: accessibility

Description (last modified by khokansardar)

login_header() splits $errors into two separate notices by severity, and can render both at once:

  • #login_error — items whose error data is not 'message'
  • #login-message — items whose error data is 'message'

The aria-describedby logic that wires those notices to the username and password fields assumes only one of them exists. When both render, one of them is left unreferenced: visible on screen, but never announced when focus reaches the input.

Affected code

src/wp-login.php, lines 1499–1508:

$aria_describedby = '';
$has_errors       = $errors->has_errors();

if ( $has_errors ) {
        $aria_describedby = ' aria-describedby="login_error"';
}

if ( $has_errors && 'message' === $errors->get_error_data() ) {
        $aria_describedby = ' aria-describedby="login-message"';
}

The value is consumed at lines 1517 (#user_login) and 1523 (#user_pass). The notices themselves are built in login_header(), at lines 243–312.

Root cause

Two independent defects in the same block:

  1. get_error_data() is called with no argument. It falls back to get_error_code(), which returns only the first error code (src/wp-includes/class-wp-error.php, lines 155–161). The severity of every other item is ignored.
  2. The second branch overwrites instead of appending. aria-describedby accepts a space-separated list of IDs, but these two branches can only ever emit exactly one ID.

A failed sign-in populates $errors before the informational message is appended, so in practice the error-severity code is the first one, $aria_describedby sticks at login_error, and #login-message is left unreferenced. The logic is fragile in the other direction too: if a 'message'-severity code were ever the first one, the real error notice would be the one left unreferenced.

Steps to reproduce

  1. Load wp-login.php with redirect_to pointing at about.php?updated, the address WordPress returns users to after a core update: wp-login.php?redirect_to=%2Fwp-admin%2Fabout.php%3Fupdated
  2. Submit the form with invalid credentials.
  3. Inspect the rendered markup of #user_login and #user_pass.

This is reachable in core, without a plugin. wp_signon() puts an error-severity code into $errors, and wp-login.php:1444 then adds updated at 'message' severity to the same WP_Error object. $redirect_to survives the submission through the form's hidden redirect_to field, so both notices render on the response to the failed sign-in.

Actual

notices rendered : id="login_error" and id="login-message"
aria-describedby : aria-describedby="login_error"

The message "You have successfully updated WordPress! Please log back in to see what's new." is rendered, but is never associated with either field.

Expected

aria-describedby="login_error login-message"

Change History (5)

This ticket was mentioned in PR #12796 on WordPress/wordpress-develop by @khokansardar.


5 weeks ago
#1

  • Keywords has-unit-tests added

login_header() splits the login errors into a separate notice per severity and can display both at the same time. The aria-describedby logic assumed only one of them existed, so when both were displayed one notice was left unreferenced.

What the problem was:

  • #login_error (errors) and #login-message (informational messages) can both render at once, but the association logic could only ever emit a single ID, so the other notice was visible on screen and never announced when focus reached the username or password field.
  • WP_Error::get_error_data() was called without a code, so it fell back to the first error code and ignored the severity of every other item.
  • The second branch overwrote the attribute instead of appending to it.

What the fix does:

  • Determines the severities the same way login_header() does, by passing each error code to WP_Error::get_error_data(), and references every notice that is actually displayed.

Approach and why:

  • Mirroring login_header()'s own partition means the IDs cannot drift from what is rendered.
  • IDs are emitted in the order login_header() outputs the notices, so the order the descriptions are announced in matches the visual order regardless of error-code order.
  • No escaping is applied because both IDs are hardcoded literals, consistent with the code being replaced.

Trac ticket: https://core.trac.wordpress.org/ticket/65777

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Ticket analysis, tests cases and generate ticket and PR. All changes were reviewed and validated by me.

#2 @afercia
5 weeks ago

@khokansardar thanks for the thorough report. I see the issue.
There's a few typos, incomplete sentences, and undesired line breaks that make the report a little difficult to parse. Could you please fix them, wnen you have a chance? If you don't have permission to edit, please add the whole text in a comment. I can later edit and delete the comment. Thank you.

#3 @khokansardar
5 weeks ago

  • Description modified (diff)

This ticket was mentioned in Slack in #accessibility by joedolson. View the logs.


3 weeks ago

#5 @joedolson
3 weeks ago

  • Milestone Awaiting Review7.2
Note: See TracTickets for help on using tickets.