diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php
index 20303dc1e7e17..33f8918d9fef4 100644
--- a/src/wp-includes/comment.php
+++ b/src/wp-includes/comment.php
@@ -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.
@@ -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.
*
@@ -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.
*
diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index 37d457a3e18a2..7ae7dd4848236 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -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: `@Name`. 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.
*
diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php
index 59353a2b7a20c..c115309a4a5e3 100644
--- a/tests/phpunit/tests/kses.php
+++ b/tests/phpunit/tests/kses.php
@@ -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 @admin!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ remove_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ $this->assertSame(
+ 'Hello @admin!',
+ 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 @admin!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ remove_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ $this->assertSame(
+ 'Hello @admin!',
+ 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 @admin!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );
+
+ remove_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ $this->assertSame(
+ 'Hello @admin!',
+ 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 @admin!';
+ 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 @admin!',
+ 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 = 'Alot of hyphens.';
$custom_tags = array(