diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index d9a05c829646a..b2790c7d43ec9 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -310,6 +310,11 @@
add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 );
add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 );
+
+// Note mention chips in comment content: allow `span` through comment kses,
+// then reduce its classes to the mention tokens right after `wp_filter_kses`.
+add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_note_mention_span', 10, 2 );
+add_filter( 'pre_comment_content', '_wp_kses_sanitize_note_mention_classes', 11 );
add_filter( 'comment_email', 'antispambot' );
add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' );
add_filter( 'option_category_base', '_wp_filter_taxonomy_base' );
diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index 37d457a3e18a2..46cd2c4576c03 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -1131,6 +1131,89 @@ function wp_kses_allowed_html( $context = '' ) {
}
}
+/**
+ * Allows the note mention chip markup in comment content.
+ *
+ * The notes `@` mention completer stores a mention as a chip carrying the
+ * mentioned user's ID in a class token:
+ * `@Name`. The default comment
+ * allowlist does not allow `span` at all, so for users without
+ * `unfiltered_html` the mention would be stripped on save.
+ *
+ * The allowance is deliberately narrow and always on: `span` is a
+ * semantics-free element and _wp_kses_sanitize_note_mention_classes()
+ * reduces its `class` to the two mention tokens right after kses runs, so
+ * regular (including anonymous) commenters gain nothing beyond the inert
+ * mention markup itself.
+ *
+ * @since 7.1.0
+ * @access private
+ *
+ * @param array> $allowed The allowed tags structure for the context.
+ * @param string $context The kses context.
+ * @return array> Modified allowed tags structure.
+ */
+function _wp_kses_allow_note_mention_span( $allowed, $context ): array {
+ if ( ! is_array( $allowed ) ) {
+ $allowed = array();
+ }
+ if ( 'pre_comment_content' !== $context ) {
+ return $allowed;
+ }
+
+ if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) {
+ $allowed['span'] = array();
+ }
+
+ $allowed['span']['class'] = true;
+
+ return $allowed;
+}
+
+/**
+ * Reduces `span` classes in comment content to the note mention tokens.
+ *
+ * _wp_kses_allow_note_mention_span() lets `class` through kses on `span` so
+ * the mention chip survives, but `class` is an open-ended styling and
+ * scripting hook, so this companion pass - running right after
+ * `wp_filter_kses` at priority 10 - strips every class token except the two
+ * the mention markup uses: `wp-note-mention` and `user-N`. `span` is the only
+ * comment tag allowed to carry `class` at all, so walking `span` tags covers
+ * the entire allowance.
+ *
+ * The pass only applies while the restrictive comment allowlist is active:
+ * users with `unfiltered_html` are filtered through `wp_filter_post_kses`
+ * (or not at all), where arbitrary classes are already permitted, and
+ * narrowing their markup here would restrict what core allows them to post.
+ *
+ * @since 7.1.0
+ * @access private
+ *
+ * @param string $content Slashed comment content, already filtered by kses.
+ * @return string Slashed comment content with span classes reduced.
+ */
+function _wp_kses_sanitize_note_mention_classes( $content ): string {
+ if ( ! is_string( $content ) ) {
+ $content = '';
+ }
+ if ( false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) {
+ return $content;
+ }
+
+ $processor = new WP_HTML_Tag_Processor( wp_unslash( $content ) );
+
+ while ( $processor->next_tag( 'SPAN' ) ) {
+ foreach ( $processor->class_list() as $token ) {
+ if ( 'wp-note-mention' !== $token && ! preg_match( '/^user-[1-9][0-9]*$/', $token ) ) {
+ // Removing the last class also removes the attribute itself.
+ $processor->remove_class( $token );
+ }
+ }
+ }
+
+ return wp_slash( $processor->get_updated_html() );
+}
+
/**
* You add any KSES hooks here.
*
diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php
index 59353a2b7a20c..1afd7e0884a64 100644
--- a/tests/phpunit/tests/kses.php
+++ b/tests/phpunit/tests/kses.php
@@ -536,6 +536,227 @@ public function test_wp_kses_allowed_html() {
$this->assertSame( $allowedtags, wp_kses_allowed_html( 'data' ) );
}
+ /**
+ * Tests that the comment content context allows only the mention span beyond the defaults.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ */
+ public function test_wp_kses_allowed_html_pre_comment_content_allows_only_the_mention_span() {
+ global $allowedtags;
+
+ $allowed = wp_kses_allowed_html( 'pre_comment_content' );
+
+ $this->assertSame(
+ array( 'class' => true ),
+ $allowed['span'],
+ 'The mention span should be allowed in comment content.'
+ );
+
+ unset( $allowed['span'] );
+ $this->assertSame(
+ $allowedtags,
+ $allowed,
+ 'Nothing beyond the mention span should be allowed on top of the default comment tags.'
+ );
+ }
+
+ /**
+ * Tests that a note mention survives content sanitization of a `note` comment.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_markup_survives_note_content_sanitization() {
+ 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( $content, wp_unslash( $filtered['comment_content'] ) );
+ }
+
+ /**
+ * Tests that the mention markup also survives in regular comment content.
+ *
+ * The allowance is always on rather than scoped per comment type: the
+ * mention markup is inert, so uniform sanitization avoids stateful
+ * arming and disarming of kses filters around each note write.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_markup_survives_regular_comment_content_sanitization() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello @admin!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );
+
+ $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) );
+ }
+
+ /**
+ * Tests that span classes are reduced to the two mention tokens.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello @admin!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ $this->assertSame(
+ 'Hello @admin!',
+ wp_unslash( $filtered['comment_content'] ),
+ 'Class tokens beyond `wp-note-mention` and `user-N` should be stripped from spans.'
+ );
+ }
+
+ /**
+ * Tests that class tokens are reduced on spans regardless of tag-name casing.
+ *
+ * kses preserves tag-name casing, so the class reduction must match `SPAN`
+ * case-insensitively rather than bail on a `@admin!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ $this->assertEqualHTML(
+ 'Hello @admin!',
+ wp_unslash( $filtered['comment_content'] ),
+ '',
+ 'Class tokens should be reduced on spans regardless of tag-name casing.'
+ );
+ }
+
+ /**
+ * Tests that the class attribute is removed when no mention tokens remain.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_class_attribute_removed_when_no_tokens_remain() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello there!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );
+
+ // Markup-equivalence assertion: the HTML API's whitespace handling
+ // when removing the final attribute is not part of its contract.
+ $this->assertEqualHTML(
+ 'Hello there!',
+ wp_unslash( $filtered['comment_content'] ),
+ '',
+ 'A span with no valid mention tokens should lose its class attribute entirely.'
+ );
+ }
+
+ /**
+ * Tests that only the `class` attribute is allowed on mention spans.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ */
+ public function test_note_mention_allows_only_class_on_mention_spans() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+ $content = 'Hello @admin!';
+ $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
+
+ $this->assertSame(
+ 'Hello @admin!',
+ wp_unslash( $filtered['comment_content'] ),
+ 'Attributes beyond `class` should be stripped from spans.'
+ );
+ }
+
+ /**
+ * Tests that `class` is still stripped from links in comment content.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_allow_note_mention_span
+ */
+ public function test_class_is_still_stripped_from_links_in_comment_content() {
+ add_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ /*
+ * The 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 ) ) );
+
+ $this->assertSame(
+ 'Hello @admin!',
+ wp_unslash( $filtered['comment_content'] ),
+ 'The class allowance is scoped to spans; links keep the default sanitization.'
+ );
+ }
+
+ /**
+ * Tests that the class reduction is skipped while the restrictive comment kses is inactive.
+ *
+ * Users with `unfiltered_html` are filtered through `wp_filter_post_kses`
+ * (or not at all), where arbitrary classes are permitted; the mention
+ * class reduction must not narrow what they can post.
+ *
+ * @ticket 65622
+ *
+ * @covers ::_wp_kses_sanitize_note_mention_classes
+ */
+ public function test_note_mention_class_reduction_skipped_when_restrictive_kses_is_inactive() {
+ // kses_init() hooks wp_filter_kses by default in the test
+ // environment, so detach it to simulate the unfiltered_html setup.
+ // The test framework restores filters after each test.
+ remove_filter( 'pre_comment_content', 'wp_filter_kses' );
+
+ $content = 'Hello there!';
+
+ $this->assertSame(
+ wp_slash( $content ),
+ _wp_kses_sanitize_note_mention_classes( wp_slash( $content ) ),
+ 'Span classes should be left untouched when wp_filter_kses is not active.'
+ );
+ }
+
+ /**
+ * Builds a complete commentdata array for wp_filter_comment().
+ *
+ * @param 'note'|'comment' $comment_type The comment type.
+ * @param string $content The comment content.
+ * @return array{
+ * comment_content: string,
+ * ...
+ * }
+ */
+ private function get_mention_commentdata( string $comment_type, string $content ): array {
+ 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(