Skip to content
Open
4 changes: 2 additions & 2 deletions src/wp-admin/comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,9 @@
$post_id = $comment->comment_post_ID;
if ( current_user_can( 'edit_post', $post_id ) ) {
$post_link = "<a href='" . esc_url( get_edit_post_link( $post_id ) ) . "'>";
$post_link .= esc_html( get_the_title( $post_id ) ) . '</a>';
$post_link .= wp_kses_post_title( get_the_title( $post_id ) ) . '</a>';
} else {
$post_link = esc_html( get_the_title( $post_id ) );
$post_link = wp_kses_post_title( get_the_title( $post_id ) );
}
echo $post_link;

Expand Down
4 changes: 2 additions & 2 deletions src/wp-admin/edit-form-comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@
$post_id = $comment->comment_post_ID;
if ( current_user_can( 'edit_post', $post_id ) ) {
$post_link = "<a href='" . esc_url( get_edit_post_link( $post_id ) ) . "'>";
$post_link .= esc_html( get_the_title( $post_id ) ) . '</a>';
$post_link .= wp_kses_post_title( get_the_title( $post_id ) ) . '</a>';
} else {
$post_link = esc_html( get_the_title( $post_id ) );
$post_link = wp_kses_post_title( get_the_title( $post_id ) );
}
?>

Expand Down
4 changes: 2 additions & 2 deletions src/wp-admin/includes/class-wp-comments-list-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -1121,9 +1121,9 @@ public function column_response( $comment ) {

if ( current_user_can( 'edit_post', $post->ID ) ) {
$post_link = "<a href='" . get_edit_post_link( $post->ID ) . "' class='comments-edit-item-link'>";
$post_link .= esc_html( get_the_title( $post->ID ) ) . '</a>';
$post_link .= wp_kses_post_title( get_the_title( $post->ID ) ) . '</a>';
} else {
$post_link = esc_html( get_the_title( $post->ID ) );
$post_link = wp_kses_post_title( get_the_title( $post->ID ) );
}

echo '<div class="response-links">';
Expand Down
15 changes: 9 additions & 6 deletions src/wp-admin/includes/class-wp-media-list-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ public function column_cb( $item ) {
<span class="screen-reader-text">
<?php
/* translators: Hidden accessibility text. %s: Attachment title. */
printf( __( 'Select %s' ), _draft_or_post_title() );
printf( __( 'Select %s' ), _draft_or_post_title( $post ) );
?>
</span>
</label>
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -653,7 +653,7 @@ public function column_parent( $post ) {
'<br /><a href="#the-list" onclick="findPosts.open( \'media[]\', \'%s\' ); return false;" class="hide-if-no-js aria-button-if-js" aria-label="%s">%s</a>',
$post->ID,
/* translators: %s: Attachment title. */
esc_attr( sprintf( __( 'Attach &#8220;%s&#8221; to existing content' ), $title ) ),
esc_attr( sprintf( __( 'Attach &#8220;%s&#8221; to existing content' ), wp_strip_all_tags( $title ) ) ),
__( 'Attach' )
);
}
Expand Down Expand Up @@ -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();
Expand All @@ -777,6 +777,8 @@ public function display_rows() {
</tr>
<?php
endwhile;

remove_filter( 'the_title', 'wp_kses_post_title' );
}

/**
Expand All @@ -800,7 +802,8 @@ protected function get_default_primary_column_name() {
* @return array<string, string> 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(
Expand Down Expand Up @@ -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 );
Expand Down
23 changes: 17 additions & 6 deletions src/wp-admin/includes/class-wp-posts-list-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}

/**
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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' );
}

/**
Expand Down Expand Up @@ -1232,7 +1243,7 @@ public function column_title( $post ) {

echo '<strong>';

$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 );
Expand Down Expand Up @@ -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 "</strong>\n";
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions src/wp-admin/includes/dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -689,8 +689,8 @@ function wp_dashboard_recent_drafts( $drafts = false ) {
'<div class="draft-title"><a href="%s" aria-label="%s">%s</a><time datetime="%s">%s</time></div>',
esc_url( $url ),
/* translators: %s: Post title. */
esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;' ), $title ) ),
esc_html( $title ),
esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;' ), wp_strip_all_tags( $title ) ) ),
$title,
get_the_time( 'c', $draft ),
get_the_time( __( 'F j, Y' ), $draft )
);
Expand Down Expand Up @@ -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 &#8220;%s&#8221;' ), $draft_or_post_title ) ),
esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;' ), wp_strip_all_tags( $draft_or_post_title ) ) ),
$draft_or_post_title
);
}
Expand Down
3 changes: 2 additions & 1 deletion src/wp-admin/includes/template.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 );
}

/**
Expand Down
25 changes: 25 additions & 0 deletions src/wp-includes/post-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em>page</em> 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).
*
Expand Down
44 changes: 44 additions & 0 deletions tests/phpunit/tests/admin/wpPostsListTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em class="title">page</em> title',
)
);

$output = $this->render_column_title( $post, 'list' );

$this->assertStringContainsString(
sprintf( '<a class="row-title" href="%s">The <em class="title">page</em> title</a>', get_edit_post_link( $post->ID ) ),
$output
);
$this->assertStringNotContainsString(
sprintf( '<a class="row-title" href="%s">The &lt;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 ) );
}
}
35 changes: 35 additions & 0 deletions tests/phpunit/tests/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em class="title">page</em> <strong>title</strong>';

$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 <a href="https://example.com">page</a> & title <script>alert(1)</script>' );

$this->assertStringNotContainsString( '<a ', $result );
$this->assertStringNotContainsString( '<script', $result );
$this->assertStringContainsString( 'page', $result );
$this->assertStringContainsString( '&amp; title', $result );
$this->assertStringContainsString( 'alert(1)', $result );
}

/**
* @ticket 66244
* @covers ::wp_kses_post_title
*/
public function test_wp_kses_post_title_strips_disallowed_attributes() {
$this->assertSame(
'<em class="title">page</em>',
wp_kses_post_title( '<em class="title" onclick="alert(1)">page</em>' )
);
}
}
Loading