From 7e67054b244942269e8c79536b8acee3640e9aa6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 15 May 2026 09:29:13 -0700 Subject: [PATCH] REST API: Allow inline rich text in Notes content. Notes (block-comment type) ship a lightweight rich-text input from Gutenberg supporting bold, italic, links, and inline code. Regular `pre_comment_content` sanitization (`wp_filter_kses`) would strip those tags for users without the `unfiltered_html` capability. Install a narrower, note-specific kses allowlist on `pre_comment_content` for the duration of any REST request that targets a note, leaving non-note comments on their existing filter chain. Force `rel="noopener nofollow"` on outbound links via the HTML API to prevent SEO manipulation and window.opener attacks. Backports the server-side piece of Gutenberg PR WordPress/gutenberg#78242. Props adamsilverstein, mamaduka, jasmussen. See #XXXXX. --- src/wp-includes/comment.php | 139 ++++++++++++++++ src/wp-includes/default-filters.php | 3 + .../rest-api/rest-comments-controller.php | 149 ++++++++++++++++++ 3 files changed, 291 insertions(+) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 5395997ecd0ef..970c6f76b94c7 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -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; +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index a4ecf677e2af1..7004bd413eedc 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -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' ); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 8542bcd42af24..56ff2b4305fb7 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -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( + 'Bold and italic and code' + ); + $this->assertSame( + 'Bold and italic and code', + $stored + ); + } + + /** + * @ticket XXXXX + */ + public function test_note_preserves_safe_link_and_forces_rel() { + $stored = $this->post_note_and_get_stored_content( + 'See WordPress.' + ); + $this->assertStringContainsString( + 'assertMatchesRegularExpression( + '/]*\brel="[^"]*\bnoopener\b[^"]*\bnofollow\b[^"]*"/i', + $stored + ); + $this->assertStringContainsString( '>WordPress', $stored ); + } + + /** + * @ticket XXXXX + */ + public function test_note_overrides_client_supplied_rel() { + $stored = $this->post_note_and_get_stored_content( + 'WP' + ); + $this->assertStringNotContainsString( 'dofollow', $stored ); + $this->assertMatchesRegularExpression( + '/]*\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' + ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( '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( + 'Bold' + ); + $this->assertStringContainsString( 'Bold', $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' => 'Bold 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 , 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. *