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