.../ckeditor/Plugin/ckeditor/plugin/Internal.php | 46 +++++++- .../lib/Drupal/ckeditor/Tests/CKEditorTest.php | 21 +++- core/modules/filter/filter.api.php | 38 +++++++ core/modules/filter/filter.module | 113 +++++++++++++++++++- .../lib/Drupal/filter/Tests/FilterAPITest.php | 97 +++++++++++++++-- .../filter/tests/filter_test/filter_test.module | 15 ++- 6 files changed, 313 insertions(+), 17 deletions(-) diff --git a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/Internal.php b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/Internal.php index 86fe66f..5b8d9e2 100644 --- a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/Internal.php +++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/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,43 @@ 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) { + $filter_types = filter_get_filter_types_by_format($editor->format); + + // When nothing is disallowed, set allowedContent to true. + if (!in_array(FILTER_TYPE_HTML_RESTRICTOR, $filter_types)) { + return TRUE; + } + // Generate setting that accurately reflects allowed tags and attributes. + else { + $allowed_tags = filter_get_allowed_tags_by_format($editor->format); + $setting = array(); + foreach($allowed_tags 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 fbfb93b..b3a3d9c 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' => '', @@ -103,13 +107,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. + unset($format->filters['filter_html']); + $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.'); } /** @@ -165,6 +177,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. @@ -237,6 +250,10 @@ protected function getDefaultStylesComboConfig() { return array(); } + 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.api.php b/core/modules/filter/filter.api.php index 49311c8..eaba0e3 100644 --- a/core/modules/filter/filter.api.php +++ b/core/modules/filter/filter.api.php @@ -82,6 +82,11 @@ * - tips callback: The name of a function that returns end-user-facing * filter usage guidelines for the filter. See hook_filter_FILTER_tips() * for details. + * - allowed tags callback: The name of a function that returns 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. + * This is only needed for filters of the type FILTER_TYPE_HTML_RESTRICTOR. * - weight: A default weight for the filter in new text formats. * * @see filter_example.module @@ -184,6 +189,39 @@ function hook_filter_FILTER_settings($form, &$form_state, $filter, $format, $def } /** + * Filter allowed tags callback for hook_filter_info(). + * + * Note: This is not really a hook. The function name is manually specified via + * 'allowed tags callback' in hook_filter_info(), with this recommended callback + * name pattern. It is called from filter_get_allowed_tags_by_format(). + * + * 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. + * + * @param $filter + * The filter object containing settings for the given format. + * + * @return array + * An array of allowed tags, or the empty array in case no tags are allowed. + * A valid return value is e.g. array('p' => TRUE, 'a' => array('href')). + * + * @see filter_get_allowed_tags_by_format() + */ +function hook_filter_FILTER_allowed_tags($filter) { + return 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, + ); +} + +/** * Prepare callback for hook_filter_info(). * * Note: This is not really a hook. The function name is manually specified via diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module index b827b4f..94527b0 100644 --- a/core/modules/filter/filter.module +++ b/core/modules/filter/filter.module @@ -495,6 +495,90 @@ function filter_get_filter_types_by_format($format_id) { } /** + * Retrieve all tags and attributes that are 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_tags_by_format($format_id) { + $filters = filter_list_format($format_id); + + // Ignore filters that are disabled or don't have an "allowed tags" setting. + $filters_info = filter_get_filters(); + $filters = array_filter($filters, function($filter) use ($filters_info) { + if (!$filter->status) { + return FALSE; + } + if (!empty($filters_info[$filter->name]['allowed tags callback'])) { + 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_tags = array_reduce($filters, function($intersection, $filter) { + $filters_info = filter_get_filters(); + $function = $filters_info[$filter->name]['allowed tags callback']; + $new_allowed_tags = $function($filter); + + // The first filter with an "allowed tags" setting provides the initial + // set. + if (!isset($intersection)) { + return $new_allowed_tags; + } + // Subsequent filters with an "allowed tags" 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 tags" 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_tags)) { + 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_tags[$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_tags; + } +} + +/** * 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 @@ -1229,6 +1313,7 @@ function filter_filter_info() { 'filter_html_nofollow' => 0, ), 'tips callback' => '_filter_html_tips', + 'allowed tags callback' => '_filter_html_allowed_tags', 'weight' => -10, ); $filters['filter_autop'] = array( @@ -1267,6 +1352,7 @@ function filter_filter_info() { 'type' => FILTER_TYPE_HTML_RESTRICTOR, 'process callback' => '_filter_html_escape', 'tips callback' => '_filter_html_escape_tips', + 'allowed tags callback' => '_filter_html_escape_allowed_tags', 'weight' => -10, ); return $filters; @@ -1302,10 +1388,25 @@ function _filter_html_settings($form, &$form_state, $filter, $format, $defaults) } /** + * Filter allowed tags callback for the HTML content filter. + * + * See hook_filter_FILTER_allowed_tags() for documentation of parameters and + * return value. + */ +function _filter_html_allowed_tags($filter) { + $allowed_tags = array(); + $tags = preg_split('/\s+|<|>/', $filter->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($tags as $tag) { + $allowed_tags[$tag] = TRUE; + } + return $allowed_tags; +} + +/** * Provides filtering of input into accepted HTML. */ function _filter_html($text, $filter) { - $allowed_tags = preg_split('/\s+|<|>/', $filter->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY); + $allowed_tags = array_keys(_filter_html_allowed_tags($filter)); $text = filter_xss($text, $allowed_tags); if ($filter->settings['filter_html_nofollow']) { @@ -1763,6 +1864,16 @@ function _filter_autop_tips($filter, $format, $long = FALSE) { } /** + * Filter allowed tags callback for the HTML content filter. + * + * See hook_filter_FILTER_allowed_tags() for documentation of parameters and + * return value. + */ +function _filter_html_escape_allowed_tags($filter) { + return array(); +} + +/** * Escapes all HTML tags, so they will be visible instead of being effective. */ function _filter_html_escape($text) { diff --git a/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php b/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php index 3918ea9..674af92 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_tags_by_format() + * - filter_get_filter_types_by_format() */ function testFilterFormatAPI() { // Test on filtered_html. $this->assertEqual( + filter_get_allowed_tags_by_format('filtered_html'), + array('p' => TRUE, 'br' => TRUE, 'strong' => TRUE, 'a' => TRUE), + 'filter_get_allowed_tags_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_tags_by_format('full_html'), + TRUE, // Every tag is allowed. + 'filter_get_allowed_tags_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_tags_by_format('stupid_filtered_html'), + array(), // No tag is allowed. + 'filter_get_allowed_tags_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_tags_by_format('very_restricted_html'), + array('p' => TRUE, 'br' => array(), 'a' => array('href')), + 'filter_get_allowed_tags_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.' + ); } } diff --git a/core/modules/filter/tests/filter_test/filter_test.module b/core/modules/filter/tests/filter_test/filter_test.module index a61941a..95eb5e3 100644 --- a/core/modules/filter/tests/filter_test/filter_test.module +++ b/core/modules/filter/tests/filter_test/filter_test.module @@ -40,7 +40,12 @@ function filter_test_filter_info() { 'title' => 'Testing filter', 'type' => FILTER_TYPE_TRANSFORM_IRREVERSIBLE, 'description' => 'Replaces all content with filter and text format information.', - 'process callback' => 'filter_test_replace', + 'process callback' => '_filter_test_replace', + ); + $filters['filter_test_restrict_tags_and_attributes'] = array( + 'title' => 'Tag & attribute restricting filter', + 'type' => FILTER_TYPE_HTML_RESTRICTOR, + 'allowed tags callback' => '_filter_html_restrict_tags_and_attributes', ); return $filters; } @@ -50,7 +55,7 @@ function filter_test_filter_info() { * * Replaces all text with filter and text format information. */ -function filter_test_replace($text, $filter, $format, $langcode, $cache, $cache_id) { +function _filter_test_replace($text, $filter, $format, $langcode, $cache, $cache_id) { $text = array(); $text[] = 'Filter: ' . $filter->title . ' (' . $filter->name . ')'; $text[] = 'Format: ' . $format->name . ' (' . $format->format . ')'; @@ -62,3 +67,9 @@ function filter_test_replace($text, $filter, $format, $langcode, $cache, $cache_ return implode("
\n", $text); } +/** + * Filter allowed tags callback. + */ +function _filter_html_restrict_tags_and_attributes($filter) { + return $filter->settings['restrictions']; +}