Skip to content

Commit 2459eed

Browse files
Comments: allow the Notes @mention chip markup in comment content.
The Notes @mention completer stores a mention as a non-interactive chip, `<span class="wp-note-mention user-N">@name</span>`, the `user-N` class token carrying the mentioned user's ID. Fix an issue where the default comment kses allowlist does not permit `span`, so for users without the `unfiltered_html` capability the mention markup is stripped when the note is saved. See related Gutenberg pull requests: WordPress/gutenberg#79604 and WordPress/gutenberg#80528. Props mamaduka, westonruter, t-hamano, luisdavid01, vedantere. Fixes #65622. git-svn-id: https://develop.svn.wordpress.org/trunk@62832 602fd350-edb4-49c9-b593-d223f7449a82
1 parent fc047ab commit 2459eed

3 files changed

Lines changed: 309 additions & 0 deletions

File tree

src/wp-includes/default-filters.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,11 @@
310310
add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
311311
add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 );
312312
add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 );
313+
314+
// Note mention chips in comment content: allow `span` through comment kses,
315+
// then reduce its classes to the mention tokens right after `wp_filter_kses`.
316+
add_filter( 'wp_kses_allowed_html', '_wp_kses_allow_note_mention_span', 10, 2 );
317+
add_filter( 'pre_comment_content', '_wp_kses_sanitize_note_mention_classes', 11 );
313318
add_filter( 'comment_email', 'antispambot' );
314319
add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' );
315320
add_filter( 'option_category_base', '_wp_filter_taxonomy_base' );

src/wp-includes/kses.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,89 @@ function wp_kses_allowed_html( $context = '' ) {
11311131
}
11321132
}
11331133

1134+
/**
1135+
* Allows the note mention chip markup in comment content.
1136+
*
1137+
* The notes `@` mention completer stores a mention as a chip carrying the
1138+
* mentioned user's ID in a class token:
1139+
* `<span class="wp-note-mention user-N">@Name</span>`. The default comment
1140+
* allowlist does not allow `span` at all, so for users without
1141+
* `unfiltered_html` the mention would be stripped on save.
1142+
*
1143+
* The allowance is deliberately narrow and always on: `span` is a
1144+
* semantics-free element and _wp_kses_sanitize_note_mention_classes()
1145+
* reduces its `class` to the two mention tokens right after kses runs, so
1146+
* regular (including anonymous) commenters gain nothing beyond the inert
1147+
* mention markup itself.
1148+
*
1149+
* @since 7.1.0
1150+
* @access private
1151+
*
1152+
* @param array<string, array<string, bool>> $allowed The allowed tags structure for the context.
1153+
* @param string $context The kses context.
1154+
* @return array<string, array<string, bool>> Modified allowed tags structure.
1155+
*/
1156+
function _wp_kses_allow_note_mention_span( $allowed, $context ): array {
1157+
if ( ! is_array( $allowed ) ) {
1158+
$allowed = array();
1159+
}
1160+
if ( 'pre_comment_content' !== $context ) {
1161+
return $allowed;
1162+
}
1163+
1164+
if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) {
1165+
$allowed['span'] = array();
1166+
}
1167+
1168+
$allowed['span']['class'] = true;
1169+
1170+
return $allowed;
1171+
}
1172+
1173+
/**
1174+
* Reduces `span` classes in comment content to the note mention tokens.
1175+
*
1176+
* _wp_kses_allow_note_mention_span() lets `class` through kses on `span` so
1177+
* the mention chip survives, but `class` is an open-ended styling and
1178+
* scripting hook, so this companion pass - running right after
1179+
* `wp_filter_kses` at priority 10 - strips every class token except the two
1180+
* the mention markup uses: `wp-note-mention` and `user-N`. `span` is the only
1181+
* comment tag allowed to carry `class` at all, so walking `span` tags covers
1182+
* the entire allowance.
1183+
*
1184+
* The pass only applies while the restrictive comment allowlist is active:
1185+
* users with `unfiltered_html` are filtered through `wp_filter_post_kses`
1186+
* (or not at all), where arbitrary classes are already permitted, and
1187+
* narrowing their markup here would restrict what core allows them to post.
1188+
*
1189+
* @since 7.1.0
1190+
* @access private
1191+
*
1192+
* @param string $content Slashed comment content, already filtered by kses.
1193+
* @return string Slashed comment content with span classes reduced.
1194+
*/
1195+
function _wp_kses_sanitize_note_mention_classes( $content ): string {
1196+
if ( ! is_string( $content ) ) {
1197+
$content = '';
1198+
}
1199+
if ( false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) {
1200+
return $content;
1201+
}
1202+
1203+
$processor = new WP_HTML_Tag_Processor( wp_unslash( $content ) );
1204+
1205+
while ( $processor->next_tag( 'SPAN' ) ) {
1206+
foreach ( $processor->class_list() as $token ) {
1207+
if ( 'wp-note-mention' !== $token && ! preg_match( '/^user-[1-9][0-9]*$/', $token ) ) {
1208+
// Removing the last class also removes the attribute itself.
1209+
$processor->remove_class( $token );
1210+
}
1211+
}
1212+
}
1213+
1214+
return wp_slash( $processor->get_updated_html() );
1215+
}
1216+
11341217
/**
11351218
* You add any KSES hooks here.
11361219
*

tests/phpunit/tests/kses.php

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,227 @@ public function test_wp_kses_allowed_html() {
536536
$this->assertSame( $allowedtags, wp_kses_allowed_html( 'data' ) );
537537
}
538538

539+
/**
540+
* Tests that the comment content context allows only the mention span beyond the defaults.
541+
*
542+
* @ticket 65622
543+
*
544+
* @covers ::_wp_kses_allow_note_mention_span
545+
*/
546+
public function test_wp_kses_allowed_html_pre_comment_content_allows_only_the_mention_span() {
547+
global $allowedtags;
548+
549+
$allowed = wp_kses_allowed_html( 'pre_comment_content' );
550+
551+
$this->assertSame(
552+
array( 'class' => true ),
553+
$allowed['span'],
554+
'The mention span should be allowed in comment content.'
555+
);
556+
557+
unset( $allowed['span'] );
558+
$this->assertSame(
559+
$allowedtags,
560+
$allowed,
561+
'Nothing beyond the mention span should be allowed on top of the default comment tags.'
562+
);
563+
}
564+
565+
/**
566+
* Tests that a note mention survives content sanitization of a `note` comment.
567+
*
568+
* @ticket 65622
569+
*
570+
* @covers ::_wp_kses_allow_note_mention_span
571+
* @covers ::_wp_kses_sanitize_note_mention_classes
572+
*/
573+
public function test_note_mention_markup_survives_note_content_sanitization() {
574+
add_filter( 'pre_comment_content', 'wp_filter_kses' );
575+
576+
$content = 'Hello <span class="wp-note-mention user-2">@admin</span>!';
577+
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
578+
579+
remove_filter( 'pre_comment_content', 'wp_filter_kses' );
580+
581+
$this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) );
582+
}
583+
584+
/**
585+
* Tests that the mention markup also survives in regular comment content.
586+
*
587+
* The allowance is always on rather than scoped per comment type: the
588+
* mention markup is inert, so uniform sanitization avoids stateful
589+
* arming and disarming of kses filters around each note write.
590+
*
591+
* @ticket 65622
592+
*
593+
* @covers ::_wp_kses_allow_note_mention_span
594+
* @covers ::_wp_kses_sanitize_note_mention_classes
595+
*/
596+
public function test_note_mention_markup_survives_regular_comment_content_sanitization() {
597+
add_filter( 'pre_comment_content', 'wp_filter_kses' );
598+
$content = 'Hello <span class="wp-note-mention user-2">@admin</span>!';
599+
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );
600+
601+
$this->assertSame( $content, wp_unslash( $filtered['comment_content'] ) );
602+
}
603+
604+
/**
605+
* Tests that span classes are reduced to the two mention tokens.
606+
*
607+
* @ticket 65622
608+
*
609+
* @covers ::_wp_kses_sanitize_note_mention_classes
610+
*/
611+
public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens() {
612+
add_filter( 'pre_comment_content', 'wp_filter_kses' );
613+
$content = 'Hello <span class="wp-note-mention user-2 is-destructive components-button">@admin</span>!';
614+
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
615+
616+
$this->assertSame(
617+
'Hello <span class="wp-note-mention user-2">@admin</span>!',
618+
wp_unslash( $filtered['comment_content'] ),
619+
'Class tokens beyond `wp-note-mention` and `user-N` should be stripped from spans.'
620+
);
621+
}
622+
623+
/**
624+
* Tests that class tokens are reduced on spans regardless of tag-name casing.
625+
*
626+
* kses preserves tag-name casing, so the class reduction must match `SPAN`
627+
* case-insensitively rather than bail on a `<span` substring check.
628+
*
629+
* @ticket 65622
630+
*
631+
* @covers ::_wp_kses_sanitize_note_mention_classes
632+
*/
633+
public function test_note_mention_class_tokens_are_reduced_on_uppercase_span_tags() {
634+
add_filter( 'pre_comment_content', 'wp_filter_kses' );
635+
$content = 'Hello <SPAN class="wp-note-mention user-2 is-destructive">@admin</SPAN>!';
636+
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
637+
638+
$this->assertEqualHTML(
639+
'Hello <span class="wp-note-mention user-2">@admin</span>!',
640+
wp_unslash( $filtered['comment_content'] ),
641+
'<body>',
642+
'Class tokens should be reduced on spans regardless of tag-name casing.'
643+
);
644+
}
645+
646+
/**
647+
* Tests that the class attribute is removed when no mention tokens remain.
648+
*
649+
* @ticket 65622
650+
*
651+
* @covers ::_wp_kses_sanitize_note_mention_classes
652+
*/
653+
public function test_note_mention_class_attribute_removed_when_no_tokens_remain() {
654+
add_filter( 'pre_comment_content', 'wp_filter_kses' );
655+
$content = 'Hello <span class="is-destructive user-0 user-x wp-note-mention-foo">there</span>!';
656+
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'comment', $content ) ) );
657+
658+
// Markup-equivalence assertion: the HTML API's whitespace handling
659+
// when removing the final attribute is not part of its contract.
660+
$this->assertEqualHTML(
661+
'Hello <span>there</span>!',
662+
wp_unslash( $filtered['comment_content'] ),
663+
'<body>',
664+
'A span with no valid mention tokens should lose its class attribute entirely.'
665+
);
666+
}
667+
668+
/**
669+
* Tests that only the `class` attribute is allowed on mention spans.
670+
*
671+
* @ticket 65622
672+
*
673+
* @covers ::_wp_kses_allow_note_mention_span
674+
*/
675+
public function test_note_mention_allows_only_class_on_mention_spans() {
676+
add_filter( 'pre_comment_content', 'wp_filter_kses' );
677+
$content = 'Hello <span class="wp-note-mention user-2" data-user-id="2" onclick="alert(1)" style="color:red" id="mention">@admin</span>!';
678+
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
679+
680+
$this->assertSame(
681+
'Hello <span class="wp-note-mention user-2">@admin</span>!',
682+
wp_unslash( $filtered['comment_content'] ),
683+
'Attributes beyond `class` should be stripped from spans.'
684+
);
685+
}
686+
687+
/**
688+
* Tests that `class` is still stripped from links in comment content.
689+
*
690+
* @ticket 65622
691+
*
692+
* @covers ::_wp_kses_allow_note_mention_span
693+
*/
694+
public function test_class_is_still_stripped_from_links_in_comment_content() {
695+
add_filter( 'pre_comment_content', 'wp_filter_kses' );
696+
697+
/*
698+
* The href is external to the test site so that wp_rel_ugc() - which
699+
* applies to notes like any other comment - deterministically appends
700+
* `rel="nofollow ugc"`.
701+
*/
702+
$content = 'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/">@admin</a>!';
703+
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );
704+
705+
$this->assertSame(
706+
'Hello <a href="https://example.com/author/admin/" rel="nofollow ugc">@admin</a>!',
707+
wp_unslash( $filtered['comment_content'] ),
708+
'The class allowance is scoped to spans; links keep the default sanitization.'
709+
);
710+
}
711+
712+
/**
713+
* Tests that the class reduction is skipped while the restrictive comment kses is inactive.
714+
*
715+
* Users with `unfiltered_html` are filtered through `wp_filter_post_kses`
716+
* (or not at all), where arbitrary classes are permitted; the mention
717+
* class reduction must not narrow what they can post.
718+
*
719+
* @ticket 65622
720+
*
721+
* @covers ::_wp_kses_sanitize_note_mention_classes
722+
*/
723+
public function test_note_mention_class_reduction_skipped_when_restrictive_kses_is_inactive() {
724+
// kses_init() hooks wp_filter_kses by default in the test
725+
// environment, so detach it to simulate the unfiltered_html setup.
726+
// The test framework restores filters after each test.
727+
remove_filter( 'pre_comment_content', 'wp_filter_kses' );
728+
729+
$content = 'Hello <span class="components-button is-destructive">there</span>!';
730+
731+
$this->assertSame(
732+
wp_slash( $content ),
733+
_wp_kses_sanitize_note_mention_classes( wp_slash( $content ) ),
734+
'Span classes should be left untouched when wp_filter_kses is not active.'
735+
);
736+
}
737+
738+
/**
739+
* Builds a complete commentdata array for wp_filter_comment().
740+
*
741+
* @param 'note'|'comment' $comment_type The comment type.
742+
* @param string $content The comment content.
743+
* @return array{
744+
* comment_content: string,
745+
* ...
746+
* }
747+
*/
748+
private function get_mention_commentdata( string $comment_type, string $content ): array {
749+
return array(
750+
'comment_content' => $content,
751+
'comment_type' => $comment_type,
752+
'comment_author' => 'admin',
753+
'comment_author_IP' => '127.0.0.1',
754+
'comment_author_url' => 'http://example.org',
755+
'comment_author_email' => 'admin@example.org',
756+
'comment_agent' => '',
757+
);
758+
}
759+
539760
public function test_hyphenated_tag() {
540761
$content = '<hyphenated-tag attribute="value" otherattribute="value2">Alot of hyphens.</hyphenated-tag>';
541762
$custom_tags = array(

0 commit comments

Comments
 (0)