diff --git a/src/wp-admin/comment.php b/src/wp-admin/comment.php
index ed93797a64faf..28e8df62e9fad 100644
--- a/src/wp-admin/comment.php
+++ b/src/wp-admin/comment.php
@@ -205,9 +205,9 @@
$post_id = $comment->comment_post_ID;
if ( current_user_can( 'edit_post', $post_id ) ) {
$post_link = "";
- $post_link .= esc_html( get_the_title( $post_id ) ) . '';
+ $post_link .= wp_kses_post_title( get_the_title( $post_id ) ) . '';
} else {
- $post_link = esc_html( get_the_title( $post_id ) );
+ $post_link = wp_kses_post_title( get_the_title( $post_id ) );
}
echo $post_link;
diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php
index 375dd61ad955e..6f6da27c49f30 100644
--- a/src/wp-admin/edit-form-comment.php
+++ b/src/wp-admin/edit-form-comment.php
@@ -184,9 +184,9 @@
$post_id = $comment->comment_post_ID;
if ( current_user_can( 'edit_post', $post_id ) ) {
$post_link = "";
- $post_link .= esc_html( get_the_title( $post_id ) ) . '';
+ $post_link .= wp_kses_post_title( get_the_title( $post_id ) ) . '';
} else {
- $post_link = esc_html( get_the_title( $post_id ) );
+ $post_link = wp_kses_post_title( get_the_title( $post_id ) );
}
?>
diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php
index 2b927a7f81a6a..0d3b8698ff94f 100644
--- a/src/wp-admin/includes/class-wp-comments-list-table.php
+++ b/src/wp-admin/includes/class-wp-comments-list-table.php
@@ -1121,9 +1121,9 @@ public function column_response( $comment ) {
if ( current_user_can( 'edit_post', $post->ID ) ) {
$post_link = "';
+ $post_link .= wp_kses_post_title( get_the_title( $post->ID ) ) . '';
} else {
- $post_link = esc_html( get_the_title( $post->ID ) );
+ $post_link = wp_kses_post_title( get_the_title( $post->ID ) );
}
echo '
';
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 feac4753d3e92..bc44c52064a47 100644
--- a/src/wp-admin/includes/class-wp-media-list-table.php
+++ b/src/wp-admin/includes/class-wp-media-list-table.php
@@ -455,7 +455,7 @@ public function column_cb( $item ) {
@@ -483,7 +483,7 @@ public function column_title( $post ) {
}
}
- $title = _draft_or_post_title();
+ $title = _draft_or_post_title( $post );
$thumb = wp_get_attachment_image( $attachment_id, array( 60, 60 ), true, array( 'alt' => '' ) );
$link_start = '';
$link_end = '';
@@ -653,7 +653,7 @@ public function column_parent( $post ) {
'
%s',
$post->ID,
/* translators: %s: Attachment title. */
- esc_attr( sprintf( __( 'Attach “%s” to existing content' ), $title ) ),
+ esc_attr( sprintf( __( 'Attach “%s” to existing content' ), wp_strip_all_tags( $title ) ) ),
__( 'Attach' )
);
}
@@ -759,7 +759,7 @@ public function display_rows() {
$this->comment_pending_count = get_pending_comments_num( $post_ids );
- add_filter( 'the_title', 'esc_html' );
+ add_filter( 'the_title', 'wp_kses_post_title' );
while ( have_posts() ) :
the_post();
@@ -777,6 +777,8 @@ public function display_rows() {
An array of row actions.
*/
private function _get_row_actions( $post, $att_title ) {
- $actions = array();
+ $att_title = wp_strip_all_tags( $att_title );
+ $actions = array();
if ( ! $this->is_trash && current_user_can( 'edit_post', $post->ID ) ) {
$actions['edit'] = sprintf(
@@ -927,7 +930,7 @@ 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();
+ $att_title = _draft_or_post_title( $post );
$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 00edeed1acfa1..0591c6f324c5e 100644
--- a/src/wp-admin/includes/class-wp-posts-list-table.php
+++ b/src/wp-admin/includes/class-wp-posts-list-table.php
@@ -812,13 +812,15 @@ public function display_rows( $posts = array(), $level = 0 ) {
$posts = $wp_query->posts;
}
- add_filter( 'the_title', 'esc_html' );
+ add_filter( 'the_title', 'wp_kses_post_title' );
if ( $this->hierarchical_display ) {
$this->_display_rows_hierarchical( $posts, $this->get_pagenum(), $per_page );
} else {
$this->_display_rows( $posts, $level );
}
+
+ remove_filter( 'the_title', 'wp_kses_post_title' );
}
/**
@@ -1078,7 +1080,7 @@ public function column_cb( $item ) {
*/
if ( apply_filters( 'wp_list_table_show_post_checkbox', $show, $post ) ) :
- $post_title = _draft_or_post_title();
+ $post_title = _draft_or_post_title( $post );
// If the post has no title, try adding part of the excerpt.
$no_title_excerpt = $this->get_no_title_excerpt( $post );
@@ -1141,7 +1143,16 @@ protected function _column_title( $post, $classes, $data, $primary ) {
* @return string The post title, or 'no title' if no title.
*/
protected function get_primary_column_aria_label( $item ) {
- return ! empty( $item->post_title ) ? $item->post_title : __( 'no title' );
+ if ( empty( $item->post_title ) ) {
+ return __( 'no title' );
+ }
+
+ // Keep screen-reader labels as plain text, matching media list table behavior.
+ $title = wp_strip_all_tags( $item->post_title );
+ $title = html_entity_decode( $title, ENT_QUOTES, get_bloginfo( 'charset' ) );
+ $title = trim( $title );
+
+ return '' !== $title ? $title : __( 'no title' );
}
/**
@@ -1232,7 +1243,7 @@ public function column_title( $post ) {
echo '
';
- $title = _draft_or_post_title();
+ $title = _draft_or_post_title( $post );
// If the post has no title, try adding part of the excerpt.
$no_title_excerpt = $this->get_no_title_excerpt( $post );
@@ -1261,7 +1272,7 @@ public function column_title( $post ) {
if ( isset( $parent_name ) ) {
$post_type_object = get_post_type_object( $post->post_type );
- echo ' | ' . $post_type_object->labels->parent_item_colon . ' ' . esc_html( $parent_name );
+ echo ' | ' . $post_type_object->labels->parent_item_colon . ' ' . wp_kses_post_title( $parent_name );
}
echo "\n";
@@ -1585,7 +1596,7 @@ 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();
+ $title = wp_strip_all_tags( _draft_or_post_title( $post ) );
if ( $can_edit_post && 'trash' !== $post->post_status ) {
$actions['edit'] = sprintf(
diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php
index 488e01aa92481..c28720dfeeaa6 100644
--- a/src/wp-admin/includes/dashboard.php
+++ b/src/wp-admin/includes/dashboard.php
@@ -689,8 +689,8 @@ function wp_dashboard_recent_drafts( $drafts = false ) {
'
',
esc_url( $url ),
/* translators: %s: Post title. */
- esc_attr( sprintf( __( 'Edit “%s”' ), $title ) ),
- esc_html( $title ),
+ esc_attr( sprintf( __( 'Edit “%s”' ), wp_strip_all_tags( $title ) ) ),
+ $title,
get_the_time( 'c', $draft ),
get_the_time( __( 'F j, Y' ), $draft )
);
@@ -1057,7 +1057,7 @@ function wp_dashboard_recent_posts( $args ) {
sprintf( _x( '%1$s, %2$s', 'dashboard' ), $date, get_the_time() ),
$recent_post_link,
/* translators: %s: Post title. */
- esc_attr( sprintf( __( 'Edit “%s”' ), $draft_or_post_title ) ),
+ esc_attr( sprintf( __( 'Edit “%s”' ), wp_strip_all_tags( $draft_or_post_title ) ) ),
$draft_or_post_title
);
}
diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php
index e8f6c4f5bab6c..403221582862b 100644
--- a/src/wp-admin/includes/template.php
+++ b/src/wp-admin/includes/template.php
@@ -2107,6 +2107,7 @@ function the_post_password() {
* returned.
*
* @since 2.7.0
+ * @since 7.2.0 Permits a subset of formatting tags instead of encoding all HTML.
*
* @param int|WP_Post $post Optional. Post ID or WP_Post object. Default is global $post.
* @return string The post title if set.
@@ -2116,7 +2117,7 @@ function _draft_or_post_title( $post = 0 ) {
if ( empty( $title ) ) {
$title = __( '(no title)' );
}
- return esc_html( $title );
+ return wp_kses_post_title( $title );
}
/**
diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php
index 8a571495e96ca..29f634b9934bd 100644
--- a/src/wp-includes/post-template.php
+++ b/src/wp-includes/post-template.php
@@ -181,6 +181,31 @@ function get_the_title( $post = 0 ) {
return apply_filters( 'the_title', $post_title, $post_id );
}
+/**
+ * Sanitizes a post title for HTML display, allowing limited formatting tags.
+ *
+ * WordPress permits HTML in post titles. `esc_html()` encodes those tags, so a
+ * title such as `The
page title` is shown with the tags as text.
+ * This keeps a small set of inline formatting tags and escapes everything else.
+ *
+ * @since 7.2.0
+ *
+ * @param string $title Post title to sanitize.
+ * @return string Sanitized title safe for HTML display.
+ */
+function wp_kses_post_title( $title ) {
+ return wp_kses(
+ $title,
+ array(
+ 'strong' => array( 'class' => true ),
+ 'em' => array( 'class' => true ),
+ 'b' => array( 'class' => true ),
+ 'i' => array( 'class' => true ),
+ 'span' => array( 'class' => true ),
+ )
+ );
+}
+
/**
* Displays the Post Global Unique Identifier (guid).
*
diff --git a/tests/phpunit/tests/admin/wpPostsListTable.php b/tests/phpunit/tests/admin/wpPostsListTable.php
index 25324dcb4cb7f..1c792592ce79a 100644
--- a/tests/phpunit/tests/admin/wpPostsListTable.php
+++ b/tests/phpunit/tests/admin/wpPostsListTable.php
@@ -596,4 +596,48 @@ public function test_checkbox_label_includes_no_title_excerpt() {
$this->assertStringContainsString( 'Select (no title) Hello world example excerpt.', $output );
}
+
+ /**
+ * Formatting tags in a post title are rendered in the list table title column.
+ *
+ * @ticket 66244
+ *
+ * @covers ::wp_kses_post_title
+ * @covers ::_draft_or_post_title
+ * @covers WP_Posts_List_Table::column_title
+ * @covers WP_Posts_List_Table::get_primary_column_aria_label
+ */
+ public function test_post_title_formatting_tags_are_rendered_in_list_table() {
+ $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ if ( is_multisite() ) {
+ grant_super_admin( $user_id );
+ }
+ wp_set_current_user( $user_id );
+
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_type' => 'post',
+ 'post_title' => 'The
page title',
+ )
+ );
+
+ $output = $this->render_column_title( $post, 'list' );
+
+ $this->assertStringContainsString(
+ sprintf( '
The page title', get_edit_post_link( $post->ID ) ),
+ $output
+ );
+ $this->assertStringNotContainsString(
+ sprintf( '
The <em', get_edit_post_link( $post->ID ) ),
+ $output
+ );
+
+ $table = _get_list_table( 'WP_Posts_List_Table', array( 'screen' => 'edit-post' ) );
+ $method = new ReflectionMethod( $table, 'get_primary_column_aria_label' );
+ if ( PHP_VERSION_ID < 80100 ) {
+ $method->setAccessible( true );
+ }
+
+ $this->assertSame( 'The page title', $method->invoke( $table, $post ) );
+ }
}
diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php
index 18c4701b30d4d..63c6c63a2f491 100644
--- a/tests/phpunit/tests/kses.php
+++ b/tests/phpunit/tests/kses.php
@@ -2933,4 +2933,39 @@ public function data_allowed_attributes_in_descriptions() {
),
);
}
+
+ /**
+ * @ticket 66244
+ * @covers ::wp_kses_post_title
+ */
+ public function test_wp_kses_post_title_allows_formatting_tags() {
+ $title = 'The page title';
+
+ $this->assertSame( $title, wp_kses_post_title( $title ) );
+ }
+
+ /**
+ * @ticket 66244
+ * @covers ::wp_kses_post_title
+ */
+ public function test_wp_kses_post_title_strips_disallowed_tags_and_encodes_ampersands() {
+ $result = wp_kses_post_title( 'The page & title ' );
+
+ $this->assertStringNotContainsString( '
assertStringNotContainsString( '