Skip to content
18 changes: 18 additions & 0 deletions src/wp-includes/comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -2186,6 +2186,7 @@ function wp_insert_comment( $commentdata ) {
* filtering the same comment more than once.
*
* @since 2.0.0
* @since 7.1.0 The note mention attributes are allowed in the content of `note` comments.
*
* @param array $commentdata Contains information on the comment.
* @return array Parsed comment information.
Expand Down Expand Up @@ -2218,6 +2219,19 @@ function wp_filter_comment( $commentdata ) {
$commentdata['comment_agent'] = apply_filters( 'pre_comment_user_agent', ( $commentdata['comment_agent'] ?? '' ) );
/** This filter is documented in wp-includes/comment.php */
$commentdata['comment_author'] = apply_filters( 'pre_comment_author_name', $commentdata['comment_author'] );
/*
* Notes may contain `@` mention markup that the default comment kses
* allowlist would strip. The extended allowlist is attached only while
* this note's content is filtered - never for other comment types - so
* that the markup is not writable from regular (including anonymous)
* comments. See _wp_kses_allow_note_mention_attributes().
*/
$is_note = isset( $commentdata['comment_type'] ) && 'note' === $commentdata['comment_type'];

if ( $is_note ) {
add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_note_mention_attributes', 10, 2 );
}

/**
* Filters the comment content before it is set.
*
Expand All @@ -2226,6 +2240,10 @@ function wp_filter_comment( $commentdata ) {
* @param string $comment_content The comment content.
*/
$commentdata['comment_content'] = apply_filters( 'pre_comment_content', $commentdata['comment_content'] );

if ( $is_note ) {
remove_filter( 'wp_kses_allowed_html', '_wp_kses_allow_note_mention_attributes' );
}
/**
* Filters the comment author's IP address before it is set.
*
Expand Down
40 changes: 40 additions & 0 deletions src/wp-includes/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,46 @@ function wp_kses_allowed_html( $context = '' ) {
}
}

/**
* Allows the note mention markup in comment content.
*
* The notes `@` mention completer stores a mention as a link to the
* mentioned user's author page, the `user-N` class carrying the mentioned
* user's ID: `<a class="wp-note-mention user-N" href="…">@Name</a>`. The
* default comment allowlist includes `a` but only its `href` and `title`
* attributes, so for users without `unfiltered_html` the mention classes
* would be stripped on save.
*
* This callback is deliberately not attached globally: `class` attributes are
* CSS and JavaScript selector hooks, so allowing them in every comment would
* extend what anonymous commenters can publish (for example, text disguised
* by theme classes). Instead, wp_filter_comment() attaches it
* around the 'pre_comment_content' filter only while a `note` comment is
* being filtered. Notes can only be written by logged-in users who can edit
* the post, and are never rendered on the front end, so the sanitization of
* regular comments is unchanged.
*
* @since 7.1.0
* @access private
*
* @param array|string $allowed The allowed tags structure for the context.
* @param string $context The kses context.
* @return array|string Modified allowed tags structure.
*/
function _wp_kses_allow_note_mention_attributes( $allowed, $context ) {
if ( 'pre_comment_content' !== $context || ! is_array( $allowed ) ) {
return $allowed;
}

if ( ! isset( $allowed['a'] ) || ! is_array( $allowed['a'] ) ) {
$allowed['a'] = array();
}

$allowed['a']['class'] = true;

return $allowed;
}

/**
* You add any KSES hooks here.
*
Expand Down
139 changes: 139 additions & 0 deletions tests/phpunit/tests/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,145 @@ public function test_wp_kses_allowed_html() {
$this->assertSame( $allowedtags, wp_kses_allowed_html( 'data' ) );
}

/**
* Tests that the 'pre_comment_content' context is not loosened by the note mention support.
*
* The note mention attributes must only be allowed while a `note` comment is
* being filtered (see wp_filter_comment()), never in the comment content
* context itself, which also sanitizes regular (including anonymous) comments.
*
* @ticket 65622
*
* @covers ::wp_kses_allowed_html
*/
public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_attributes() {
global $allowedtags;

$this->assertSame( $allowedtags, wp_kses_allowed_html( 'pre_comment_content' ) );
}

/**
* Tests that a note mention survives content sanitization of a `note` comment.
*
* @ticket 65622
*
* @covers ::wp_filter_comment
* @covers ::_wp_kses_allow_note_mention_attributes
*/
public function test_note_mention_markup_survives_note_content_sanitization() {
add_filter( 'pre_comment_content', 'wp_filter_kses' );

/*
* The mention href is external to the test site so that wp_rel_ugc()
* - which applies to notes like any other comment - deterministically
* appends `rel="nofollow ugc"`.
*/
$content = 'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/">@admin</a>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

remove_filter( 'pre_comment_content', 'wp_filter_kses' );

$this->assertSame(
'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/" rel="nofollow ugc">@admin</a>!',
wp_unslash( $filtered['comment_content'] )
);
}

/**
* Tests that only the `class` attribute is allowed on note links beyond the defaults.
*
* @ticket 65622
*
* @covers ::wp_filter_comment
* @covers ::_wp_kses_allow_note_mention_attributes
*/
public function test_note_mention_allows_only_class_on_note_links() {
add_filter( 'pre_comment_content', 'wp_filter_kses' );

$content = 'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/" data-user-id="2" onclick="alert(1)" style="color:red">@admin</a>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

remove_filter( 'pre_comment_content', 'wp_filter_kses' );

$this->assertSame(
'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/" rel="nofollow ugc">@admin</a>!',
wp_unslash( $filtered['comment_content'] ),
'Attributes beyond `class` and the default link attributes should be stripped from note links.'
);
}

/**
* Tests that the note mention markup is stripped from regular comment content.
*
* @ticket 65622
*
* @covers ::wp_filter_comment
*/
public function test_note_mention_markup_stripped_from_regular_comment_content() {
add_filter( 'pre_comment_content', 'wp_filter_kses' );

$content = 'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/">@admin</a>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );

remove_filter( 'pre_comment_content', 'wp_filter_kses' );

$this->assertSame(
'Hello <a href="https://example.com/author/admin/" rel="nofollow ugc">@admin</a>!',
wp_unslash( $filtered['comment_content'] )
);
}

/**
* Tests that the note mention allowance does not leak beyond the note being filtered.
*
* @ticket 65622
*
* @covers ::wp_filter_comment
*/
public function test_note_mention_allowance_does_not_leak_after_note_filtering() {
global $allowedtags;

add_filter( 'pre_comment_content', 'wp_filter_kses' );

$content = 'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/">@admin</a>!';
wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

// A regular comment filtered after a note still gets the default rules.
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );

remove_filter( 'pre_comment_content', 'wp_filter_kses' );

$this->assertSame(
'Hello <a href="https://example.com/author/admin/" rel="nofollow ugc">@admin</a>!',
wp_unslash( $filtered['comment_content'] ),
'The mention classes should be stripped from a regular comment filtered after a note.'
);
$this->assertSame(
$allowedtags,
wp_kses_allowed_html( 'pre_comment_content' ),
'The comment content allowlist should be back to its default after a note is filtered.'
);
}

/**
* Builds a complete commentdata array for wp_filter_comment().
*
* @param string $comment_type The comment type.
* @param string $content The comment content.
* @return array Commentdata containing every field wp_filter_comment() reads.
*/
private function get_mention_commentdata( $comment_type, $content ) {
return array(
'comment_content' => $content,
'comment_type' => $comment_type,
'comment_author' => 'admin',
'comment_author_IP' => '127.0.0.1',
'comment_author_url' => 'http://example.org',
'comment_author_email' => 'admin@example.org',
'comment_agent' => '',
);
}

public function test_hyphenated_tag() {
$content = '<hyphenated-tag attribute="value" otherattribute="value2">Alot of hyphens.</hyphenated-tag>';
$custom_tags = array(
Expand Down
Loading