From 7cc12454f7880e6a2e7dba84f41405695c1fcde1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 09:16:05 -0700 Subject: [PATCH 1/6] Comments: Allow note mention attributes in comment content. The notes @-mention completer stores a mention as `@Name`. The default comment kses allowlist only keeps `href` and `title` on links, so for users without `unfiltered_html` the attributes that make a mention a mention (the chip class and the mentioned user's ID) are stripped on save. Add a 'pre_comment_content' context to wp_kses_allowed_html() that allows `class` and `data-user-id` on links so saved mentions survive sanitization. Both attributes are inert markup. Backports the PHP changes from the Gutenberg mentions PR: https://github.com/WordPress/gutenberg/pull/79604 --- src/wp-includes/kses.php | 18 ++++++++++++++++++ tests/phpunit/tests/kses.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 6009fa07e35ea..e62c37762b579 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1049,6 +1049,8 @@ function wp_kses_one_attr( $attr, $element ) { * * @since 3.5.0 * @since 5.0.1 `form` removed as allowable HTML tag. + * @since 7.1.0 Added the 'pre_comment_content' context, which allows the note + * mention attributes `class` and `data-user-id` on `a` elements. * * @global array $allowedposttags * @global array $allowedtags @@ -1116,6 +1118,22 @@ function wp_kses_allowed_html( $context = '' ) { /** This filter is documented in wp-includes/kses.php */ return apply_filters( 'wp_kses_allowed_html', $tags, $context ); + case 'pre_comment_content': + /* + * The notes `@` mention completer stores a mention as + * `@Name`. + * Allow the attributes that make a mention a mention (the chip class + * and the mentioned user's ID) so that saved mentions survive + * sanitization for users without `unfiltered_html`; both attributes + * are inert markup (`data-*` carries data only and `class` has no + * behavior of its own). + */ + $tags = $allowedtags; + $tags['a']['class'] = true; + $tags['a']['data-user-id'] = true; + /** This filter is documented in wp-includes/kses.php */ + return apply_filters( 'wp_kses_allowed_html', $tags, $context ); + case 'strip': /** This filter is documented in wp-includes/kses.php */ return apply_filters( 'wp_kses_allowed_html', array(), $context ); diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 389ed466ab303..f5d98b6a65b41 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -536,6 +536,34 @@ public function test_wp_kses_allowed_html() { $this->assertSame( $allowedtags, wp_kses_allowed_html( 'data' ) ); } + /** + * Tests that the 'pre_comment_content' context allows the note mention attributes on links. + * + * @covers ::wp_kses_allowed_html + */ + public function test_wp_kses_allowed_html_pre_comment_content_allows_mention_attributes() { + $tags = wp_kses_allowed_html( 'pre_comment_content' ); + + $this->assertTrue( $tags['a']['class'], "The 'class' attribute should be allowed on links in comment content." ); + $this->assertTrue( $tags['a']['data-user-id'], "The 'data-user-id' attribute should be allowed on links in comment content." ); + + // The default context should remain unchanged. + $tags = wp_kses_allowed_html( 'data' ); + $this->assertArrayNotHasKey( 'class', $tags['a'], "The 'class' attribute should not be allowed on links in the default context." ); + $this->assertArrayNotHasKey( 'data-user-id', $tags['a'], "The 'data-user-id' attribute should not be allowed on links in the default context." ); + } + + /** + * Tests that a note mention link survives comment content sanitization. + * + * @covers ::wp_kses + */ + public function test_note_mention_markup_survives_comment_content_sanitization() { + $content = 'Hello @admin!'; + + $this->assertSame( $content, wp_kses( $content, 'pre_comment_content' ) ); + } + public function test_hyphenated_tag() { $content = 'Alot of hyphens.'; $custom_tags = array( From 8e48638c327da6764a4a5835c7e81b68a914047e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 09:30:38 -0700 Subject: [PATCH 2/6] Add @ticket annotations for Trac #65622 to the new kses tests. --- tests/phpunit/tests/kses.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index f5d98b6a65b41..0c78b47fc2ad4 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -539,6 +539,8 @@ public function test_wp_kses_allowed_html() { /** * Tests that the 'pre_comment_content' context allows the note mention attributes on links. * + * @ticket 65622 + * * @covers ::wp_kses_allowed_html */ public function test_wp_kses_allowed_html_pre_comment_content_allows_mention_attributes() { @@ -556,6 +558,8 @@ public function test_wp_kses_allowed_html_pre_comment_content_allows_mention_att /** * Tests that a note mention link survives comment content sanitization. * + * @ticket 65622 + * * @covers ::wp_kses */ public function test_note_mention_markup_survives_comment_content_sanitization() { From 9e7ecb2be02f93b299ac334832acef16510bbed0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 09:53:07 -0700 Subject: [PATCH 3/6] Scope the note mention kses allowance to note comments. Allowing class and data-user-id on links in the pre_comment_content context loosened sanitization for every comment, including anonymous front-end comments. Both attributes are CSS/JS selector hooks, so that would let any commenter publish links styled by theme classes or reachable by delegated script handlers. Attach the extended allowlist in wp_filter_comment() only while a note comment's content is being filtered instead. Notes can only be written by logged-in users who can edit the post and never render on the front end, and the sanitization of regular comments is unchanged. --- src/wp-includes/comment.php | 18 ++++++ src/wp-includes/kses.php | 58 +++++++++++++------ tests/phpunit/tests/kses.php | 106 ++++++++++++++++++++++++++++++----- 3 files changed, 151 insertions(+), 31 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 20303dc1e7e17..281f545769fb3 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 whose attributes 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 attributes are 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 e62c37762b579..61677c045a52e 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1049,8 +1049,6 @@ function wp_kses_one_attr( $attr, $element ) { * * @since 3.5.0 * @since 5.0.1 `form` removed as allowable HTML tag. - * @since 7.1.0 Added the 'pre_comment_content' context, which allows the note - * mention attributes `class` and `data-user-id` on `a` elements. * * @global array $allowedposttags * @global array $allowedtags @@ -1118,22 +1116,6 @@ function wp_kses_allowed_html( $context = '' ) { /** This filter is documented in wp-includes/kses.php */ return apply_filters( 'wp_kses_allowed_html', $tags, $context ); - case 'pre_comment_content': - /* - * The notes `@` mention completer stores a mention as - * `@Name`. - * Allow the attributes that make a mention a mention (the chip class - * and the mentioned user's ID) so that saved mentions survive - * sanitization for users without `unfiltered_html`; both attributes - * are inert markup (`data-*` carries data only and `class` has no - * behavior of its own). - */ - $tags = $allowedtags; - $tags['a']['class'] = true; - $tags['a']['data-user-id'] = true; - /** This filter is documented in wp-includes/kses.php */ - return apply_filters( 'wp_kses_allowed_html', $tags, $context ); - case 'strip': /** This filter is documented in wp-includes/kses.php */ return apply_filters( 'wp_kses_allowed_html', array(), $context ); @@ -1149,6 +1131,46 @@ function wp_kses_allowed_html( $context = '' ) { } } +/** + * Allows the note mention attributes on links in comment content. + * + * The notes `@` mention completer stores a mention as + * `@Name`. The default + * comment allowlist only keeps `href` and `title` on links, so for users + * without `unfiltered_html` the attributes that make a mention a mention (the + * chip class and the mentioned user's ID) would be stripped on save. + * + * This callback is deliberately not attached globally: `class` and `data-*` + * attributes are CSS and JavaScript selector hooks, so allowing them in every + * comment would extend what anonymous commenters can publish (for example, + * disguising a link as a themed button). 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; + $allowed['a']['data-user-id'] = true; + + return $allowed; +} + /** * You add any KSES hooks here. * diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 0c78b47fc2ad4..fa4aca0bac13f 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -537,35 +537,115 @@ public function test_wp_kses_allowed_html() { } /** - * Tests that the 'pre_comment_content' context allows the note mention attributes on links. + * 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_allows_mention_attributes() { - $tags = wp_kses_allowed_html( 'pre_comment_content' ); + 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 link 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' ); - $this->assertTrue( $tags['a']['class'], "The 'class' attribute should be allowed on links in comment content." ); - $this->assertTrue( $tags['a']['data-user-id'], "The 'data-user-id' attribute should be allowed on links in comment content." ); + $content = 'Hello @admin!'; + $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); - // The default context should remain unchanged. - $tags = wp_kses_allowed_html( 'data' ); - $this->assertArrayNotHasKey( 'class', $tags['a'], "The 'class' attribute should not be allowed on links in the default context." ); - $this->assertArrayNotHasKey( 'data-user-id', $tags['a'], "The 'data-user-id' attribute should not be allowed on links in the default context." ); + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + // `rel="ugc"` is added to all comment links by wp_rel_ugc(), unrelated to kses. + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ) + ); } /** - * Tests that a note mention link survives comment content sanitization. + * Tests that the note mention attributes are stripped from regular comment content. * * @ticket 65622 * - * @covers ::wp_kses + * @covers ::wp_filter_comment */ - public function test_note_mention_markup_survives_comment_content_sanitization() { + 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 ) ) ); - $this->assertSame( $content, wp_kses( $content, 'pre_comment_content' ) ); + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ), + 'The mention attributes 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() { From 91f701d792ddf281266e03ee06fe58b3366a8c45 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 10:04:01 -0700 Subject: [PATCH 4/6] Align the note mention kses allowance with the span markup. Design review on the Gutenberg side changed the stored mention from a link to a plain span, since a mention marks a person rather than offering navigation. Allow span.class and span.data-user-id instead of the link attributes; the allowance is still attached only while a note comment is filtered. --- src/wp-includes/comment.php | 8 ++++---- src/wp-includes/kses.php | 21 ++++++++++----------- tests/phpunit/tests/kses.php | 25 +++++++++---------------- 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 281f545769fb3..33f8918d9fef4 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2220,10 +2220,10 @@ function wp_filter_comment( $commentdata ) { /** 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 whose attributes 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 attributes are not writable from regular (including anonymous) + * 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']; diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 61677c045a52e..d08b2e8252c72 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1132,19 +1132,18 @@ function wp_kses_allowed_html( $context = '' ) { } /** - * Allows the note mention attributes on links in comment content. + * Allows the note mention markup in comment content. * * The notes `@` mention completer stores a mention as - * `@Name`. The default - * comment allowlist only keeps `href` and `title` on links, so for users - * without `unfiltered_html` the attributes that make a mention a mention (the - * chip class and the mentioned user's ID) would be stripped on save. + * `@Name`. The default + * comment allowlist does not include `span` at all, so for users without + * `unfiltered_html` the mention markup would be stripped on save. * * This callback is deliberately not attached globally: `class` and `data-*` * attributes are CSS and JavaScript selector hooks, so allowing them in every * comment would extend what anonymous commenters can publish (for example, - * disguising a link as a themed button). Instead, wp_filter_comment() attaches - * it around the 'pre_comment_content' filter only while a `note` comment is + * 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. @@ -1161,12 +1160,12 @@ function _wp_kses_allow_note_mention_attributes( $allowed, $context ) { return $allowed; } - if ( ! isset( $allowed['a'] ) || ! is_array( $allowed['a'] ) ) { - $allowed['a'] = array(); + if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) { + $allowed['span'] = array(); } - $allowed['a']['class'] = true; - $allowed['a']['data-user-id'] = true; + $allowed['span']['class'] = true; + $allowed['span']['data-user-id'] = true; return $allowed; } diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index fa4aca0bac13f..07018bcdfc1d5 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -554,7 +554,7 @@ public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_ } /** - * Tests that a note mention link survives content sanitization of a `note` comment. + * Tests that a note mention survives content sanitization of a `note` comment. * * @ticket 65622 * @@ -564,20 +564,16 @@ public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_ public function test_note_mention_markup_survives_note_content_sanitization() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $content = 'Hello @admin!'; $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); remove_filter( 'pre_comment_content', 'wp_filter_kses' ); - // `rel="ugc"` is added to all comment links by wp_rel_ugc(), unrelated to kses. - $this->assertSame( - 'Hello @admin!', - wp_unslash( $filtered['comment_content'] ) - ); + $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); } /** - * Tests that the note mention attributes are stripped from regular comment content. + * Tests that the note mention markup is stripped from regular comment content. * * @ticket 65622 * @@ -586,15 +582,12 @@ public function test_note_mention_markup_survives_note_content_sanitization() { public function test_note_mention_markup_stripped_from_regular_comment_content() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $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'] ) - ); + $this->assertSame( 'Hello @admin!', wp_unslash( $filtered['comment_content'] ) ); } /** @@ -609,7 +602,7 @@ public function test_note_mention_allowance_does_not_leak_after_note_filtering() add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $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. @@ -618,9 +611,9 @@ public function test_note_mention_allowance_does_not_leak_after_note_filtering() remove_filter( 'pre_comment_content', 'wp_filter_kses' ); $this->assertSame( - 'Hello @admin!', + 'Hello @admin!', wp_unslash( $filtered['comment_content'] ), - 'The mention attributes should be stripped from a regular comment filtered after a note.' + 'The mention markup should be stripped from a regular comment filtered after a note.' ); $this->assertSame( $allowedtags, From f74c68b644744de96a7b5a177f7fdfd3edfc3517 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 11:26:42 -0700 Subject: [PATCH 5/6] Comments: Carry the note mention user ID in a class instead of a data attribute The notes mention completer now stores a mention as `@Name`, so the kses allowance for note comments shrinks to the single `class` attribute and no longer needs `data-user-id`. Add a test asserting that any other attribute is still stripped from note spans. Trac ticket: https://core.trac.wordpress.org/ticket/65622 --- src/wp-includes/kses.php | 20 ++++++++++---------- tests/phpunit/tests/kses.php | 29 ++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index d08b2e8252c72..8aa6e671ccdde 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1135,14 +1135,15 @@ function wp_kses_allowed_html( $context = '' ) { * Allows the note mention markup in comment content. * * The notes `@` mention completer stores a mention as - * `@Name`. The default - * comment allowlist does not include `span` at all, so for users without - * `unfiltered_html` the mention markup would be stripped on save. - * - * This callback is deliberately not attached globally: `class` and `data-*` - * 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 + * `@Name`, the `user-N` class + * carrying the mentioned user's ID. The default comment allowlist does not + * include `span` at all, so for users without `unfiltered_html` the mention + * markup 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 @@ -1164,8 +1165,7 @@ function _wp_kses_allow_note_mention_attributes( $allowed, $context ) { $allowed['span'] = array(); } - $allowed['span']['class'] = true; - $allowed['span']['data-user-id'] = true; + $allowed['span']['class'] = true; return $allowed; } diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 07018bcdfc1d5..529208306c831 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -564,7 +564,7 @@ public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_ public function test_note_mention_markup_survives_note_content_sanitization() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $content = 'Hello @admin!'; $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); remove_filter( 'pre_comment_content', 'wp_filter_kses' ); @@ -572,6 +572,29 @@ public function test_note_mention_markup_survives_note_content_sanitization() { $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); } + /** + * Tests that only the `class` attribute is allowed on note spans. + * + * @ticket 65622 + * + * @covers ::wp_filter_comment + * @covers ::_wp_kses_allow_note_mention_attributes + */ + public function test_note_mention_allows_only_class_on_note_spans() { + 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` should be stripped from note spans.' + ); + } + /** * Tests that the note mention markup is stripped from regular comment content. * @@ -582,7 +605,7 @@ public function test_note_mention_markup_survives_note_content_sanitization() { public function test_note_mention_markup_stripped_from_regular_comment_content() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $content = 'Hello @admin!'; $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) ); remove_filter( 'pre_comment_content', 'wp_filter_kses' ); @@ -602,7 +625,7 @@ public function test_note_mention_allowance_does_not_leak_after_note_filtering() add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $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. From e94266ce64f647756a20f10960905ab82a5e6b7a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 13:51:51 -0700 Subject: [PATCH 6/6] Align the note mention kses allowance with the link markup. Mentions are now inserted as links to the mentioned user's author page - - instead of spans, so the note allowance moves from span.class to a.class. Note content now also picks up wp_rel_ugc()'s rel="nofollow ugc" like any other comment link, which the tests cover with a deterministic external href. --- src/wp-includes/kses.php | 17 +++++++++-------- tests/phpunit/tests/kses.php | 35 +++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 8aa6e671ccdde..96a770be18105 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1134,11 +1134,12 @@ function wp_kses_allowed_html( $context = '' ) { /** * Allows the note mention markup in comment content. * - * The notes `@` mention completer stores a mention as - * `@Name`, the `user-N` class - * carrying the mentioned user's ID. The default comment allowlist does not - * include `span` at all, so for users without `unfiltered_html` the mention - * markup would be stripped on save. + * 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 @@ -1161,11 +1162,11 @@ function _wp_kses_allow_note_mention_attributes( $allowed, $context ) { return $allowed; } - if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) { - $allowed['span'] = array(); + if ( ! isset( $allowed['a'] ) || ! is_array( $allowed['a'] ) ) { + $allowed['a'] = array(); } - $allowed['span']['class'] = true; + $allowed['a']['class'] = true; return $allowed; } diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 529208306c831..9fe667752350b 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -564,34 +564,42 @@ public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_ public function test_note_mention_markup_survives_note_content_sanitization() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + /* + * 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( $content, wp_unslash( $filtered['comment_content'] ) ); + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ) + ); } /** - * Tests that only the `class` attribute is allowed on note spans. + * 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_spans() { + public function test_note_mention_allows_only_class_on_note_links() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $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!', + 'Hello @admin!', wp_unslash( $filtered['comment_content'] ), - 'Attributes beyond `class` should be stripped from note spans.' + 'Attributes beyond `class` and the default link attributes should be stripped from note links.' ); } @@ -605,12 +613,15 @@ public function test_note_mention_allows_only_class_on_note_spans() { public function test_note_mention_markup_stripped_from_regular_comment_content() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $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'] ) ); + $this->assertSame( + 'Hello @admin!', + wp_unslash( $filtered['comment_content'] ) + ); } /** @@ -625,7 +636,7 @@ public function test_note_mention_allowance_does_not_leak_after_note_filtering() add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + $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. @@ -634,9 +645,9 @@ public function test_note_mention_allowance_does_not_leak_after_note_filtering() remove_filter( 'pre_comment_content', 'wp_filter_kses' ); $this->assertSame( - 'Hello @admin!', + 'Hello @admin!', wp_unslash( $filtered['comment_content'] ), - 'The mention markup should be stripped from a regular comment filtered after a note.' + 'The mention classes should be stripped from a regular comment filtered after a note.' ); $this->assertSame( $allowedtags,