diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 4b6d9de25fa11..f08b3f9b50896 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -254,9 +254,11 @@
add_filter( 'wp_get_custom_css', 'wp_replace_insecure_home_url' );
// RSS filters.
-add_filter( 'the_title_rss', 'strip_tags' );
+
add_filter( 'the_title_rss', 'ent2ncr', 8 );
-add_filter( 'the_title_rss', 'esc_html' );
+add_filter( 'the_title_rss', 'wp_encode_bare_lt', 9 );
+add_filter( 'the_title_rss', 'strip_tags', 10 );
+add_filter( 'the_title_rss', 'esc_html', 11 );
add_filter( 'the_content_rss', 'ent2ncr', 8 );
add_filter( 'the_content_feed', 'wp_staticize_emoji' );
add_filter( 'the_content_feed', '_oembed_filter_feed_content' );
diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php
index 498d676f5c20f..0e1664e78e7b6 100644
--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -5509,6 +5509,29 @@ function normalize_whitespace( $str ) {
return $str;
}
+/**
+ * Encodes bare `<` characters (those not starting an HTML tag) as `<`.
+ *
+ * @since x.x.x
+ *
+ * @param string $text The text to encode.
+ * @return string Text with bare `<` characters encoded.
+ */
+function wp_encode_bare_lt( $text ) {
+ /*
+ * A valid HTML tag begins with `<` followed by:
+ * - a letter (opening tag, e.g. ``)
+ * - `/` (closing tag, e.g. ``)
+ * - `!` (comment or doctype, e.g. ``)
+ * - `?` (processing instruction, e.g. ``)
+ *
+ * Any `<` not followed by one of these is a bare less-than sign and is
+ * encoded as `<` so that strip_tags() does not silently discard it.
+ */
+ $encoded = preg_replace( '#<(?![a-zA-Z/!?])#', '<', $text );
+ return null !== $encoded ? $encoded : $text;
+}
+
/**
* Properly strips all HTML tags including 'script' and 'style'.
*
diff --git a/tests/phpunit/tests/feed/atom.php b/tests/phpunit/tests/feed/atom.php
index 99d4e837afa4a..3cc1baa0d3da4 100644
--- a/tests/phpunit/tests/feed/atom.php
+++ b/tests/phpunit/tests/feed/atom.php
@@ -294,4 +294,30 @@ public function test_atom_enclosure_with_extended_url_length_type_parsing() {
}
}
}
+
+ /**
+ * Tests that a bare `<` in a post title is encoded inside the Atom feed CDATA block, not stripped.
+ *
+ * Atom wraps `the_title_rss()` output in ``. This test verifies
+ * the encoded `<` character survives into that block by writing the raw title
+ * directly to the database, bypassing kses sanitization on insert.
+ *
+ * @ticket 9993
+ *
+ * @covers ::get_the_title_rss
+ */
+ public function test_atom_title_encodes_bare_lt_in_cdata() {
+ global $wpdb;
+
+ $post_id = self::factory()->post->create();
+
+ $wpdb->update( $wpdb->posts, array( 'post_title' => '& > test <' ), array( 'ID' => $post_id ) );
+ clean_post_cache( $post_id );
+
+ $this->go_to( '/?feed=atom&p=' . $post_id );
+ $feed = $this->do_atom();
+
+ $this->assertStringContainsString( 'test <', $feed, 'Bare `<` was stripped from the Atom feed title CDATA block.' );
+ $this->assertStringNotContainsString( 'test ]]>', $feed, 'Bare `<` must not be stripped (leaving title truncated before `]]>`).' );
+ }
}
diff --git a/tests/phpunit/tests/feed/rss2.php b/tests/phpunit/tests/feed/rss2.php
index c4f8807d7b7d3..41bd42141e7a3 100644
--- a/tests/phpunit/tests/feed/rss2.php
+++ b/tests/phpunit/tests/feed/rss2.php
@@ -629,4 +629,42 @@ function ( $headers ) use ( $today ) {
$this->go_to( '/?feed=rss2&withcomments=1' );
}
+
+ /**
+ * Tests that the `the_title_rss` filter encodes bare `<` characters rather than stripping them.
+ *
+ * @ticket 9993
+ * @dataProvider data_title_rss_encodes_special_characters
+ *
+ * @covers ::get_the_title_rss
+ */
+ public function test_title_rss_encodes_special_characters( $title, $expected ) {
+ $this->assertSame( $expected, apply_filters( 'the_title_rss', $title ) );
+ }
+
+ /**
+ * Data provider for test_title_rss_encodes_special_characters().
+ *
+ * @return array[]
+ */
+ public static function data_title_rss_encodes_special_characters() {
+ return array(
+ 'bare less-than at end is encoded not stripped' => array(
+ '& > test <',
+ '& > test <',
+ ),
+ 'bare less-than in middle is encoded' => array(
+ 'a < b',
+ 'a < b',
+ ),
+ 'html tags are still stripped by strip_tags' => array(
+ 'bold',
+ 'bold',
+ ),
+ 'plain text passes through unchanged' => array(
+ 'Hello World',
+ 'Hello World',
+ ),
+ );
+ }
}