Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7cc1245
Comments: Allow note mention attributes in comment content.
adamsilverstein Jul 13, 2026
8e48638
Add @ticket annotations for Trac #65622 to the new kses tests.
adamsilverstein Jul 13, 2026
9e7ecb2
Scope the note mention kses allowance to note comments.
adamsilverstein Jul 13, 2026
91f701d
Align the note mention kses allowance with the span markup.
adamsilverstein Jul 13, 2026
f74c68b
Comments: Carry the note mention user ID in a class instead of a data…
adamsilverstein Jul 13, 2026
e94266c
Align the note mention kses allowance with the link markup.
adamsilverstein Jul 13, 2026
d5b62af
Merge branch 'trunk' into add/notes-mention-kses
adamsilverstein Jul 14, 2026
e31873f
Merge remote-tracking branch 'origin/trunk' into add/notes-mention-kses
adamsilverstein Jul 15, 2026
d237dd1
Update src/wp-includes/kses.php
adamsilverstein Jul 20, 2026
c091f34
Apply suggestions from code review
adamsilverstein Jul 21, 2026
d5d7895
Merge branch 'trunk' into add/notes-mention-kses
adamsilverstein Jul 21, 2026
d68a800
Improve typing of get_mention_commentdata helper method
westonruter Jul 21, 2026
0a9c8d0
Remove unnecessary filter removal
westonruter Jul 21, 2026
d09d32e
Narrow type for comment_type param
westonruter Jul 21, 2026
a2d01f0
Fix phpcs
westonruter Jul 21, 2026
69ec26b
Comments: Switch the note mention kses allowance to the span chip markup
adamsilverstein Jul 21, 2026
5000cb4
Use assertEqualHTML for the class-attribute-removal assertion
adamsilverstein Jul 21, 2026
1181a96
Port review refinements from the Gutenberg PR
adamsilverstein Jul 21, 2026
21d00fb
Remove case-sensitive span substring bailout from mention class reduc…
adamsilverstein Jul 21, 2026
659c556
Fix type for context param
westonruter Jul 21, 2026
9cd3cf0
Remove unnecessary filter removals
westonruter Jul 21, 2026
4a1a446
Add types to get_mention_commentdata helper
westonruter Jul 21, 2026
0ca0786
Merge branch 'trunk' into add/notes-mention-kses
adamsilverstein Jul 22, 2026
f3130cc
Merge branch 'add/notes-mention-kses' of https://github.com/adamsilve…
westonruter Jul 22, 2026
32f362b
Remove one more unnecessary filter removal
westonruter Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down
83 changes: 83 additions & 0 deletions src/wp-includes/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* `<span class="wp-note-mention user-N">@Name</span>`. 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<string, array<string, bool>> $allowed The allowed tags structure for the context.
* @param string|array<string, array<string, bool>> $context The kses context.
Comment on lines +1152 to +1153
* @return array<string, array<string, bool>> 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.
*
Expand Down
221 changes: 221 additions & 0 deletions tests/phpunit/tests/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span class="wp-note-mention user-2">@admin</span>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotta love that this content is slashed!


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 <span class="wp-note-mention user-2">@admin</span>!';
$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 <span class="wp-note-mention user-2 is-destructive components-button">@admin</span>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

$this->assertSame(
'Hello <span class="wp-note-mention user-2">@admin</span>!',
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 `<span` substring check.
*
* @ticket 65622
*
* @covers ::_wp_kses_sanitize_note_mention_classes
*/
public function test_note_mention_class_tokens_are_reduced_on_uppercase_span_tags() {
add_filter( 'pre_comment_content', 'wp_filter_kses' );
$content = 'Hello <SPAN class="wp-note-mention user-2 is-destructive">@admin</SPAN>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

$this->assertEqualHTML(
'Hello <span class="wp-note-mention user-2">@admin</span>!',
wp_unslash( $filtered['comment_content'] ),
'<body>',
'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 <span class="is-destructive user-0 user-x wp-note-mention-foo">there</span>!';
$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 <span>there</span>!',
wp_unslash( $filtered['comment_content'] ),
'<body>',
'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 <span class="wp-note-mention user-2" data-user-id="2" onclick="alert(1)" style="color:red" id="mention">@admin</span>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

$this->assertSame(
'Hello <span class="wp-note-mention user-2">@admin</span>!',
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 <a class="wp-note-mention user-2" href="https://example.com/author/admin/">@admin</a>!';
$filtered = wp_filter_comment( wp_slash( $this->get_mention_commentdata( 'note', $content ) ) );

$this->assertSame(
'Hello <a href="https://example.com/author/admin/" rel="nofollow ugc">@admin</a>!',
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 <span class="components-button is-destructive">there</span>!';

$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 = '<hyphenated-tag attribute="value" otherattribute="value2">Alot of hyphens.</hyphenated-tag>';
$custom_tags = array(
Expand Down
Loading