Skip to content

Commit 34ffbd3

Browse files
committed
Fix: Preserve bare < characters in feed titles
1 parent bccb9c1 commit 34ffbd3

4 files changed

Lines changed: 91 additions & 2 deletions

File tree

src/wp-includes/default-filters.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,11 @@
254254
add_filter( 'wp_get_custom_css', 'wp_replace_insecure_home_url' );
255255

256256
// RSS filters.
257-
add_filter( 'the_title_rss', 'strip_tags' );
257+
258258
add_filter( 'the_title_rss', 'ent2ncr', 8 );
259-
add_filter( 'the_title_rss', 'esc_html' );
259+
add_filter( 'the_title_rss', 'wp_encode_bare_lt', 9 );
260+
add_filter( 'the_title_rss', 'strip_tags', 10 );
261+
add_filter( 'the_title_rss', 'esc_html', 11 );
260262
add_filter( 'the_content_rss', 'ent2ncr', 8 );
261263
add_filter( 'the_content_feed', 'wp_staticize_emoji' );
262264
add_filter( 'the_content_feed', '_oembed_filter_feed_content' );

src/wp-includes/formatting.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5509,6 +5509,29 @@ function normalize_whitespace( $str ) {
55095509
return $str;
55105510
}
55115511

5512+
/**
5513+
* Encodes bare `<` characters (those not starting an HTML tag) as `&lt;`.
5514+
*
5515+
* @since x.x.x
5516+
*
5517+
* @param string $text The text to encode.
5518+
* @return string Text with bare `<` characters encoded.
5519+
*/
5520+
function wp_encode_bare_lt( $text ) {
5521+
/*
5522+
* A valid HTML tag begins with `<` followed by:
5523+
* - a letter (opening tag, e.g. `<strong>`)
5524+
* - `/` (closing tag, e.g. `</strong>`)
5525+
* - `!` (comment or doctype, e.g. `<!-- … -->`)
5526+
* - `?` (processing instruction, e.g. `<?xml … ?>`)
5527+
*
5528+
* Any `<` not followed by one of these is a bare less-than sign and is
5529+
* encoded as `&lt;` so that strip_tags() does not silently discard it.
5530+
*/
5531+
$encoded = preg_replace( '#<(?![a-zA-Z/!?])#', '&lt;', $text );
5532+
return null !== $encoded ? $encoded : $text;
5533+
}
5534+
55125535
/**
55135536
* Properly strips all HTML tags including 'script' and 'style'.
55145537
*

tests/phpunit/tests/feed/atom.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,4 +294,30 @@ public function test_atom_enclosure_with_extended_url_length_type_parsing() {
294294
}
295295
}
296296
}
297+
298+
/**
299+
* Tests that a bare `<` in a post title is encoded inside the Atom feed CDATA block, not stripped.
300+
*
301+
* Atom wraps `the_title_rss()` output in `<![CDATA[…]]>`. This test verifies
302+
* the encoded `<` character survives into that block by writing the raw title
303+
* directly to the database, bypassing kses sanitization on insert.
304+
*
305+
* @ticket 9993
306+
*
307+
* @covers ::get_the_title_rss
308+
*/
309+
public function test_atom_title_encodes_bare_lt_in_cdata() {
310+
global $wpdb;
311+
312+
$post_id = self::factory()->post->create();
313+
314+
$wpdb->update( $wpdb->posts, array( 'post_title' => '& > test <' ), array( 'ID' => $post_id ) );
315+
clean_post_cache( $post_id );
316+
317+
$this->go_to( '/?feed=atom&p=' . $post_id );
318+
$feed = $this->do_atom();
319+
320+
$this->assertStringContainsString( 'test &lt;', $feed, 'Bare `<` was stripped from the Atom feed title CDATA block.' );
321+
$this->assertStringNotContainsString( 'test ]]>', $feed, 'Bare `<` must not be stripped (leaving title truncated before `]]>`).' );
322+
}
297323
}

tests/phpunit/tests/feed/rss2.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,4 +629,42 @@ function ( $headers ) use ( $today ) {
629629

630630
$this->go_to( '/?feed=rss2&withcomments=1' );
631631
}
632+
633+
/**
634+
* Tests that the `the_title_rss` filter encodes bare `<` characters rather than stripping them.
635+
*
636+
* @ticket 9993
637+
* @dataProvider data_title_rss_encodes_special_characters
638+
*
639+
* @covers ::get_the_title_rss
640+
*/
641+
public function test_title_rss_encodes_special_characters( $title, $expected ) {
642+
$this->assertSame( $expected, apply_filters( 'the_title_rss', $title ) );
643+
}
644+
645+
/**
646+
* Data provider for test_title_rss_encodes_special_characters().
647+
*
648+
* @return array[]
649+
*/
650+
public static function data_title_rss_encodes_special_characters() {
651+
return array(
652+
'bare less-than at end is encoded not stripped' => array(
653+
'& > test <',
654+
'&amp; &gt; test &lt;',
655+
),
656+
'bare less-than in middle is encoded' => array(
657+
'a < b',
658+
'a &lt; b',
659+
),
660+
'html tags are still stripped by strip_tags' => array(
661+
'<strong>bold</strong>',
662+
'bold',
663+
),
664+
'plain text passes through unchanged' => array(
665+
'Hello World',
666+
'Hello World',
667+
),
668+
);
669+
}
632670
}

0 commit comments

Comments
 (0)