summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDries2013-06-19 18:58:20 (GMT)
committerDries2013-06-19 18:58:20 (GMT)
commit31fe58d108d4d09ab2bf9a28157f767e2f0c14fb (patch)
treeecb778104397208e1f40e58588ff34aa87fecebd
parent00cb147e146d24c87bc31462e9ff3aabb3dd1569 (diff)
Issue #1936392 by Wim Leers: Configure CKEditor's 'Advanced Content Filter' (ACF) to match Drupal's text filters settings.
-rw-r--r--core/modules/ckeditor/js/ckeditor.js77
-rw-r--r--core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/CKEditorPlugin/Internal.php152
-rw-r--r--core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/Editor/CKEditor.php2
-rw-r--r--core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php94
-rw-r--r--core/modules/filter/filter.module148
-rw-r--r--core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtml.php15
-rw-r--r--core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterHtmlEscape.php8
-rw-r--r--core/modules/filter/lib/Drupal/filter/Plugin/FilterBase.php7
-rw-r--r--core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php94
-rw-r--r--core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php104
-rw-r--r--core/modules/filter/tests/filter_test/lib/Drupal/filter_test/Plugin/Filter/FilterTestRestrictTagsAndAttributes.php73
11 files changed, 754 insertions, 20 deletions
diff --git a/core/modules/ckeditor/js/ckeditor.js b/core/modules/ckeditor/js/ckeditor.js
index 8704aff..18c4632 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 e1f07d6..56f9a6e 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 @@ class Internal extends CKEditorPluginBase {
),
);
- // 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 @@ class Internal extends CKEditorPluginBase {
*
* @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 @@ class Internal extends CKEditorPluginBase {
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 46077a5..5ab4961 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 @@ class CKEditor extends EditorBase {
'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 81c3b80..971d12e 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 @@ class CKEditorTest extends DrupalUnitTestBase {
'filters' => array(
'filter_html' => array(
'status' => 1,
+ 'settings' => array(
+ 'allowed_html' => '<h4> <h5> <h6> <p> <br> <strong> <a>',
+ )
),
),
));
@@ -76,6 +79,7 @@ class CKEditorTest extends DrupalUnitTestBase {
// Default toolbar.
$expected_config = $this->getDefaultInternalConfig() + array(
+ 'allowedContent' => $this->getDefaultAllowedContentConfig(),
'toolbar' => $this->getDefaultToolbarConfig(),
'contentsCss' => $this->getDefaultContentsCssConfig(),
'extraPlugins' => '',
@@ -83,6 +87,7 @@ class CKEditorTest extends DrupalUnitTestBase {
'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 @@ class CKEditorTest extends DrupalUnitTestBase {
$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'] .= '<pre> <h3>';
$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 @@ class CKEditorTest extends DrupalUnitTestBase {
// 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 @@ class CKEditorTest extends DrupalUnitTestBase {
);
}
+ 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 263afb5..1b90cde 100644
--- a/core/modules/filter/filter.module
+++ b/core/modules/filter/filter.module
@@ -416,6 +416,154 @@ function filter_get_filter_types_by_format($format_id) {
}
/**
+ * 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.
*
* 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 d77e37e..177ccdb 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,21 @@ class FilterHtml extends FilterBase {
/**
* {@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}
+ */
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..24e3114 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,14 @@ class FilterHtmlEscape extends FilterBase {
/**
* {@inheritdoc}
*/
+ public function getHTMLRestrictions() {
+ // Nothing is allowed.
+ return array('allowed' => 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..2e80bd0 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 @@ abstract class FilterBase extends PluginBase implements FilterInterface {
/**
* {@inheritdoc}
*/
+ public function getHTMLRestrictions() {
+ 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..5b231a9 100644
--- a/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php
+++ b/core/modules/filter/lib/Drupal/filter/Plugin/FilterInterface.php
@@ -176,6 +176,100 @@ interface FilterInterface {
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 <div> tag.
+ * 'div' => TRUE,
+ * // Allows no attributes on the <p> tag.
+ * 'p' => FALSE,
+ * // Allows the following attributes on the <a> 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 <alt> tag,
+ * // with any value.
+ * 'img' => array(
+ * 'src' => TRUE,
+ * 'alt' => TRUE,
+ * ),
+ * // Allow RDFa on <span> 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.
*
* 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..f32d30b 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 @@ class FilterAPITest extends WebTestBase {
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 @@ class FilterAPITest extends WebTestBase {
// Note that the filter_html filter is of the type FILTER_TYPE_HTML_RESTRICTOR.
'filter_html' => array(
'status' => 1,
+ 'settings' => array(
+ 'allowed_html' => '<p> <br> <strong> <a>',
+ ),
),
)
));
@@ -48,12 +55,7 @@ class FilterAPITest extends WebTestBase {
'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 @@ class FilterAPITest extends WebTestBase {
}
/**
- * 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' => '<p> <br> <a> <strong>',
+ ),
+ ),
+ '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 0000000..86f4452
--- /dev/null
+++ b/core/modules/filter/tests/filter_test/lib/Drupal/filter_test/Plugin/Filter/FilterTestRestrictTagsAndAttributes.php
@@ -0,0 +1,73 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\filter_test\Plugin\Filter\FilterTestRestrictTagsAndAttributes.
+ */
+
+namespace Drupal\filter_test\Plugin\Filter;
+
+use Drupal\filter\Annotation\Filter;
+use Drupal\Core\Annotation\Translation;
+use Drupal\filter\Plugin\FilterBase;
+use Drupal\Component\Utility\Xss;
+
+/**
+ * Provides a test filter to restirct HTML tags and attributes.
+ *
+ * @Filter(
+ * id = "filter_test_restrict_tags_and_attributes",
+ * module = "filter_test",
+ * title = @Translation("Tag and attribute restricting filter"),
+ * description = @Translation("Used for testing filter_get_html_restrictions_by_format()."),
+ * type = FILTER_TYPE_HTML_RESTRICTOR
+ * )
+ */
+class FilterTestRestrictTagsAndAttributes extends FilterBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function process($text, $langcode, $cache, $cache_id) {
+ $allowed_tags = array_filter($this->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;
+ }
+
+}