diff --git a/core/modules/ckeditor/js/ckeditor.js b/core/modules/ckeditor/js/ckeditor.js index 8704aff0bce01016d7247968381fcd6f12f19665..18c4632d47d2b9bb497e7b4ca8b5d0c9b8ab6a27 100644 --- a/core/modules/ckeditor/js/ckeditor.js +++ b/core/modules/ckeditor/js/ckeditor.js @@ -6,6 +6,7 @@ Drupal.editors.ckeditor = { attach: function (element, format) { this._loadExternalPlugins(format); + this._ACF_HACK_to_support_blacklisted_attributes(element, format); return !!CKEDITOR.replace(element, format.editorSettings); }, @@ -42,6 +43,7 @@ Drupal.editors.ckeditor = { attachInlineEditor: function (element, format, mainToolbarId, floatedToolbarId) { this._loadExternalPlugins(format); + this._ACF_HACK_to_support_blacklisted_attributes(element, format); var settings = $.extend(true, {}, format.editorSettings); @@ -98,8 +100,81 @@ Drupal.editors.ckeditor = { } delete format.editorSettings.drupalExternalPlugins; } - } + }, + + /** + * This is a huge hack to do ONE thing: to allow Drupal to fully mandate what + * CKEditor should allow by setting CKEditor's allowedContent setting. The + * problem is that allowedContent only allows for whitelisting, whereas + * Drupal's default HTML filtering (the filter_html filter) also blacklists + * the "style" and "on*" ("onClick" etc.) attributes. + * + * So this function hacks in explicit support for Drupal's filter_html's need + * to blacklist specifically those attributes, until ACF supports blacklisting + * of properties: http://dev.ckeditor.com/ticket/10276. + * + * Limitations: + * - This does not support blacklisting of other attributes, it's only + * intended to implement filter_html's blacklisted attributes. + * - This is only a temporary work-around; it assumes the filter_html + * filter is being used whenever *any* restriction exists. This is a valid + * assumption for the default text formats in Drupal 8 core, but obviously + * won't work for release. + * + * This is the only way we could get https://drupal.org/node/1936392 committed + * before Drupal 8 code freeze on July 1, 2013. CKEditor has committed to + * explicitly supporting this in some way. + * + * @todo D8 remove this once http://dev.ckeditor.com/ticket/10276 is done. + */ + _ACF_HACK_to_support_blacklisted_attributes: function (element, format) { + function override(rule) { + var oldValue = rule.attributes; + function filter_html_override_attributes (attribute) { + // Disallow the "style" and "on*" attributes on any tag. + if (attribute === 'style' || attribute.substr(0, 2) === 'on') { + return false; + } + + // Ensure the original logic still runs, if any. + if (typeof oldValue === 'function') { + return oldValue(attribute); + } + else if (typeof oldValue === 'boolean') { + return oldValue; + } + + // Otherwise, accept this attribute. + return true; + } + rule.attributes = filter_html_override_attributes; + } + CKEDITOR.once('instanceLoaded', function(e) { + if (e.editor.name === element.id) { + // If everything is allowed, everything is allowed. + if (format.editorSettings.allowedContent === true) { + return; + } + // Otherwise, assume Drupal's filter_html filter is being used. + else { + // Get the filter object (ACF). + var filter = e.editor.filter; + // Find the "config" rule (the one caused by the allowedContent + // setting) for each HTML tag, and override its "attributes" value. + for (var el in filter._.rules.elements) { + if (filter._.rules.elements.hasOwnProperty(el)) { + for (var i = 0; i < filter._.rules.elements[el].length; i++) { + if (filter._.rules.elements[el][i].featureName === 'config') { + override(filter._.rules.elements[el][i]); + } + } + } + } + } + } + }); + } }; })(Drupal, CKEDITOR, jQuery); 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 e1f07d6276460dafa67817ee35bc5dec279fc18d..56f9a6e2056fb8a5d733d2e7c0e9981bfd835240 100644 --- a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/CKEditorPlugin/Internal.php +++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/CKEditorPlugin/Internal.php @@ -55,7 +55,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); @@ -242,6 +246,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. */ @@ -264,4 +269,149 @@ 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 { + $get_allowed_attribute_values = function($attribute_values) { + $values = array_keys(array_filter($attribute_values, function($value) { + return $value !== FALSE; + })); + if (count($values)) { + return implode(',', $values); + } + else { + return NULL; + } + }; + + $html_restrictions = filter_get_html_restrictions_by_format($editor->format); + // When all HTML is allowed, also set allowedContent to true. + if ($html_restrictions === FALSE) { + return TRUE; + } + $setting = array(); + foreach ($html_restrictions['allowed'] as $tag => $attributes) { + // Tell CKEditor the tag is allowed, but no attributes. + if ($attributes === FALSE) { + $setting[$tag] = array( + 'attributes' => FALSE, + 'styles' => FALSE, + 'classes' => FALSE, + ); + } + // Tell CKEditor the tag is allowed, as well as any attribute on it. The + // "style" and "class" attributes are handled separately by CKEditor: + // they are disallowed even if you specify it in the list of allowed + // attributes, unless you state specific values for them that are + // allowed. Or, in this case: any value for them is allowed. + elseif ($attributes === TRUE) { + $setting[$tag] = array( + 'attributes' => TRUE, + 'styles' => TRUE, + 'classes' => TRUE, + ); + // We've just marked that any value for the "style" and "class" + // attributes is allowed. However, that may not be the case: the "*" + // tag may still apply restrictions. + // Since CKEditor's ACF follows the following principle: + // Once validated, an element or its property cannot be + // invalidated by another rule. + // That means that the most permissive setting wins. Which means that + // it will still be allowed by CKEditor to e.g. define any style, no + // matter what the "*" tag's restrictions may be. If there's a setting + // for either the "style" or "class" attribute, it cannot possibly be + // more permissive than what was set above. Hence: inherit from the + // "*" tag where possible. + if (isset($html_restrictions['allowed']['*'])) { + $wildcard = $html_restrictions['allowed']['*']; + if (isset($wildcard['style'])) { + if (!is_array($wildcard['style'])) { + $setting[$tag]['styles'] = $wildcard['style']; + } + else { + $allowed_styles = $get_allowed_attribute_values($wildcard['style']); + if (isset($allowed_styles)) { + $setting[$tag]['styles'] = $allowed_styles; + } + else { + unset($setting[$tag]['styles']); + } + } + } + if (isset($wildcard['class'])) { + if (!is_array($wildcard['class'])) { + $setting[$tag]['classes'] = $wildcard['class']; + } + else { + $allowed_classes = $get_allowed_attribute_values($wildcard['class']); + if (isset($allowed_classes)) { + $setting[$tag]['classes'] = $allowed_classes; + } + else { + unset($setting[$tag]['classes']); + } + } + } + } + } + // Tell CKEditor the tag is allowed, along with some tags. + elseif (is_array($attributes)) { + // CKEditor does not yet support blacklisting, so ignore those. + // @todo Update this once http://dev.ckeditor.com/ticket/10276 lands. + $attributes = array_filter($attributes, function($value) { + return $value !== FALSE; + }); + + // Configure allowed attributes, allowed "style" attribute values and + // allowed "class" attribute values. + // CKEditor only allows specific values for the "class" and "style" + // attributes; so ignore restrictions on other attributes, which + // Drupal filters may provide. + // NOTE: A Drupal contrib module can subclass this class, override the + // getConfig() method, and override the JavaScript at + // Drupal.editors.ckeditor to somehow make validation of values for + // attributes other than "class" and "style" work. + if (count($attributes)) { + $setting[$tag]['attributes'] = implode(',', array_keys($attributes)); + } + if (isset($attributes['style']) && is_array($attributes['style'])) { + $allowed_styles = $get_allowed_attribute_values($attributes['style']); + if (isset($allowed_values)) { + $setting[$tag]['styles'] = $allowed_styles; + } + } + if (isset($attributes['class']) && is_array($attributes['class'])) { + $allowed_classes = $get_allowed_attribute_values($attributes['class']); + if (isset($allowed_classes)) { + $setting[$tag]['classes'] = $allowed_classes; + } + } + } + } + + return $setting; + } + } + } diff --git a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/Editor/CKEditor.php b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/Editor/CKEditor.php index 46077a5f124619ca6e44d373aab26e3746f15b00..5ab4961f7034e8436cc7b60e8dc085f7316a2028 100644 --- a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/Editor/CKEditor.php +++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/Editor/CKEditor.php @@ -186,6 +186,8 @@ public function getJSSettings(EditorEntity $editor) { 'drupalExternalPlugins' => array_map('file_create_url', $external_plugins), ); + ksort($settings); + return $settings; } diff --git a/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php b/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php index 81c3b80444a0ae78492cdfdfd8902f372e72641c..971d12e058739162e8918c9895445d4d777829e5 100644 --- a/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php +++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php @@ -21,7 +21,7 @@ class CKEditorTest extends DrupalUnitTestBase { * * @var array */ - public static $modules = array('system', 'editor', 'ckeditor'); + public static $modules = array('system', 'editor', 'ckeditor', 'filter_test'); /** * An instance of the "CKEditor" text editor plugin. @@ -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' => '', @@ -83,6 +87,7 @@ function testGetJSSettings() { 'stylesSet' => FALSE, 'drupalExternalPlugins' => array(), ); + ksort($expected_config); $this->assertIdentical($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for default configuration.'); // Customize the configuration: add button, have two contextually enabled @@ -102,15 +107,81 @@ function testGetJSSettings() { $expected_config['drupalExternalPlugins']['llama_contextual_and_button'] = file_create_url('core/modules/ckeditor/tests/modules/js/llama_contextual_and_button.js'); $expected_config['contentsCss'][] = file_create_url('core/modules/ckeditor/tests/modules/ckeditor_test.css'); $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.'); + ksort($expected_config); + $this->assertIdentical($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']['pre'] = array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE); + $expected_config['allowedContent']['h3'] = array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE); $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.'); + $this->assertIdentical($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.'); + + // Disable the filter_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->assertIdentical($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.'); + + // Enable the filter_test_restrict_tags_and_attributes filter. + $format->setFilterConfig('filter_test_restrict_tags_and_attributes', array( + 'status' => 1, + 'settings' => array( + 'restrictions' => array( + 'allowed' => array( + 'p' => TRUE, + 'a' => array( + 'href' => TRUE, + 'rel' => array('nofollow' => TRUE), + 'class' => array('external' => TRUE), + 'target' => array('_blank' => FALSE), + ), + 'span' => array( + 'class' => array('dodo' => FALSE), + 'property' => array('dc:*' => TRUE), + 'rel' => array('foaf:*' => FALSE), + ), + '*' => array( + 'style' => FALSE, + 'class' => array('is-a-hipster-llama' => TRUE, 'and-more' => TRUE), + 'data-*' => TRUE, + ), + 'del' => FALSE, + ) + ), + ), + )); + $format->save(); + $expected_config['allowedContent'] = array( + 'p' => array( + 'attributes' => TRUE, + 'styles' => FALSE, + 'classes' => 'is-a-hipster-llama,and-more', + ), + 'a' => array( + 'attributes' => 'href,rel,class,target', + 'classes' => 'external', + ), + 'span' => array( + 'attributes' => 'class,property,rel', + ), + '*' => array( + 'attributes' => 'class,data-*', + 'classes' => 'is-a-hipster-llama,and-more', + ), + 'del' => array( + 'attributes' => FALSE, + 'styles' => FALSE, + 'classes' => FALSE, + ), + ); + $expected_config['format_tags'] = 'p'; + ksort($expected_config); + $this->assertIdentical($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.'); } /** @@ -166,6 +237,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. @@ -236,6 +308,18 @@ protected function getDefaultInternalConfig() { ); } + protected function getDefaultAllowedContentConfig() { + return array( + 'h4' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE), + 'h5' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE), + 'h6' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE), + 'p' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE), + 'br' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE), + 'strong' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE), + 'a' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE), + ); + } + 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 263afb522f1e389acd68d485af5f4e4dfabdc16a..1b90cdea323b0f502b452793cdd1df6ce97588f4 100644 --- a/core/modules/filter/filter.module +++ b/core/modules/filter/filter.module @@ -415,6 +415,154 @@ function filter_get_filter_types_by_format($format_id) { return array_unique($filter_types); } +/** + * Retrieve all HTML restrictions (tags and attributes) for a given text format. + * + * Note that restrictions applied to the "*" tag (the wildcard tag, i.e. all + * tags) are treated just like any other HTML tag. That means that any + * restrictions applied to it are not automatically applied to all other tags. + * It is up to the caller to handle this in whatever way it sees fit; this way + * no information granularity is lost. + * + * @param string $format_id + * A text format ID. + * + * @return array|FALSE + * An structured array as returned by FilterInterface::getHTMLRestrictions(), + * but with the intersection of all filters in this text format. + * Will either indicate blacklisting of tags or whitelisting of tags. In the + * latter case, it's possible that restrictions on attributes are also stored. + * FALSE means there are no HTML restrictions. + */ +function filter_get_html_restrictions_by_format($format_id) { + $format = filter_format_load($format_id); + + // Ignore filters that are disabled or don't have HTML restrictions. + $filters = array_filter($format->filters()->getAll(), function($filter) { + if (!$filter->status) { + return FALSE; + } + if ($filter->getType() === FILTER_TYPE_HTML_RESTRICTOR && $filter->getHTMLRestrictions() !== FALSE) { + return TRUE; + } + return FALSE; + }); + + if (empty($filters)) { + return FALSE; + } + 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. + $restrictions = array_reduce($filters, function($restrictions, $filter) { + $new_restrictions = $filter->getHTMLRestrictions(); + + // The first filter with HTML restrictions provides the initial set. + if (!isset($restrictions)) { + return $new_restrictions; + } + // 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 { + // Track the union of forbidden (blacklisted) tags. + if (isset($new_restrictions['forbidden_tags'])) { + if (!isset($restrictions['forbidden_tags'])) { + $restrictions['forbidden_tags'] = $new_restrictions['forbidden_tags']; + } + else { + $restrictions['forbidden_tags'] = array_unique(array_merge($restrictions['forbidden_tags'], $new_restrictions['forbidden_tags'])); + } + } + + // Track the intersection of allowed (whitelisted) tags. + if (isset($restrictions['allowed'])) { + $intersection = $restrictions['allowed']; + foreach ($intersection as $tag => $attributes) { + // If the current tag is not whitelisted by the new filter, then + // it's outside of the intersection. + if (!array_key_exists($tag, $new_restrictions['allowed'])) { + // The exception is the asterisk (which applies to all tags): it + // does not need to be whitelisted by every filter in order to be + // used; not every filter needs attribute restrictions on all tags. + if ($tag === '*') { + continue; + } + unset($intersection[$tag]); + } + // The tag is in the intersection, but now we must calculate the + // intersection of the allowed attributes. + else { + $current_attributes = $intersection[$tag]; + $new_attributes = $new_restrictions['allowed'][$tag]; + // The current intersection does not allow any attributes, never + // allow. + if (!is_array($current_attributes) && $current_attributes == FALSE) { + continue; + } + // The new filter allows less attributes (all -> list or none). + else if (!is_array($current_attributes) && $current_attributes == TRUE && ($new_attributes == FALSE || is_array($new_attributes))) { + $intersection[$tag] = $new_attributes; + } + // The new filter allows less attributes (list -> none). + else if (is_array($current_attributes) && $new_attributes == FALSE) { + $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 attribute values; do an intersection, + // where we take into account that a value of: + // - TRUE means the attribute value is allowed; + // - FALSE means the attribute value is forbidden; + // hence we keep the ANDed result. + else { + $intersection[$tag] = array_intersect_key($intersection[$tag], $new_attributes); + foreach (array_keys($intersection[$tag]) as $attribute_value) { + $intersection[$tag][$attribute_value] = $intersection[$tag][$attribute_value] && $new_attributes[$attribute_value]; + } + } + } + } + $restrictions['allowed'] = $intersection; + } + + return $restrictions; + } + }, NULL); + + // Simplification: if we have both a (intersected) whitelist and a (unioned) + // blacklist, then remove any tags from the whitelist that also exist in the + // blacklist. Now the whitelist alone expresses all tag-level restrictions, + // and we can delete the blacklist. + if (isset($restrictions['allowed']) && isset($restrictions['forbidden_tags'])) { + foreach ($restrictions['forbidden_tags'] as $tag) { + if (isset($restrictions['allowed'][$tag])) { + unset($restrictions['allowed'][$tag]); + } + } + unset($restrictions['forbidden_tags']); + } + + // Simplification: if the only remaining allowed tag is the asterisk (which + // contains attribute restrictions that apply to all tags), and only + // whitelisting filters were used, then effectively nothing is allowed. + if (isset($restrictions['allowed'])) { + if (count($restrictions['allowed']) === 1 && array_key_exists('*', $restrictions['allowed']) && !isset($restrictions['forbidden_tags'])) { + $restrictions['allowed'] = array(); + } + } + + return $restrictions; + } +} + /** * Returns the ID of the fallback text format that all users have access to. * 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 d77e37eb90993b2585f4a871721c2b13669547ed..177ccdb4045d65ee5aeeaf3261f1a8b561a34a33 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtml.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtml.php @@ -65,6 +65,21 @@ public function process($text, $langcode, $cache, $cache_id) { return _filter_html($text, $this); } + /** + * {@inheritdoc} + */ + public function getHTMLRestrictions() { + $restrictions = array('allowed' => array()); + $tags = preg_split('/\s+|<|>/', $this->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY); + // List the allowed HTML tags. + foreach ($tags as $tag) { + $restrictions['allowed'][$tag] = TRUE; + } + // The 'style' and 'on*' ('onClick' etc.) attributes are always forbidden. + $restrictions['allowed']['*'] = array('style' => FALSE, 'on*' => FALSE); + return $restrictions; + } + /** * {@inheritdoc} */ 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 f4a3694179139c76e24b8cd0196c4cb154524b69..24e3114d27a4be439d4aa1e636c53aa7598a33e7 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtmlEscape.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtmlEscape.php @@ -31,6 +31,14 @@ public function process($text, $langcode, $cache, $cache_id) { return _filter_html_escape($text); } + /** + * {@inheritdoc} + */ + public function getHTMLRestrictions() { + // Nothing is allowed. + return array('allowed' => array()); + } + /** * {@inheritdoc} */ diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php b/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php index ab56c911613644955e04dc65f7aaf121b79a8f15..2e80bd067d126bae5df319fcf756032ff64dd851 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php @@ -147,6 +147,13 @@ public function prepare($text, $langcode, $cache, $cache_id) { return $text; } + /** + * {@inheritdoc} + */ + public function getHTMLRestrictions() { + return FALSE; + } + /** * {@inheritdoc} */ diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php b/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php index 0857139f0b12955da4db24c6d121744f81f748f7..5b231a991ae8364ab4d627cf2535d27c545848d6 100644 --- a/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php +++ b/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php @@ -175,6 +175,100 @@ 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 *either* of the following keys: + * - 'allowed': (optional) the allowed tags as keys, and for each of those + * tags (keys) either of the following values: + * - TRUE to indicate any attribute is allowed + * - FALSE to indicate no attributes are allowed + * - an array to convey attribute restrictions: the keys must be + * attribute names (which may use a wildcard, e.g. "data-*"), the + * possible values are similar to the above: + * - TRUE to indicate any attribute value is allowed + * - FALSE to indicate the attribute is forbidden + * - an array to convey attribute value restrictions: the key must + * be attribute values (which may use a wildcard, e.g. "xsd:*"), + * the possible values are TRUE or FALSE: to mark the attribute + * value as allowed or forbidden, respectively + * - 'forbidden_tags': (optional) the forbidden tags + * + * There is one special case: the "wildcard tag", "*": any attribute + * restrictions on that pseudotag apply to all tags. + * + * If no restrictions apply, then FALSE must be returned. + * + * Here is a concrete example, for a very granular filter: + * @code + * array( + * 'allowed' => array( + * // Allows any attribute with any value on the
tag. + * 'div' => TRUE, + * // Allows no attributes on the

tag. + * 'p' => FALSE, + * // Allows the following attributes on the tag: + * // - 'href', with any value; + * // - 'rel', with the value 'nofollow' value. + * 'a' => array( + * 'href' => TRUE, + * 'rel' => array('nofollow' => TRUE), + * ), + * // Only allows the 'src' and 'alt' attributes on the tag, + * // with any value. + * 'img' => array( + * 'src' => TRUE, + * 'alt' => TRUE, + * ), + * // Allow RDFa on tags, using only the dc, foaf, xsd and sioc + * // vocabularies/namespaces. + * 'span' => array( + * 'property' => array('dc:*' => TRUE, 'foaf:*' => TRUE), + * 'datatype' => array('xsd:*' => TRUE), + * 'rel' => array('sioc:*' => TRUE), + * ), + * // Forbid the 'style' and 'on*' ('onClick' etc.) attributes on any + * // tag. + * '*' => array( + * 'style' => FALSE, + * 'on*' => FALSE, + * ), + * ) + * ) + * @endcode + * + * A simpler example, for a very coarse filter: + * @code + * array( + * 'forbidden_tags' => array('iframe', 'script') + * ) + * @endcode + * + * The simplest example possible: a filter that doesn't allow any HTML: + * @code + * array( + * 'allowed' => array() + * ) + * @endcode + * + * And for a filter that applies no restrictions, i.e. allows any HTML: + * @code + * FALSE + * @endcode + * + * @see filter_get_html_restrictions_by_format() + */ + public function getHTMLRestrictions(); + /** * Generates a filter's tip. * diff --git a/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php b/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php index 3918ea926a1d67f3299d250fc3e9d61e1ab3c727..f32d30b84dd57fdb75b68b4bd05939f7d2b4ccd1 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,22 +90,98 @@ function testCheckMarkup() { } /** - * Tests the function filter_get_filter_types_by_format(). + * Tests the following functions for a variety of formats: + * - filter_get_html_restrictions_by_format() + * - filter_get_filter_types_by_format() */ function testFilterFormatAPI() { // Test on filtered_html. - $this->assertEqual( + $this->assertIdentical( + filter_get_html_restrictions_by_format('filtered_html'), + array('allowed' => array('p' => TRUE, 'br' => TRUE, 'strong' => TRUE, 'a' => TRUE, '*' => array('style' => FALSE, 'on*' => FALSE))), + 'filter_get_html_restrictions_by_format() works as expected for the filtered_html format.' + ); + $this->assertIdentical( 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.' ); // Test on full_html. - $this->assertEqual( + $this->assertIdentical( + filter_get_html_restrictions_by_format('full_html'), + FALSE, // Every tag is allowed. + 'filter_get_html_restrictions_by_format() works as expected for the full_html format.' + ); + $this->assertIdentical( 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 allowed. + $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->assertIdentical( + filter_get_html_restrictions_by_format('stupid_filtered_html'), + array('allowed' => array()), // No tag is allowed. + 'filter_get_html_restrictions_by_format() works as expected for the stupid_filtered_html format.' + ); + $this->assertIdentical( + 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' => '


', + ), + ), + 'filter_test_restrict_tags_and_attributes' => array( + 'status' => 1, + 'settings' => array( + 'restrictions' => array( + 'allowed' => array( + 'p' => TRUE, + 'br' => FALSE, + 'a' => array('href' => TRUE), + 'em' => TRUE, + ), + ) + ), + ), + ) + )); + $very_restricted_html->save(); + $this->assertIdentical( + filter_get_html_restrictions_by_format('very_restricted_html'), + array('allowed' => array('p' => TRUE, 'br' => FALSE, 'a' => array('href' => TRUE), '*' => array('style' => FALSE, 'on*' => FALSE))), + 'filter_get_html_restrictions_by_format() works as expected for the very_restricted_html format.' + ); + $this->assertIdentical( + 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/lib/Drupal/filter_test/Plugin/Filter/FilterTestRestrictTagsAndAttributes.php b/core/modules/filter/tests/filter_test/lib/Drupal/filter_test/Plugin/Filter/FilterTestRestrictTagsAndAttributes.php new file mode 100644 index 0000000000000000000000000000000000000000..86f4452b3e90b202c0c58eb9f7bb37e3f3a24e10 --- /dev/null +++ b/core/modules/filter/tests/filter_test/lib/Drupal/filter_test/Plugin/Filter/FilterTestRestrictTagsAndAttributes.php @@ -0,0 +1,73 @@ +settings['restrictions']['allowed'], function($value) { + return is_array($value) || (bool) $value !== FALSE; + }); + return Xss::filter($text, array_keys($allowed_tags)); + } + + /** + * {@inheritdoc} + */ + public function getHTMLRestrictions() { + $restrictions = $this->settings['restrictions']; + + // The configuration system stores FALSE as '0' and TRUE as '1'. Fix that. + if (isset($restrictions['allowed'])) { + foreach ($restrictions['allowed'] as $tag => $attrs_or_bool) { + if (!is_array($attrs_or_bool)) { + $restrictions['allowed'][$tag] = (bool) $attrs_or_bool; + } + else { + foreach ($attrs_or_bool as $attr => $attrvals_or_bool) { + if (!is_array($attrvals_or_bool)) { + $restrictions['allowed'][$tag][$attr] = (bool) $attrvals_or_bool; + } + else { + foreach ($attrvals_or_bool as $attrval => $bool) { + $restrictions['allowed'][$tag][$attr][$attrval] = (bool) $bool; + } + } + } + } + } + } + if (isset($restrictions['forbidden_tags'])) { + foreach ($restrictions['forbidden_tags'] as $tag => $bool) { + $restrictions['forbidden_tags'][$tag] = (bool) $bool; + } + } + + return $restrictions; + } + +}