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( '