.../ckeditor/Plugin/CKEditorPlugin/Internal.php | 49 +++++++++- .../lib/Drupal/ckeditor/Tests/CKEditorTest.php | 21 ++++- core/modules/filter/filter.module | 81 ++++++++++++++++ .../lib/Drupal/filter/Plugin/Filter/FilterHtml.php | 12 +++ .../filter/Plugin/Filter/FilterHtmlEscape.php | 7 ++ .../filter/lib/Drupal/filter/Plugin/FilterBase.php | 7 ++ .../lib/Drupal/filter/Plugin/FilterInterface.php | 31 +++++++ .../lib/Drupal/filter/Tests/FilterAPITest.php | 97 +++++++++++++++++--- 8 files changed, 291 insertions(+), 14 deletions(-) diff --git a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/CKEditorPlugin/Internal.php b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/CKEditorPlugin/Internal.php index 4516762..b9c3aff 100644 --- a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/CKEditorPlugin/Internal.php +++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/CKEditorPlugin/Internal.php @@ -56,7 +56,11 @@ public function getConfig(Editor $editor) { ), ); - // Next, add the format_tags setting, if its button is enabled. + // Add the allowedContent setting, which ensures CKEditor only allows tags + // and attributes that are allowed by the text format for this text editor. + $config['allowedContent'] = $this->generateAllowedContentSetting($editor); + + // Add the format_tags setting, if its button is enabled. $toolbar_buttons = array_unique(NestedArray::mergeDeepArray($editor->settings['toolbar']['buttons'])); if (in_array('Format', $toolbar_buttons)) { $config['format_tags'] = $this->generateFormatTagsSetting($editor); @@ -243,6 +247,7 @@ public function getButtons() { * * @param \Drupal\editor\Plugin\Core\Entity\Editor $editor * A configured text editor object. + * * @return array * An array containing the "format_tags" configuration. */ @@ -265,4 +270,46 @@ protected function generateFormatTagsSetting(Editor $editor) { return implode(';', $format_tags); } + /** + * Builds the "allowedContent" configuration part of the CKEditor JS settings. + * + * This ensures that CKEditor obeys the HTML restrictions defined by Drupal's + * filter system, by enabling CKEditor's Advanced Content Filter (ACF) + * functionality: http://ckeditor.com/blog/CKEditor-4.1-RC-Released. + * + * @see getConfig() + * + * @param \Drupal\editor\Plugin\Core\Entity\Editor $editor + * A configured text editor object. + * + * @return string|TRUE + * The "allowedContent" configuration: a well-formatted string or TRUE. The + * latter indicates that anything is allowed. + */ + protected function generateAllowedContentSetting(Editor $editor) { + // When nothing is disallowed, set allowedContent to true. + $filter_types = filter_get_filter_types_by_format($editor->format); + if (!in_array(FILTER_TYPE_HTML_RESTRICTOR, $filter_types)) { + return TRUE; + } + // Generate setting that accurately reflects allowed tags and attributes. + else { + $allowed_html = filter_get_allowed_html_by_format($editor->format); + // When all HTML is allowed, also set allowedContent to true. + if ($allowed_html === TRUE) { + return TRUE; + } + $setting = array(); + foreach($allowed_html as $tag => $attributes) { + if ($attributes === TRUE) { + $setting[] = $tag . '[*]'; + } + else { + $setting[] = $tag . '[' . implode(',', $attributes) . ']'; + } + } + return implode(';', $setting); + } + } + } diff --git a/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php b/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php index 6f71077..9a689ad 100644 --- a/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php +++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php @@ -53,6 +53,9 @@ function setUp() { 'filters' => array( 'filter_html' => array( 'status' => 1, + 'settings' => array( + 'allowed_html' => '


', + ) ), ), )); @@ -76,6 +79,7 @@ function testGetJSSettings() { // Default toolbar. $expected_config = $this->getDefaultInternalConfig() + array( + 'allowedContent' => $this->getDefaultAllowedContentConfig(), 'toolbar' => $this->getDefaultToolbarConfig(), 'contentsCss' => $this->getDefaultContentsCssConfig(), 'extraPlugins' => '', @@ -104,13 +108,21 @@ function testGetJSSettings() { $expected_config['keystrokes'] = array(array(1114187, 'link'), array(1114188, NULL)); $this->assertEqual($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.'); - // Change the allowed HTML tags; the "format_tags" setting for CKEditor - // should automatically be updated as well. + // Change the allowed HTML tags; the "allowedContent" and format_tags" + // settings for CKEditor should automatically be updated as well. $format = entity_load('filter_format', 'filtered_html'); $format->filters('filter_html')->settings['allowed_html'] .= '

 

'; $format->save(); + $expected_config['allowedContent'] = 'h4[*];h5[*];h6[*];p[*];br[*];strong[*];a[*];pre[*];h3[*]'; $expected_config['format_tags'] = 'p;h3;h4;h5;h6;pre'; $this->assertEqual($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.'); + + // Remove the filtered_html filter: allow *all *tags. + $format->setFilterConfig('filter_html', array('status' => 0)); + $format->save(); + $expected_config['allowedContent'] = TRUE; + $expected_config['format_tags'] = 'p;h1;h2;h3;h4;h5;h6;pre'; + $this->assertEqual($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.'); } /** @@ -166,6 +178,7 @@ function testInternalGetConfig() { // Default toolbar. $expected = $this->getDefaultInternalConfig(); + $expected['allowedContent'] = $this->getDefaultAllowedContentConfig(); $this->assertIdentical($expected, $internal_plugin->getConfig($editor), '"Internal" plugin configuration built correctly for default toolbar.'); // Format dropdown/button enabled: new setting should be present. @@ -230,6 +243,10 @@ protected function getDefaultInternalConfig() { ); } + protected function getDefaultAllowedContentConfig() { + return 'h4[*];h5[*];h6[*];p[*];br[*];strong[*];a[*]'; + } + protected function getDefaultToolbarConfig() { return array( 0 => array('items' => array('Bold', 'Italic')), diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module index 99461e6..b326b8e 100644 --- a/core/modules/filter/filter.module +++ b/core/modules/filter/filter.module @@ -416,6 +416,87 @@ function filter_get_filter_types_by_format($format_id) { } /** + * Retrieve all HTML tags and attributes allowed by a given text format. + * + * @param string $format_id + * A text format ID. + * + * @return array|TRUE + * An array of HTML tags (in "p", not "

" format) that are allowed by the + * text format. The empty array implies no tags are allowed. TRUE implies all + * tags are allowed. + */ +function filter_get_allowed_html_by_format($format_id) { + $format = filter_format_load($format_id); + + // Ignore filters that are disabled or don't have an "allowed html" setting. + $filters = array_filter($format->filters()->getAll(), function($filter) { + if (!$filter->status) { + return FALSE; + } + if ($filter->getType() === FILTER_TYPE_HTML_RESTRICTOR && $filter->getAllowedHTML() !== FALSE) { + return TRUE; + } + return FALSE; + }); + + if (empty($filters)) { + return TRUE; + } + else { + // From the set of remaining filters (they were filtered by array_filter() + // above), collect the list of tags and attributes that are allowed by all + // filters, i.e. the intersection of all allowed tags and attributes. + $allowed_html = array_reduce($filters, function($intersection, $filter) { + $new_allowed_html = $filter->getAllowedHTML(); + + // The first filter with an "allowed html" setting provides the initial + // set. + if (!isset($intersection)) { + return $new_allowed_html; + } + // Subsequent filters with an "allowed html" setting must be intersected + // with the existing set, to ensure we only end up with the tags that are + // allowed by *all* filters with an "allowed html" setting. + else { + foreach ($intersection as $tag => $attributes) { + // If the current tag is disallowed by the new filter, then it's + // outside of the intersection. + if (!array_key_exists($tag, $new_allowed_html)) { + unset($intersection[$tag]); + } + // The tag is in the intersection, but now we most calculate the + // intersection of the allowed attributes. + else { + $current_attributes = $intersection[$tag]; + $new_attributes = $new_allowed_html[$tag]; + // The new filter allows less attributes; assign new. + if ($current_attributes == TRUE && is_array($new_attributes)) { + $intersection[$tag] = $new_attributes; + } + // The new filter allows more attributes; retain current. + else if (is_array($current_attributes) && $new_attributes == TRUE) { + continue; + } + // The new filter allows the same attributes; retain current. + else if ($current_attributes == $new_attributes) { + continue; + } + // Both list an array of allowed attributes; do an intersection. + else { + $intersection[$tag] = array_intersect($intersection[$tag], $new_attributes); + } + } + } + return $intersection; + } + }, NULL); + + return $allowed_html; + } +} + +/** * Returns the ID of the fallback text format that all users have access to. * * The fallback text format is a regular text format in every respect, except diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtml.php b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtml.php index bcfd15a..4d227c2 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtml.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtml.php @@ -68,6 +68,18 @@ public function process($text, $langcode, $cache, $cache_id) { /** * {@inheritdoc} */ + public function getAllowedHTML() { + $allowed_html = array(); + $tags = preg_split('/\s+|<|>/', $this->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($tags as $tag) { + $allowed_html[$tag] = TRUE; + } + return $allowed_html; + } + + /** + * {@inheritdoc} + */ public function tips($long = FALSE) { global $base_url; diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtmlEscape.php b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtmlEscape.php index f4a3694..59f7867 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtmlEscape.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtmlEscape.php @@ -34,6 +34,13 @@ public function process($text, $langcode, $cache, $cache_id) { /** * {@inheritdoc} */ + public function getAllowedHTML() { + return array(); + } + + /** + * {@inheritdoc} + */ public function tips($long = FALSE) { return t('No HTML tags allowed.'); } diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php b/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php index ab56c91..d6150db 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php @@ -150,6 +150,13 @@ public function prepare($text, $langcode, $cache, $cache_id) { /** * {@inheritdoc} */ + public function getAllowedHTML() { + return FALSE; + } + + /** + * {@inheritdoc} + */ public function tips($long = FALSE) { } diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php b/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php index 0857139..7bd3203 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php @@ -176,6 +176,37 @@ public function prepare($text, $langcode, $cache, $cache_id); public function process($text, $langcode, $cache, $cache_id); /** + * Returns HTML allowed by this filter's configuration. + * + * May be implemented by filters of the type FILTER_TYPE_HTML_RESTRICTOR, this + * won't be used for filters of other types; they should just return FALSE. + * + * This callback function is only necessary for filters that strip away HTML + * tags (and possibly attributes) and allows other modules to gain insight in + * a generic manner into which HTML tags and attributes are allowed by a + * format. + * + * @return array|FALSE + * A nested array with the allowed tags as keys, and for each of those tags + * (keys) the corresponding allowed attributes. An empty array for allowed + * attributes means no attributes are allowed, TRUE means all attributes are + * allowed. An example: + * array( + * // Allows no attributes on the

tag. + * 'p' => array(), + * // Only allows the 'href' attribute on the tag. + * 'a' => array('href'), + * // Only allows the 'src' and 'alt' attributes on the tag. + * 'img' => array('src', 'alt'), + * // Allows any attribute on the

tag. + * 'div' => TRUE, + * ) + * + * @see filter_get_allowed_html_by_format() + */ + public function getAllowedHTML(); + + /** * Generates a filter's tip. * * A filter's tips should be informative and to the point. Short tips are diff --git a/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php b/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php index 3918ea9..c57280f 100644 --- a/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php +++ b/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php @@ -2,17 +2,19 @@ /** * @file - * Definition of Drupal\filter\Tests\FilterAPITest. + * Contains \Drupal\filter\Tests\FilterAPITest. */ namespace Drupal\filter\Tests; -use Drupal\simpletest\WebTestBase; +use Drupal\simpletest\DrupalUnitTestBase; /** * Tests the behavior of Filter's API. */ -class FilterAPITest extends WebTestBase { +class FilterAPITest extends DrupalUnitTestBase { + + public static $modules = array('system', 'filter', 'filter_test'); public static function getInfo() { return array( @@ -25,6 +27,8 @@ public static function getInfo() { function setUp() { parent::setUp(); + $this->installConfig(array('system')); + // Create Filtered HTML format. $filtered_html_format = entity_create('filter_format', array( 'format' => 'filtered_html', @@ -38,6 +42,9 @@ function setUp() { // Note that the filter_html filter is of the type FILTER_TYPE_HTML_RESTRICTOR. 'filter_html' => array( 'status' => 1, + 'settings' => array( + 'allowed_html' => '


', + ), ), ) )); @@ -48,12 +55,7 @@ function setUp() { 'format' => 'full_html', 'name' => 'Full HTML', 'weight' => 1, - 'filters' => array( - 'filter_htmlcorrector' => array( - 'weight' => 10, - 'status' => 1, - ), - ), + 'filters' => array(), )); $full_html_format->save(); } @@ -88,11 +90,18 @@ function testCheckMarkup() { } /** - * Tests the function filter_get_filter_types_by_format(). + * Tests the following functions for a variety of formats: + * - filter_get_allowed_html_by_format() + * - filter_get_filter_types_by_format() */ function testFilterFormatAPI() { // Test on filtered_html. $this->assertEqual( + filter_get_allowed_html_by_format('filtered_html'), + array('p' => TRUE, 'br' => TRUE, 'strong' => TRUE, 'a' => TRUE), + 'filter_get_allowed_html_by_format() works as expected for the filtered_html format.' + ); + $this->assertEqual( filter_get_filter_types_by_format('filtered_html'), array(FILTER_TYPE_HTML_RESTRICTOR, FILTER_TYPE_MARKUP_LANGUAGE), 'filter_get_filter_types_by_format() works as expected for the filtered_html format.' @@ -100,10 +109,76 @@ function testFilterFormatAPI() { // Test on full_html. $this->assertEqual( + filter_get_allowed_html_by_format('full_html'), + TRUE, // Every tag is allowed. + 'filter_get_allowed_html_by_format() works as expected for the full_html format.' + ); + $this->assertEqual( filter_get_filter_types_by_format('full_html'), - array(FILTER_TYPE_HTML_RESTRICTOR), + array(), 'filter_get_filter_types_by_format() works as expected for the full_html format.' ); + + // Test on stupid_filtered_html, where nothing is disallowed. + $stupid_filtered_html_format = entity_create('filter_format', array( + 'format' => 'stupid_filtered_html', + 'name' => 'Stupid Filtered HTML', + 'filters' => array( + 'filter_html' => array( + 'status' => 1, + 'settings' => array( + 'allowed_html' => '', // Nothing is allowed. + ), + ), + ), + )); + $stupid_filtered_html_format->save(); + $this->assertEqual( + filter_get_allowed_html_by_format('stupid_filtered_html'), + array(), // No tag is allowed. + 'filter_get_allowed_html_by_format() works as expected for the stupid_filtered_html format.' + ); + $this->assertEqual( + filter_get_filter_types_by_format('stupid_filtered_html'), + array(FILTER_TYPE_HTML_RESTRICTOR), + 'filter_get_filter_types_by_format() works as expected for the stupid_filtered_html format.' + ); + + // Test on very_restricted_html, where there's two different filters of the + // FILTER_TYPE_HTML_RESTRICTOR type, each restricting in different ways. + $very_restricted_html = entity_create('filter_format', array( + 'format' => 'very_restricted_html', + 'name' => 'Very Restricted HTML', + 'filters' => array( + 'filter_html' => array( + 'status' => 1, + 'settings' => array( + 'allowed_html' => 'p br a strong', + ), + ), + 'filter_test_restrict_tags_and_attributes' => array( + 'status' => 1, + 'settings' => array( + 'restrictions' => array( + 'p' => TRUE, + 'br' => array(), + 'a' => array('href'), + ) + ), + ), + ) + )); + $very_restricted_html->save(); + $this->assertEqual( + filter_get_allowed_html_by_format('very_restricted_html'), + array('p' => TRUE, 'br' => array(), 'a' => array('href')), + 'filter_get_allowed_html_by_format() works as expected for the very_restricted_html format.' + ); + $this->assertEqual( + filter_get_filter_types_by_format('very_restricted_html'), + array(FILTER_TYPE_HTML_RESTRICTOR), + 'filter_get_filter_types_by_format() works as expected for the very_restricted_html format.' + ); } }