From 7cc12454f7880e6a2e7dba84f41405695c1fcde1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 09:16:05 -0700 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 05/21] 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 06/21] 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, From d237dd13f8ee7980fdd0b70c58731b9f8e3ec4c8 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Mon, 20 Jul 2026 08:31:30 -0700 Subject: [PATCH 07/21] Update src/wp-includes/kses.php Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com> --- src/wp-includes/kses.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 7ae7dd4848236..5b02195cf5e1f 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1157,7 +1157,7 @@ function wp_kses_allowed_html( $context = '' ) { * @param string $context The kses context. * @return array|string Modified allowed tags structure. */ -function _wp_kses_allow_note_mention_attributes( $allowed, $context ) { +function _wp_kses_allow_notes_attributes( $allowed, $context ) { if ( 'pre_comment_content' !== $context || ! is_array( $allowed ) ) { return $allowed; } From c091f34e63391ab7242fbae0889bc2488ab6f445 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Mon, 20 Jul 2026 17:18:38 -0700 Subject: [PATCH 08/21] Apply suggestions from code review Co-authored-by: Weston Ruter --- src/wp-includes/comment.php | 6 +++--- src/wp-includes/kses.php | 13 ++++++++----- tests/phpunit/tests/kses.php | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 33f8918d9fef4..483e31fde851a 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2224,12 +2224,12 @@ function wp_filter_comment( $commentdata ) { * 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(). + * comments. See _wp_kses_allow_notes_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 ); + add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_notes_attributes', 10, 2 ); } /** @@ -2242,7 +2242,7 @@ function wp_filter_comment( $commentdata ) { $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' ); + remove_filter( 'wp_kses_allowed_html', '_wp_kses_allow_notes_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 5b02195cf5e1f..b824d3657a4e1 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1153,12 +1153,15 @@ function wp_kses_allowed_html( $context = '' ) { * @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. + * @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_notes_attributes( $allowed, $context ) { - if ( 'pre_comment_content' !== $context || ! is_array( $allowed ) ) { +function _wp_kses_allow_notes_attributes( $allowed, $context ): array { + if ( ! is_array( $allowed ) ) { + $allowed = array(); + } + if ( 'pre_comment_content' !== $context ) { return $allowed; } diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index c115309a4a5e3..e1b8dc9224723 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -559,7 +559,7 @@ public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_ * @ticket 65622 * * @covers ::wp_filter_comment - * @covers ::_wp_kses_allow_note_mention_attributes + * @covers ::_wp_kses_allow_notes_attributes */ public function test_note_mention_markup_survives_note_content_sanitization() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); @@ -586,7 +586,7 @@ public function test_note_mention_markup_survives_note_content_sanitization() { * @ticket 65622 * * @covers ::wp_filter_comment - * @covers ::_wp_kses_allow_note_mention_attributes + * @covers ::_wp_kses_allow_notes_attributes */ public function test_note_mention_allows_only_class_on_note_links() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); From d68a800c0418d2f00fdf426c4787fd791dc0bcde Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 17:31:51 -0700 Subject: [PATCH 09/21] Improve typing of get_mention_commentdata helper method --- tests/phpunit/tests/kses.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index e1b8dc9224723..157c663df8eee 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -661,9 +661,9 @@ public function test_note_mention_allowance_does_not_leak_after_note_filtering() * * @param string $comment_type The comment type. * @param string $content The comment content. - * @return array Commentdata containing every field wp_filter_comment() reads. + * @return array Commentdata containing every field wp_filter_comment() reads. */ - private function get_mention_commentdata( $comment_type, $content ) { + private function get_mention_commentdata( $comment_type, $content ): array { return array( 'comment_content' => $content, 'comment_type' => $comment_type, From 0a9c8d097ade3699ddab1a4b31b1129ac2de89a8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 17:35:02 -0700 Subject: [PATCH 10/21] Remove unnecessary filter removal --- tests/phpunit/tests/kses.php | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 157c663df8eee..8178ec3620a64 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -589,13 +589,10 @@ public function test_note_mention_markup_survives_note_content_sanitization() { * @covers ::_wp_kses_allow_notes_attributes */ public function test_note_mention_allows_only_class_on_note_links() { - add_filter( 'pre_comment_content', 'wp_filter_kses' ); - $content = 'Hello @admin!'; + add_filter( 'pre_comment_content', 'wp_filter_kses' ); $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'] ), @@ -611,13 +608,10 @@ public function test_note_mention_allows_only_class_on_note_links() { * @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!'; + add_filter( 'pre_comment_content', 'wp_filter_kses' ); $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'] ) @@ -634,16 +628,13 @@ public function test_note_mention_markup_stripped_from_regular_comment_content() 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!'; + add_filter( 'pre_comment_content', 'wp_filter_kses' ); 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'] ), From d09d32e7ab5092dcecb1d3bce3bd707f2912284a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 17:42:42 -0700 Subject: [PATCH 11/21] Narrow type for comment_type param --- tests/phpunit/tests/kses.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 8178ec3620a64..4c908f5a49b93 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -650,8 +650,8 @@ public function test_note_mention_allowance_does_not_leak_after_note_filtering() /** * Builds a complete commentdata array for wp_filter_comment(). * - * @param string $comment_type The comment type. - * @param string $content The comment content. + * @param 'note'|'comment' $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 ): array { From a2d01f04c6d02ede8a67e75d59bc711813abee36 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 17:43:17 -0700 Subject: [PATCH 12/21] Fix phpcs --- tests/phpunit/tests/kses.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 4c908f5a49b93..7eb4389b91827 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -589,7 +589,7 @@ public function test_note_mention_markup_survives_note_content_sanitization() { * @covers ::_wp_kses_allow_notes_attributes */ public function test_note_mention_allows_only_class_on_note_links() { - $content = 'Hello @admin!'; + $content = 'Hello @admin!'; add_filter( 'pre_comment_content', 'wp_filter_kses' ); $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) ); @@ -608,7 +608,7 @@ public function test_note_mention_allows_only_class_on_note_links() { * @covers ::wp_filter_comment */ public function test_note_mention_markup_stripped_from_regular_comment_content() { - $content = 'Hello @admin!'; + $content = 'Hello @admin!'; add_filter( 'pre_comment_content', 'wp_filter_kses' ); $filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) ); From 69ec26b0159a0d64b2fd5eb11e8c5fc2f883c74b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 08:56:41 -0700 Subject: [PATCH 13/21] Comments: Switch the note mention kses allowance to the span chip markup Match the reworked Gutenberg approach (WordPress/gutenberg#80528): mentions are span.wp-note-mention.user-N chips rather than links, so the allowance becomes two always-on filters - span.class in the comment kses context plus a post-kses pass reducing span classes to the two mention tokens - and the per-note arming inside wp_filter_comment() is no longer needed. --- src/wp-includes/comment.php | 18 --- src/wp-includes/default-filters.php | 5 + src/wp-includes/kses.php | 85 +++++++++---- tests/phpunit/tests/kses.php | 177 ++++++++++++++++++++-------- 4 files changed, 199 insertions(+), 86 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 483e31fde851a..20303dc1e7e17 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2186,7 +2186,6 @@ 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. @@ -2219,19 +2218,6 @@ 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_notes_attributes(). - */ - $is_note = isset( $commentdata['comment_type'] ) && 'note' === $commentdata['comment_type']; - - if ( $is_note ) { - add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_notes_attributes', 10, 2 ); - } - /** * Filters the comment content before it is set. * @@ -2240,10 +2226,6 @@ 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_notes_attributes' ); - } /** * Filters the comment author's IP address before it is set. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 3702c17b418e8..d0dbb676e22d1 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -321,6 +321,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 b824d3657a4e1..4f6dda1d0b8a9 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1132,23 +1132,19 @@ 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. + * 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 @@ -1157,7 +1153,7 @@ function wp_kses_allowed_html( $context = '' ) { * @param string $context The kses context. * @return array> Modified allowed tags structure. */ -function _wp_kses_allow_notes_attributes( $allowed, $context ): array { +function _wp_kses_allow_note_mention_span( $allowed, $context ): array { if ( ! is_array( $allowed ) ) { $allowed = array(); } @@ -1165,15 +1161,62 @@ function _wp_kses_allow_notes_attributes( $allowed, $context ): array { 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['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 ) { + if ( ! is_string( $content ) || false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) { + return $content; + } + + $unslashed = wp_unslash( $content ); + + if ( ! str_contains( $unslashed, '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 7eb4389b91827..10894ba9596ee 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -537,20 +537,29 @@ public function test_wp_kses_allowed_html() { } /** - * 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. + * Tests that the comment content context allows only the mention span beyond the defaults. * * @ticket 65622 * - * @covers ::wp_kses_allowed_html + * @covers ::_wp_kses_allow_note_mention_span */ - public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_attributes() { + public function test_wp_kses_allowed_html_pre_comment_content_allows_only_the_mention_span() { global $allowedtags; - $this->assertSame( $allowedtags, wp_kses_allowed_html( 'pre_comment_content' ) ); + $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.' + ); } /** @@ -558,95 +567,169 @@ public function test_wp_kses_allowed_html_pre_comment_content_disallows_mention_ * * @ticket 65622 * - * @covers ::wp_filter_comment - * @covers ::_wp_kses_allow_notes_attributes + * @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' ); - /* - * 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!'; + $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'] ) - ); + $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); } /** - * Tests that only the `class` attribute is allowed on note links beyond the defaults. + * 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_filter_comment - * @covers ::_wp_kses_allow_notes_attributes + * @covers ::_wp_kses_allow_note_mention_span + * @covers ::_wp_kses_sanitize_note_mention_classes */ - public function test_note_mention_allows_only_class_on_note_links() { - $content = 'Hello @admin!'; + 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 ) ) ); + + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $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 ) ) ); + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + $this->assertSame( - 'Hello @admin!', + 'Hello @admin!', wp_unslash( $filtered['comment_content'] ), - 'Attributes beyond `class` and the default link attributes should be stripped from note links.' + 'Class tokens beyond `wp-note-mention` and `user-N` should be stripped from spans.' ); } /** - * Tests that the note mention markup is stripped from regular comment content. + * Tests that the class attribute is removed when no mention tokens remain. * * @ticket 65622 * - * @covers ::wp_filter_comment + * @covers ::_wp_kses_sanitize_note_mention_classes */ - public function test_note_mention_markup_stripped_from_regular_comment_content() { - $content = 'Hello @admin!'; + 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 ) ) ); + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + // The HTML API leaves the removed attribute's surrounding whitespace + // in place, hence ``. $this->assertSame( - 'Hello @admin!', - wp_unslash( $filtered['comment_content'] ) + 'Hello there!', + wp_unslash( $filtered['comment_content'] ), + 'A span with no valid mention tokens should lose its class attribute entirely.' ); } /** - * Tests that the note mention allowance does not leak beyond the note being filtered. + * Tests that only the `class` attribute is allowed on mention spans. * * @ticket 65622 * - * @covers ::wp_filter_comment + * @covers ::_wp_kses_allow_note_mention_span */ - public function test_note_mention_allowance_does_not_leak_after_note_filtering() { - global $allowedtags; - - $content = 'Hello @admin!'; + public function test_note_mention_allows_only_class_on_mention_spans() { add_filter( 'pre_comment_content', 'wp_filter_kses' ); - 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 ) ) ); + $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'] ), - 'The mention classes should be stripped from a regular comment filtered after a note.' + '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 ) ) ); + + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + $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.' + '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. + $had_filter = remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $content = 'Hello there!'; + + try { + $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.' + ); + } finally { + if ( $had_filter ) { + add_filter( 'pre_comment_content', 'wp_filter_kses' ); + } + } + } + /** * Builds a complete commentdata array for wp_filter_comment(). * From 5000cb45967994e74dd46c5bcccc63362a7e69bc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 09:07:32 -0700 Subject: [PATCH 14/21] Use assertEqualHTML for the class-attribute-removal assertion The HTML API's whitespace handling when removing the final attribute is not part of its contract, so asserting the exact '' spacing is brittle. --- tests/phpunit/tests/kses.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 10894ba9596ee..473308de58f98 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -641,11 +641,12 @@ public function test_note_mention_class_attribute_removed_when_no_tokens_remain( remove_filter( 'pre_comment_content', 'wp_filter_kses' ); - // The HTML API leaves the removed attribute's surrounding whitespace - // in place, hence ``. - $this->assertSame( - 'Hello there!', + // 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.' ); } From 1181a96532677cc1298e2330c3366a6fa710540a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 09:57:38 -0700 Subject: [PATCH 15/21] Port review refinements from the Gutenberg PR Match westonruter's review on WordPress/gutenberg#80528: type the kses filter and sanitizer signatures, and drop the try/finally filter restoration in the test since the framework restores filters after each test. --- src/wp-includes/kses.php | 9 ++++++--- tests/phpunit/tests/kses.php | 19 +++++++------------ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 4f6dda1d0b8a9..2f1a990c5e36b 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1153,7 +1153,7 @@ function wp_kses_allowed_html( $context = '' ) { * @param string $context The kses context. * @return array> Modified allowed tags structure. */ -function _wp_kses_allow_note_mention_span( $allowed, $context ): array { +function _wp_kses_allow_note_mention_span( $allowed, string $context ): array { if ( ! is_array( $allowed ) ) { $allowed = array(); } @@ -1192,8 +1192,11 @@ function _wp_kses_allow_note_mention_span( $allowed, $context ): array { * @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 ) { - if ( ! is_string( $content ) || false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) { +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; } diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 473308de58f98..f70098f331be8 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -714,21 +714,16 @@ public function test_class_is_still_stripped_from_links_in_comment_content() { 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. - $had_filter = remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + // The test framework restores filters after each test. + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); $content = 'Hello there!'; - try { - $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.' - ); - } finally { - if ( $had_filter ) { - add_filter( 'pre_comment_content', 'wp_filter_kses' ); - } - } + $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.' + ); } /** From 21d00fb159817203f674ab9aa09f1d0082785a6d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 14:53:43 -0700 Subject: [PATCH 16/21] Remove case-sensitive span substring bailout from mention class reduction An uppercase survives kses with its casing preserved, so the str_contains( ..., 'next_tag( 'SPAN' ) ) { foreach ( $processor->class_list() as $token ) { diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index f70098f331be8..85530a089f8a8 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -626,6 +626,32 @@ public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens ); } + /** + * 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 `get_mention_commentdata( 'note', $content ) ) ); + + remove_filter( 'pre_comment_content', 'wp_filter_kses' ); + + $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. * From 659c55648617266ee854c73a873de4720e142cf4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 21 Jul 2026 16:22:10 -0700 Subject: [PATCH 17/21] Fix type for context param --- src/wp-includes/kses.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index b51d7e81118dd..ce2d7d84ec859 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1149,11 +1149,11 @@ function wp_kses_allowed_html( $context = '' ) { * @since 7.1.0 * @access private * - * @param array> $allowed The allowed tags structure for the context. - * @param string $context The kses context. + * @param array> $allowed The allowed tags structure for the context. + * @param string|array> $context The kses context. * @return array> Modified allowed tags structure. */ -function _wp_kses_allow_note_mention_span( $allowed, string $context ): array { +function _wp_kses_allow_note_mention_span( $allowed, $context ): array { if ( ! is_array( $allowed ) ) { $allowed = array(); } From 9cd3cf009ee67176ebdebf242364f20554635093 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 21 Jul 2026 16:28:35 -0700 Subject: [PATCH 18/21] Remove unnecessary filter removals --- tests/phpunit/tests/kses.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 85530a089f8a8..39a18312afdf6 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -613,12 +613,9 @@ public function test_note_mention_markup_survives_regular_comment_content_saniti */ 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 ) ) ); - remove_filter( 'pre_comment_content', 'wp_filter_kses' ); - $this->assertSame( 'Hello @admin!', wp_unslash( $filtered['comment_content'] ), @@ -638,12 +635,9 @@ public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens */ public function test_note_mention_class_tokens_are_reduced_on_uppercase_span_tags() { 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->assertEqualHTML( 'Hello @admin!', wp_unslash( $filtered['comment_content'] ), @@ -661,12 +655,9 @@ public function test_note_mention_class_tokens_are_reduced_on_uppercase_span_tag */ 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 ) ) ); - remove_filter( 'pre_comment_content', 'wp_filter_kses' ); - // Markup-equivalence assertion: the HTML API's whitespace handling // when removing the final attribute is not part of its contract. $this->assertEqualHTML( @@ -686,12 +677,9 @@ public function test_note_mention_class_attribute_removed_when_no_tokens_remain( */ 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 ) ) ); - remove_filter( 'pre_comment_content', 'wp_filter_kses' ); - $this->assertSame( 'Hello @admin!', wp_unslash( $filtered['comment_content'] ), @@ -717,8 +705,6 @@ public function test_class_is_still_stripped_from_links_in_comment_content() { $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'] ), From 4a1a446778ba1a150732b3c6a5ced29e9d63f5f5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 21 Jul 2026 16:29:44 -0700 Subject: [PATCH 19/21] Add types to get_mention_commentdata helper --- tests/phpunit/tests/kses.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 39a18312afdf6..8e48632c50b4a 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -743,9 +743,12 @@ public function test_note_mention_class_reduction_skipped_when_restrictive_kses_ * * @param 'note'|'comment' $comment_type The comment type. * @param string $content The comment content. - * @return array Commentdata containing every field wp_filter_comment() reads. + * @return array{ + * comment_content: string, + * ... + * } */ - private function get_mention_commentdata( $comment_type, $content ): array { + private function get_mention_commentdata( string $comment_type, string $content ): array { return array( 'comment_content' => $content, 'comment_type' => $comment_type, From 32f362b14d5cd96bb69068b2721204c519065294 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 21 Jul 2026 18:59:52 -0700 Subject: [PATCH 20/21] Remove one more unnecessary filter removal --- tests/phpunit/tests/kses.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 8e48632c50b4a..1afd7e0884a64 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -595,12 +595,9 @@ public function test_note_mention_markup_survives_note_content_sanitization() { */ 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 ) ) ); - remove_filter( 'pre_comment_content', 'wp_filter_kses' ); - $this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) ); } From 1bef13b75832336f1cef5283bffc1e3e503cfe93 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 21 Jul 2026 20:20:11 -0700 Subject: [PATCH 21/21] Correct the context param Partially reverts 659c55648617266ee854c73a873de4720e142cf4 --- src/wp-includes/kses.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index ce2d7d84ec859..46cd2c4576c03 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1149,8 +1149,8 @@ function wp_kses_allowed_html( $context = '' ) { * @since 7.1.0 * @access private * - * @param array> $allowed The allowed tags structure for the context. - * @param string|array> $context The kses context. + * @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 {