diff --git a/src/wp-admin/includes/class-wp-links-list-table.php b/src/wp-admin/includes/class-wp-links-list-table.php index de116ecf944bc..f89ace610c052 100644 --- a/src/wp-admin/includes/class-wp-links-list-table.php +++ b/src/wp-admin/includes/class-wp-links-list-table.php @@ -203,11 +203,20 @@ public function column_cb( $item ) { */ public function column_name( $link ) { $edit_link = get_edit_bookmark_link( $link ); + + /* + * `display_rows()` escapes the name, so entities are decoded first to let + * `wp_strip_all_tags()` detect the HTML. Otherwise it survives the + * `esc_attr()` call below and is announced as literal text. + */ + $aria_label_name = html_entity_decode( $link->link_name, ENT_QUOTES, get_bloginfo( 'charset' ) ); + $aria_label_name = wp_strip_all_tags( $aria_label_name ); + printf( '%s', $edit_link, /* translators: %s: Link name. */ - esc_attr( sprintf( __( 'Edit “%s”' ), $link->link_name ) ), + esc_attr( sprintf( __( 'Edit “%s”' ), $aria_label_name ) ), $link->link_name ); } diff --git a/src/wp-admin/includes/class-wp-media-list-table.php b/src/wp-admin/includes/class-wp-media-list-table.php index a14b498ea525b..1fc16a67ca8a9 100644 --- a/src/wp-admin/includes/class-wp-media-list-table.php +++ b/src/wp-admin/includes/class-wp-media-list-table.php @@ -927,7 +927,13 @@ protected function handle_row_actions( $item, $column_name, $primary ) { // Restores the more descriptive, specific name for use within this method. $post = $item; - $att_title = _draft_or_post_title(); + /* + * `_draft_or_post_title()` escapes the title, so entities are decoded first + * to let `wp_strip_all_tags()` detect the HTML. Otherwise it survives the + * `esc_attr()` calls on the `aria-label` values and is announced as literal text. + */ + $att_title = html_entity_decode( _draft_or_post_title(), ENT_QUOTES, get_bloginfo( 'charset' ) ); + $att_title = wp_strip_all_tags( $att_title ); $actions = $this->_get_row_actions( $post, $att_title ); return $this->row_actions( $actions ); diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php index 3795495d6d21a..bb56b9fc280a6 100644 --- a/src/wp-admin/includes/class-wp-posts-list-table.php +++ b/src/wp-admin/includes/class-wp-posts-list-table.php @@ -1579,7 +1579,14 @@ protected function handle_row_actions( $item, $column_name, $primary ) { $post_type_object = get_post_type_object( $post->post_type ); $can_edit_post = current_user_can( 'edit_post', $post->ID ); $actions = array(); - $title = _draft_or_post_title(); + + /* + * `_draft_or_post_title()` escapes the title, so entities are decoded first + * to let `wp_strip_all_tags()` detect the HTML. Otherwise it survives the + * `esc_attr()` calls below and is announced as literal text. + */ + $title = html_entity_decode( _draft_or_post_title(), ENT_QUOTES, get_bloginfo( 'charset' ) ); + $title = wp_strip_all_tags( $title ); if ( $can_edit_post && 'trash' !== $post->post_status ) { $actions['edit'] = sprintf( diff --git a/tests/phpunit/tests/admin/wpLinksListTable.php b/tests/phpunit/tests/admin/wpLinksListTable.php new file mode 100644 index 0000000000000..7f1c7bd44c082 --- /dev/null +++ b/tests/phpunit/tests/admin/wpLinksListTable.php @@ -0,0 +1,56 @@ +table = _get_list_table( 'WP_Links_List_Table', array( 'screen' => 'link-manager' ) ); + } + + /** + * Tests that `WP_Links_List_Table::column_name()` strips HTML from the link + * name used within the `aria-label` attribute. + * + * The name is escaped by `WP_Links_List_Table::display_rows()`, so any HTML + * it contains survives `esc_attr()` and is announced as literal text by + * screen readers. + * + * @ticket 65729 + * + * @covers WP_Links_List_Table::column_name + */ + public function test_column_name_should_strip_html_from_the_aria_label() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + // A link name is saved to the database with any HTML it contains already escaped. + $link = self::factory()->bookmark->create_and_get( array( 'link_name' => 'my<div>link' ) ); + + // Replicate the escaping applied by `WP_Links_List_Table::display_rows()`. + $link->link_name = esc_attr( $link->link_name ); + + ob_start(); + $this->table->column_name( $link ); + $output = ob_get_clean(); + + $this->assertStringContainsString( + 'aria-label="Edit “mylink”"', + $output, + 'The aria-label did not contain the stripped name.' + ); + $this->assertStringContainsString( + '>my<div>link', + $output, + 'The displayed link name should be left unchanged.' + ); + } +} diff --git a/tests/phpunit/tests/admin/wpMediaListTable.php b/tests/phpunit/tests/admin/wpMediaListTable.php index 31d2bfa7cbb9d..238910c325193 100644 --- a/tests/phpunit/tests/admin/wpMediaListTable.php +++ b/tests/phpunit/tests/admin/wpMediaListTable.php @@ -479,6 +479,46 @@ public function test_get_row_actions_should_not_include_download_without_an_atta $this->assertArrayNotHasKey( 'download', $actions, '"download" was included in the actions.' ); } + /** + * Tests that `WP_Media_List_Table::handle_row_actions()` strips HTML from the + * attachment title used within the row action `aria-label` attributes. + * + * The title is escaped by `_draft_or_post_title()`, so any HTML it contains + * survives `esc_attr()` and is announced as literal text by screen readers. + * + * @ticket 65729 + * + * @covers WP_Media_List_Table::handle_row_actions + */ + public function test_handle_row_actions_should_strip_html_from_aria_labels() { + wp_set_current_user( self::$admin ); + self::set_is_trash( false ); + + $attachment = self::factory()->attachment->create_and_get( + array( + 'post_title' => 'my
image', + 'file' => 'image.jpg', + 'post_mime_type' => 'image/jpeg', + ) + ); + + $GLOBALS['post'] = $attachment; + + $handle_row_actions = new ReflectionMethod( self::$list_table, 'handle_row_actions' ); + if ( PHP_VERSION_ID < 80100 ) { + $handle_row_actions->setAccessible( true ); + } + $output = $handle_row_actions->invoke( self::$list_table, $attachment, 'title', 'title' ); + if ( PHP_VERSION_ID < 80100 ) { + $handle_row_actions->setAccessible( false ); + } + + unset( $GLOBALS['post'] ); + + $this->assertStringNotContainsString( '<div>', $output, 'The escaped HTML was not stripped from the title.' ); + $this->assertStringContainsString( 'aria-label="Edit “myimage”"', $output, 'The "edit" aria-label did not contain the stripped title.' ); + } + /** * Sets the `$is_trash` property. * diff --git a/tests/phpunit/tests/admin/wpPostsListTable.php b/tests/phpunit/tests/admin/wpPostsListTable.php index 25324dcb4cb7f..757c605be362c 100644 --- a/tests/phpunit/tests/admin/wpPostsListTable.php +++ b/tests/phpunit/tests/admin/wpPostsListTable.php @@ -596,4 +596,44 @@ public function test_checkbox_label_includes_no_title_excerpt() { $this->assertStringContainsString( 'Select (no title) Hello world example excerpt.', $output ); } + + /** + * Tests that `WP_Posts_List_Table::handle_row_actions()` strips HTML from the + * post title used within the row action `aria-label` attributes. + * + * The title is escaped by `_draft_or_post_title()`, so any HTML it contains + * survives `esc_attr()` and is announced as literal text by screen readers. + * + * @ticket 65729 + * + * @covers WP_Posts_List_Table::handle_row_actions + */ + public function test_handle_row_actions_should_strip_html_from_aria_labels() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $post = self::factory()->post->create_and_get( + array( + 'post_title' => 'my
post', + 'post_status' => 'publish', + ) + ); + + $GLOBALS['post'] = $post; + + $table = _get_list_table( 'WP_Posts_List_Table', array( 'screen' => 'edit-post' ) ); + + $handle_row_actions = new ReflectionMethod( $table, 'handle_row_actions' ); + if ( PHP_VERSION_ID < 80100 ) { + $handle_row_actions->setAccessible( true ); + } + $output = $handle_row_actions->invoke( $table, $post, 'title', 'title' ); + if ( PHP_VERSION_ID < 80100 ) { + $handle_row_actions->setAccessible( false ); + } + + unset( $GLOBALS['post'] ); + + $this->assertStringNotContainsString( '<div>', $output, 'The escaped HTML was not stripped from the title.' ); + $this->assertStringContainsString( 'aria-label="Edit “mypost”"', $output, 'The "edit" aria-label did not contain the stripped title.' ); + } }