Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
139 changes: 139 additions & 0 deletions src/wp-includes/comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -4193,3 +4193,142 @@ function wp_create_initial_comment_meta() {
)
);
}

/**
* Returns the allowlist of HTML tags and attributes permitted in note content.
*
* Kept intentionally small: bold, italic, links, and code. Link `rel` attributes
* are normalized by {@see _wp_note_content_pre_filter()}, so the allowlist does
* not need to enumerate every valid rel value.
*
* @since 7.1.0
*
* @return array Allowed tags structure compatible with wp_kses().
*/
function wp_get_note_allowed_html() {
$allowed = array(
'strong' => array(),
'em' => array(),
'a' => array(
'href' => true,
'target' => true,
'rel' => true,
'title' => true,
),
'code' => array(),
);

/**
* Filters the HTML tags and attributes allowed in note (block comment) content.
*
* @since 7.1.0
*
* @param array $allowed Array of allowed tags in the format expected by wp_kses().
*/
return apply_filters( 'wp_note_allowed_html', $allowed );
}

/**
* Sanitizes note content with the note-specific kses allowlist.
*
* Installed on `pre_comment_content` while a note is being saved via the REST
* API. Forces `rel="noopener nofollow"` on outbound links so a hostile client
* cannot use saved notes as a vector for SEO manipulation or window.opener-based
* attacks. Link rels are normalized via the HTML API.
*
* @since 7.1.0
* @access private
*
* @param string $content Slashed comment content.
* @return string Sanitized, re-slashed content.
*/
function _wp_note_content_pre_filter( $content ) {
$unslashed = wp_unslash( $content );
$filtered = wp_kses( $unslashed, wp_get_note_allowed_html() );

$processor = new WP_HTML_Tag_Processor( $filtered );
while ( $processor->next_tag( 'A' ) ) {
$processor->set_attribute( 'rel', 'noopener nofollow' );
}
$filtered = $processor->get_updated_html();

return addslashes( $filtered );
}

/**
* Installs the note-specific kses filter when a REST request targets a note.
*
* Triggers on POST/PUT/PATCH requests to /wp/v2/comments where either the
* incoming body specifies `type=note` (create) or the targeted comment is
* already a note (update). The filter is removed again on `rest_post_dispatch`
* so the swap is strictly scoped to the current request.
*
* @since 7.1.0
* @access private
*
* @param mixed $result Response to short-circuit dispatch, or null.
* @param WP_REST_Server $server Server instance.
* @param WP_REST_Request $request The incoming REST request.
* @return mixed Untouched $result.
*/
function _wp_maybe_install_note_kses( $result, $server, $request ) {
$route = $request->get_route();
if ( ! str_starts_with( $route, '/wp/v2/comments' ) ) {
return $result;
}

if ( ! in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH' ), true ) ) {
return $result;
}

$is_note = ( 'note' === $request->get_param( 'type' ) );

// On update, the request may omit `type`. Look up the existing comment.
if ( ! $is_note ) {
$url_params = $request->get_url_params();
if ( ! empty( $url_params['id'] ) ) {
$existing = get_comment( (int) $url_params['id'] );
if ( $existing && 'note' === $existing->comment_type ) {
$is_note = true;
}
}
}

if ( ! $is_note ) {
return $result;
}

// Replace the standard comment kses filters with the note-specific one.
remove_filter( 'pre_comment_content', 'wp_filter_kses' );
remove_filter( 'pre_comment_content', 'wp_filter_post_kses' );
add_filter( 'pre_comment_content', '_wp_note_content_pre_filter' );

add_filter( 'rest_post_dispatch', '_wp_uninstall_note_kses', 10, 3 );

return $result;
}

/**
* Restores the standard comment kses filters after a note REST dispatch.
*
* @since 7.1.0
* @access private
*
* @param WP_REST_Response $response The outgoing response.
* @param WP_REST_Server $server Server instance.
* @param WP_REST_Request $request The dispatched request.
* @return WP_REST_Response Untouched response.
*/
function _wp_uninstall_note_kses( $response, $server, $request ) {
remove_filter( 'pre_comment_content', '_wp_note_content_pre_filter' );

if ( ! current_user_can( 'unfiltered_html' ) ) {
add_filter( 'pre_comment_content', 'wp_filter_kses' );
} else {
add_filter( 'pre_comment_content', 'wp_filter_post_kses' );
}

remove_filter( 'rest_post_dispatch', '_wp_uninstall_note_kses', 10 );

return $response;
}
3 changes: 3 additions & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@
add_action( 'deleted_comment_meta', 'wp_cache_set_comments_last_changed' );
add_action( 'init', 'wp_create_initial_comment_meta' );

// Notes: install a narrower kses allowlist while a REST request targets a note.
add_filter( 'rest_pre_dispatch', '_wp_maybe_install_note_kses', 10, 3 );

// Places to balance tags on input.
foreach ( array( 'content_save_pre', 'excerpt_save_pre', 'comment_save_pre', 'pre_comment_content' ) as $filter ) {
add_filter( $filter, 'convert_invalid_entities' );
Expand Down
149 changes: 149 additions & 0 deletions tests/phpunit/tests/rest-api/rest-comments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -4077,6 +4077,155 @@ public function data_note_status_provider() {
);
}

/**
* Helper: POST a note with the given raw HTML content as an editor and
* return the persisted comment_content (after server-side sanitization).
*
* @param string $content Raw note content posted by the client.
* @return string Sanitized comment_content as stored.
*/
private function post_note_and_get_stored_content( $content ) {
wp_set_current_user( self::$editor_id );
$post_id = self::factory()->post->create(
array(
'post_status' => 'publish',
'post_author' => self::$editor_id,
)
);

$request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
$request->add_header( 'Content-Type', 'application/json' );
$request->set_body(
wp_json_encode(
array(
'post' => $post_id,
'content' => $content,
'author' => self::$editor_id,
'type' => 'note',
)
)
);

$response = rest_get_server()->dispatch( $request );
$this->assertSame( 201, $response->get_status() );

$comment = get_comment( $response->get_data()['id'] );
return $comment->comment_content;
}

/**
* @ticket XXXXX
*/
public function test_note_preserves_allowed_inline_formatting() {
$stored = $this->post_note_and_get_stored_content(
'<strong>Bold</strong> and <em>italic</em> and <code>code</code>'
);
$this->assertSame(
'<strong>Bold</strong> and <em>italic</em> and <code>code</code>',
$stored
);
}

/**
* @ticket XXXXX
*/
public function test_note_preserves_safe_link_and_forces_rel() {
$stored = $this->post_note_and_get_stored_content(
'See <a href="https://wordpress.org/">WordPress</a>.'
);
$this->assertStringContainsString(
'<a href="https://wordpress.org/"',
$stored
);
// Core's `wp_rel_ugc` filter may append `ugc` to the rel attribute on
// comment links, so assert the forced safety tokens are present rather
// than matching the exact rel string.
$this->assertMatchesRegularExpression(
'/<a\b[^>]*\brel="[^"]*\bnoopener\b[^"]*\bnofollow\b[^"]*"/i',
$stored
);
$this->assertStringContainsString( '>WordPress</a>', $stored );
}

/**
* @ticket XXXXX
*/
public function test_note_overrides_client_supplied_rel() {
$stored = $this->post_note_and_get_stored_content(
'<a href="https://wordpress.org/" rel="dofollow">WP</a>'
);
$this->assertStringNotContainsString( 'dofollow', $stored );
$this->assertMatchesRegularExpression(
'/<a\b[^>]*\brel="[^"]*\bnoopener\b[^"]*\bnofollow\b[^"]*"/i',
$stored
);
}

/**
* @ticket XXXXX
*/
public function test_note_strips_disallowed_tags() {
$stored = $this->post_note_and_get_stored_content(
'Hi<script>alert(1)</script><img src="x" onerror="alert(2)">'
);
$this->assertStringNotContainsString( '<script', $stored );
$this->assertStringNotContainsString( '<img', $stored );
$this->assertStringNotContainsString( 'onerror', $stored );
$this->assertStringContainsString( 'Hi', $stored );
}

/**
* @ticket XXXXX
*/
public function test_note_strips_event_handlers_from_allowed_tags() {
$stored = $this->post_note_and_get_stored_content(
'<strong onclick="alert(1)">Bold</strong>'
);
$this->assertStringContainsString( '<strong>Bold</strong>', $stored );
$this->assertStringNotContainsString( 'onclick', $stored );
}

/**
* Regular comments must continue to use the strict wp_filter_kses
* allowlist; the note allowlist should not leak across comment types.
*
* @ticket XXXXX
*/
public function test_non_note_comment_still_strips_inline_formatting() {
wp_set_current_user( self::$editor_id );
$post_id = self::factory()->post->create(
array(
'post_status' => 'publish',
'post_author' => self::$editor_id,
)
);

$request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
$request->add_header( 'Content-Type', 'application/json' );
$request->set_body(
wp_json_encode(
array(
'post' => $post_id,
'content' => '<strong>Bold</strong> body',
'author' => self::$editor_id,
'author_name' => 'Ed',
'author_email' => 'ed@example.test',
)
)
);

$response = rest_get_server()->dispatch( $request );
$this->assertSame( 201, $response->get_status() );

$comment = get_comment( $response->get_data()['id'] );
// The strict comment allowlist permits <strong>, so the tag itself
// being preserved is expected. The key assertion is that the note
// allowlist did NOT install for a non-note request, so the forced
// `noopener nofollow` rel from the note filter must be absent.
$this->assertStringContainsString( 'body', $comment->comment_content );
$this->assertStringNotContainsString( 'noopener', $comment->comment_content );
}

/**
* Test children link for note comment type. Based on test_get_comment_with_children_link.
*
Expand Down
Loading