diff --git a/core/core.services.yml b/core/core.services.yml index 3f9619f4308e4693faca8a10a91762d471732548..1049e86639a83e99453dc53287fad813dd4345dd 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1857,6 +1857,8 @@ services: response_filter.active_link: class: Drupal\Core\EventSubscriber\ActiveLinkResponseFilter arguments: ['@current_user', '@path.current', '@path.matcher', '@language_manager'] + response_filter.rss.cdata: + class: Drupal\Core\EventSubscriber\RssResponseCdata response_filter.rss.relative_url: class: Drupal\Core\EventSubscriber\RssResponseRelativeUrlFilter messenger: diff --git a/core/lib/Drupal/Core/EventSubscriber/RssResponseCdata.php b/core/lib/Drupal/Core/EventSubscriber/RssResponseCdata.php new file mode 100644 index 0000000000000000000000000000000000000000..4c3c88726d0bc54a246e92d27ac2926742d5c73b --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/RssResponseCdata.php @@ -0,0 +1,79 @@ +<?php + +namespace Drupal\Core\EventSubscriber; + +use Drupal\Component\Utility\Xss; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Subscribes to wrap RSS descriptions in CDATA. + */ +class RssResponseCdata implements EventSubscriberInterface { + + /** + * Wraps RSS descriptions in CDATA. + * + * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event + * The response event. + */ + public function onResponse(ResponseEvent $event): void { + // Skip responses that are not RSS. + if (stripos($event->getResponse()->headers->get('Content-Type', ''), 'application/rss+xml') === FALSE) { + return; + } + + $response = $event->getResponse(); + $response->setContent($this->wrapDescriptionCdata($response->getContent())); + } + + /** + * Converts description node to CDATA RSS markup. + * + * @param string $rss_markup + * The RSS markup to update. + * + * @return string|false + * The updated RSS XML or FALSE if there is an error saving the xml. + */ + protected function wrapDescriptionCdata(string $rss_markup): string|false { + $rss_dom = new \DOMDocument(); + + // Load the RSS, if there are parsing errors, abort and return the unchanged + // markup. + $previous_value = libxml_use_internal_errors(TRUE); + $rss_dom->loadXML($rss_markup); + $errors = libxml_get_errors(); + libxml_use_internal_errors($previous_value); + if ($errors) { + return $rss_markup; + } + + foreach ($rss_dom->getElementsByTagName('item') as $item) { + foreach ($item->getElementsByTagName('description') as $node) { + $html_markup = $node->nodeValue; + if (!empty($html_markup)) { + $html_markup = Xss::filter($html_markup, ['a', 'abbr', 'acronym', 'address', 'b', 'bdo', 'big', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'ul', 'var']); + $new_node = $rss_dom->createCDATASection($html_markup); + $node->replaceChild($new_node, $node->firstChild); + } + } + } + + return $rss_dom->saveXML(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + // This should run after any other response subscriber that modifies the + // markup. + // @see \Drupal\Core\EventSubscriber\RssResponseRelativeUrlFilter + $events[KernelEvents::RESPONSE][] = ['onResponse', -513]; + + return $events; + } + +} diff --git a/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php b/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php index fa6c09c7a7d226fb73b43f943dfbea9bfce25f1e..204a5780428f199e1936460e8256ec06d073c579 100644 --- a/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php +++ b/core/lib/Drupal/Core/EventSubscriber/RssResponseRelativeUrlFilter.php @@ -74,6 +74,7 @@ protected function transformRootRelativeUrlsToAbsolute($rss_markup, Request $req */ public static function getSubscribedEvents(): array { // Should run after any other response subscriber that modifies the markup. + // Only the CDATA wrapper should run after this filter. // @see \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter $events[KernelEvents::RESPONSE][] = ['onResponse', -512]; diff --git a/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php b/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php index 0e092c84ae6a08ebfbe2b98e1975dd32359a3959..4fcbaae3bd127de7cc64ae0deb8cf82f8609f4c5 100644 --- a/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/DisplayFeedTest.php @@ -80,8 +80,13 @@ public function testFeedOutput(): void { $this->assertEquals('Copyright 2019 Dries Buytaert', $this->getSession()->getDriver()->getText('//channel/copyright')); $this->assertEquals($node_title, $this->getSession()->getDriver()->getText('//item/title')); $this->assertEquals($node_link, $this->getSession()->getDriver()->getText('//item/link')); - // Verify HTML is properly escaped in the description field. - $this->assertSession()->responseContains('<p>A paragraph</p>'); + // HTML should no longer be escaped since it is CDATA. Confirm it is + // wrapped in CDATA. + $this->assertSession()->responseContains('<description><![CDATA['); + // Confirm that the view is still displaying the content. + $this->assertSession()->responseContains('<p>A paragraph</p>'); + // Confirm that the CDATA is closed properly. + $this->assertSession()->responseContains(']]></description>'); $view = $this->container->get('entity_type.manager')->getStorage('view')->load('test_display_feed'); $display = &$view->getDisplay('feed_1'); @@ -141,8 +146,13 @@ public function testFeedFieldOutput(): void { $this->drupalGet('test-feed-display-fields.xml'); $this->assertEquals($node_title, $this->getSession()->getDriver()->getText('//item/title')); $this->assertEquals($node_link, $this->getSession()->getDriver()->getText('//item/link')); - // Verify HTML is properly escaped in the description field. - $this->assertSession()->responseContains('<p>A paragraph</p>'); + // HTML should no longer be escaped since it is CDATA. Confirm it is wrapped + // in CDATA. + $this->assertSession()->responseContains('<description><![CDATA['); + // Confirm that the view is still displaying the content. + $this->assertSession()->responseContains('<p>A paragraph</p>'); + // Confirm that the CDATA is closed properly. + $this->assertSession()->responseContains(']]></description>'); // Change the display to use the nid field, which is rewriting output as // 'node/{{ nid }}' and make sure things are still working. diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/RssResponseCdataTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/RssResponseCdataTest.php new file mode 100644 index 0000000000000000000000000000000000000000..00ae45fbb0da0dc0bbd3d70f2aa21f6cf7f1537f --- /dev/null +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/RssResponseCdataTest.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\EventSubscriber; + +use Drupal\Core\EventSubscriber\RssResponseCdata; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * @coversDefaultClass \Drupal\Core\EventSubscriber\RssResponseCdata + * @group event_subscriber + */ +class RssResponseCdataTest extends UnitTestCase { + + /** + * Provides known RSS feeds to compare. + * + * @return array + * An array of valid and invalid RSS feeds. + */ + public static function providerTestOnResponse(): array { + $data = []; + + $valid_feed = <<<RSS +<?xml version="1.0" encoding="utf-8"?> +<rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0" xml:base="https://www.drupal.org"> +<channel> + <title>Drupal.org</title> + <link>https://www.drupal.org</link> + <description>Come for the software & stay for the community +Drupal is an open source content management platform powering millions of websites and applications. It’s built, used, and supported by an active and diverse community of people around the world.</description> + <language>en</language> + <item> + <title>Drupal 8 turns one!</title> + <link>https://www.drupal.org/blog/drupal-8-turns-one</link> + <description><a href="localhost/node/1">Hello&nbsp;</a> + </description> + </item> + </channel> +</rss> +RSS; + + $valid_expected_feed = <<<RSS +<?xml version="1.0" encoding="utf-8"?> +<rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0" xml:base="https://www.drupal.org"> +<channel> + <title>Drupal.org</title> + <link>https://www.drupal.org</link> + <description>Come for the software & stay for the community +Drupal is an open source content management platform powering millions of websites and applications. It’s built, used, and supported by an active and diverse community of people around the world.</description> + <language>en</language> + <item> + <title>Drupal 8 turns one!</title> + <link>https://www.drupal.org/blog/drupal-8-turns-one</link> + <description><![CDATA[<a href="localhost/node/1">Hello </a> + ]]></description> + </item> + </channel> +</rss> + +RSS; + + $data['valid-feed'] = [$valid_feed, $valid_expected_feed]; + + $invalid_feed = <<<RSS +<?xml version="1.0" encoding="utf-8"?> +<rss version="2.0" xml:base="https://www.drupal.org" xmlns:dc="http://purl.org/dc/elements/1.1/"> +<channel> + <title>Drupal.org</title> + <link>https://www.drupal.org</link> + <description>Come for the software, stay for the community +Drupal is an open source content management platform powering millions of websites and applications. It’s built, used, and supported by an active and diverse community of people around the world.</description> + <language>en</language> + <item> + <title>Drupal 8 turns one!</title> + <link>https://www.drupal.org/blog/drupal-8-turns-one</link> + <description> + <![CDATA[ + <a href="localhost/node/1">Hello</a> + <script> +<!--//--><![CDATA[// ><!-- + +<!--//--><![CDATA[// ><!-- + +<!--//--><![CDATA[// ><!-- +(function(d, s, id) { + var js, fjs = d.getElementsByTagName(s)[0]; + if (d.getElementById(id)) return; + js = d.createElement(s); js.id = id; + js.src = "//connect.facebook.net/de_DE/sdk.js#xfbml=1&version=v2.3"; + fjs.parentNode.insertBefore(js, fjs); +}(document, 'script', 'facebook-jssdk')); +//--><!]]]]]]><![CDATA[><![CDATA[> + +//--><!]]]]><![CDATA[> + +//--><!]]> +</script> + ]]> + </description> + </item> + </channel> +</rss> +RSS; + + $data['invalid-feed'] = [$invalid_feed, $invalid_feed]; + return $data; + } + + /** + * @dataProvider providerTestOnResponse + * + * @param string $content + * The content for the request. + * @param string $expected_content + * The expected content from the response. + */ + public function testOnResponse(string $content, string $expected_content): void { + $event = new ResponseEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + Request::create('/'), + HttpKernelInterface::MAIN_REQUEST, + new Response($content, 200, [ + 'Content-Type' => 'application/rss+xml', + ]) + ); + + $url_filter = new RssResponseCdata(); + $url_filter->onResponse($event); + + $this->assertEquals($expected_content, $event->getResponse()->getContent()); + } + +}