Commit f09046af authored by catch's avatar catch

Issue #2549077 by pwolanin, Wim Leers, phenaproxima, lauriii: Allow the "Limit...

Issue #2549077 by pwolanin, Wim Leers, phenaproxima, lauriii: Allow the "Limit allowed HTML tags" filter to also restrict HTML attributes, and only allow a small whitelist of attributes by default
parent eac02c74
......@@ -262,9 +262,9 @@ public static function load($html) {
<body>!html</body>
</html>
EOD;
// PHP's \DOMDocument serialization adds straw whitespace in case the markup
// of the wrapping document contains newlines, so ensure to remove all
// newlines before injecting the actual HTML body to process.
// PHP's \DOMDocument serialization adds extra whitespace when the markup
// of the wrapping document contains newlines, so ensure we remove all
// newlines before injecting the actual HTML body to be processed.
$document = strtr($document, array("\n" => '', '!html' => $html));
$dom = new \DOMDocument();
......
......@@ -221,6 +221,11 @@
// the feature that was just added or removed. Not every feature has
// such metadata.
var featureName = this.model.get('buttonsToFeatures')[button.toLowerCase()];
// Features without an associated command do not have a 'feature name' by
// default, so we use the lowercased button name instead.
if (!featureName) {
featureName = button.toLowerCase();
}
var featuresMetadata = this.model.get('featuresMetadata');
if (!featuresMetadata[featureName]) {
featuresMetadata[featureName] = new Drupal.EditorFeature(featureName);
......@@ -301,7 +306,6 @@
broadcastConfigurationChanges: function ($ckeditorToolbar) {
var view = this;
var hiddenEditorConfig = this.model.get('hiddenEditorConfig');
var featuresMetadata = this.model.get('featuresMetadata');
var getFeatureForButton = this.getFeatureForButton.bind(this);
var getCKEditorFeatures = this.getCKEditorFeatures.bind(this);
$ckeditorToolbar
......@@ -335,6 +339,7 @@
getCKEditorFeatures(hiddenEditorConfig, function (features) {
// Trigger a standardized text editor configuration event for each
// feature that was modified by the configuration changes.
var featuresMetadata = view.model.get('featuresMetadata');
for (var name in features) {
if (features.hasOwnProperty(name)) {
var feature = features[name];
......
......@@ -516,6 +516,13 @@ protected function generateACFSettings(Editor $editor) {
}
// Tell CKEditor the tag is allowed, along with some tags.
elseif (is_array($attributes)) {
// Set defaults (these will be overridden below if more specific
// values are present).
$allowed[$tag] = array(
'attributes' => FALSE,
'styles' => FALSE,
'classes' => FALSE,
);
// Configure allowed attributes, allowed "style" attribute values and
// allowed "class" attribute values.
// CKEditor only allows specific values for the "class" and "style"
......@@ -580,6 +587,9 @@ protected function generateACFSettings(Editor $editor) {
}
}
ksort($allowed);
ksort($disallowed);
return array($allowed, $disallowed);
}
}
......
......@@ -54,7 +54,7 @@ protected function setUp() {
'filter_html' => array(
'status' => 1,
'settings' => array(
'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a>',
'allowed_html' => '<h2 id> <h3> <h4> <h5> <h6> <p> <br> <strong> <a href hreflang>',
)
),
),
......@@ -96,6 +96,7 @@ function testGetJSSettings() {
);
$expected_config = $this->castSafeStrings($expected_config);
ksort($expected_config);
ksort($expected_config['allowedContent']);
$this->assertIdentical($expected_config, $this->castSafeStrings($this->ckeditor->getJSSettings($editor)), 'Generated JS settings are correct for default configuration.');
// Customize the configuration: add button, have two contextually enabled
......@@ -122,12 +123,13 @@ function testGetJSSettings() {
// Change the allowed HTML tags; the "allowedContent" and "format_tags"
// settings for CKEditor should automatically be updated as well.
$format = $editor->getFilterFormat();
$format->filters('filter_html')->settings['allowed_html'] .= '<pre> <h3>';
$format->filters('filter_html')->settings['allowed_html'] .= '<pre> <h1>';
$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;h2;h3;h4;h5;h6;pre';
$expected_config['allowedContent']['pre'] = array('attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE);
$expected_config['allowedContent']['h1'] = array('attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE);
$expected_config['format_tags'] = 'p;h1;h2;h3;h4;h5;h6;pre';
ksort($expected_config['allowedContent']);
$this->assertIdentical($expected_config, $this->castSafeStrings($this->ckeditor->getJSSettings($editor)), 'Generated JS settings are correct for customized configuration.');
// Disable the filter_html filter: allow *all *tags.
......@@ -179,14 +181,17 @@ function testGetJSSettings() {
),
'a' => array(
'attributes' => 'href,rel,class,target',
'styles' => FALSE,
'classes' => 'external',
),
'span' => array(
'attributes' => 'class,property,rel,style',
'styles' => 'font-size',
'classes' => FALSE,
),
'*' => array(
'attributes' => 'class,data-*',
'styles' => FALSE,
'classes' => 'is-a-hipster-llama,and-more',
),
'del' => array(
......@@ -206,6 +211,8 @@ function testGetJSSettings() {
);
$expected_config['format_tags'] = 'p';
ksort($expected_config);
ksort($expected_config['allowedContent']);
ksort($expected_config['disallowedContent']);
$this->assertIdentical($expected_config, $this->castSafeStrings($this->ckeditor->getJSSettings($editor)), 'Generated JS settings are correct for customized configuration.');
}
......@@ -421,17 +428,18 @@ protected function getDefaultInternalConfig() {
}
protected function getDefaultAllowedContentConfig() {
return array(
'h2' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE),
'h3' => array('attributes' => TRUE, 'styles' => FALSE, 'classes' => TRUE),
'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),
);
return [
'h2' => ['attributes' => 'id', 'styles' => FALSE, 'classes' => FALSE],
'h3' => ['attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE],
'h4' => ['attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE],
'h5' => ['attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE],
'h6' => ['attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE],
'p' => ['attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE],
'br' => ['attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE],
'strong' => ['attributes' => FALSE, 'styles' => FALSE, 'classes' => FALSE],
'a' => ['attributes' => 'href,hreflang', 'styles' => FALSE, 'classes' => FALSE],
'*' => ['attributes' => 'lang,dir', 'styles' => FALSE, 'classes' => FALSE],
];
}
protected function getDefaultDisallowedContentConfig() {
......
......@@ -821,17 +821,32 @@
* @see Drupal.FilterStatus
*/
Drupal.FilterHTMLRule = function () {
return {
// Allow or forbid tags.
// Allow or forbid tags.
this.tags = [];
this.allow = null;
// Apply restrictions to properties set on tags.
this.restrictedTags = {
tags: [],
allow: null,
// Apply restrictions to properties set on tags.
restrictedTags: {
tags: [],
allowed: {attributes: [], styles: [], classes: []},
forbidden: {attributes: [], styles: [], classes: []}
}
allowed: {attributes: [], styles: [], classes: []},
forbidden: {attributes: [], styles: [], classes: []}
};
return this;
};
Drupal.FilterHTMLRule.prototype.clone = function () {
var clone = new Drupal.FilterHTMLRule();
clone.tags = this.tags.slice(0);
clone.allow = this.allow;
clone.restrictedTags.tags = this.restrictedTags.tags.slice(0);
clone.restrictedTags.allowed.attributes = this.restrictedTags.allowed.attributes.slice(0);
clone.restrictedTags.allowed.styles = this.restrictedTags.allowed.styles.slice(0);
clone.restrictedTags.allowed.classes = this.restrictedTags.allowed.classes.slice(0);
clone.restrictedTags.forbidden.attributes = this.restrictedTags.forbidden.attributes.slice(0);
clone.restrictedTags.forbidden.styles = this.restrictedTags.forbidden.styles.slice(0);
clone.restrictedTags.forbidden.classes = this.restrictedTags.forbidden.classes.slice(0);
return clone;
};
/**
......
......@@ -23,23 +23,15 @@
*/
getRules: function () {
var currentValue = $('#edit-filters-filter-html-settings-allowed-html').val();
var rules = [];
var rule;
var rules = Drupal.behaviors.filterFilterHtmlUpdating._parseSetting(currentValue);
// Build a FilterHTMLRule that reflects the hard-coded behavior that
// strips all "style" attribute and all "on*" attributes.
rule = new Drupal.FilterHTMLRule();
var rule = new Drupal.FilterHTMLRule();
rule.restrictedTags.tags = ['*'];
rule.restrictedTags.forbidden.attributes = ['style', 'on*'];
rules.push(rule);
// Build a FilterHTMLRule that reflects the current settings.
rule = new Drupal.FilterHTMLRule();
var behavior = Drupal.behaviors.filterFilterHtmlUpdating;
rule.allow = true;
rule.tags = behavior._parseSetting(currentValue);
rules.push(rule);
return rules;
}
};
......@@ -63,8 +55,12 @@
// The description for the "Allowed HTML tags" field.
$allowedHTMLDescription: null,
// The user-entered tag list of $allowedHTMLFormItem.
userTags: null,
/**
* The parsed, user-entered tag list of $allowedHTMLFormItem
*
* @var {Object.<string, Drupal.FilterHTMLRule>}
*/
userTags: {},
// The auto-created tag list thus far added.
autoTags: null,
......@@ -116,9 +112,10 @@
this.$allowedHTMLDescription.find('.editor-update-message').remove();
// If any auto-created tags: insert message and update form item.
if (this.autoTags.length > 0) {
if (!_.isEmpty(this.autoTags)) {
this.$allowedHTMLDescription.append(Drupal.theme('filterFilterHTMLUpdateMessage', this.autoTags));
this.$allowedHTMLFormItem.val(this._generateSetting(this.userTags) + ' ' + this._generateSetting(this.autoTags));
var userTagsWithoutOverrides = _.omit(this.userTags, _.keys(this.autoTags));
this.$allowedHTMLFormItem.val(this._generateSetting(userTagsWithoutOverrides) + ' ' + this._generateSetting(this.autoTags));
}
// Restore to original state.
else {
......@@ -142,45 +139,170 @@
* A list of new allowed tags.
*/
_calculateAutoAllowedTags: function (userAllowedTags, newFeatures) {
return _
.chain(newFeatures)
// Reduce multiple features' rules.
.reduce(function (memo, featureRules) {
// Reduce a single features' rules' required tags.
return _.union(memo, _.reduce(featureRules, function (memo, featureRule) {
return _.union(memo, featureRule.required.tags);
}, []));
}, [])
// All new features' required tags are "new allowed tags", except
// for those that are already allowed in the original allowed tags.
.difference(userAllowedTags)
.value();
var featureName;
var feature;
var featureRule;
var filterRule;
var tag;
var editorRequiredTags = {};
// Map the newly added Text Editor features to Drupal.FilterHtmlRule
// objects (to allow comparing userTags with autoTags).
for (featureName in newFeatures) {
if (newFeatures.hasOwnProperty(featureName)) {
feature = newFeatures[featureName];
for (var f = 0; f < feature.length; f++) {
featureRule = feature[f];
for (var t = 0; t < featureRule.required.tags.length; t++) {
tag = featureRule.required.tags[t];
if (!_.has(editorRequiredTags, tag)) {
filterRule = new Drupal.FilterHTMLRule();
filterRule.restrictedTags.tags = [tag];
// @todo Neither Drupal.FilterHtmlRule nor
// Drupal.EditorFeatureHTMLRule allow for generic attribute
// value restrictions, only for the "class" and "style"
// attribute's values to be restricted. The filter_html filter
// always disallows the "style" attribute, so we only need to
// support "class" attribute value restrictions. Fix once
// https://www.drupal.org/node/2567801 lands.
filterRule.restrictedTags.allowed.attributes = featureRule.required.attributes.slice(0);
filterRule.restrictedTags.allowed.classes = featureRule.required.classes.slice(0);
editorRequiredTags[tag] = filterRule;
}
// The tag is already allowed, add any additionally allowed
// attributes.
else {
filterRule = editorRequiredTags[tag];
filterRule.restrictedTags.allowed.attributes = _.union(filterRule.restrictedTags.allowed.attributes, featureRule.required.attributes);
filterRule.restrictedTags.allowed.classes = _.union(filterRule.restrictedTags.allowed.classes, featureRule.required.classes);
}
}
}
}
}
// Now compare userAllowedTags with editorRequiredTags, and build
// autoAllowedTags, which contains:
// - any tags in editorRequiredTags but not in userAllowedTags (i.e. tags
// that are additionally going to be allowed)
// - any tags in editorRequiredTags that already exists in userAllowedTags
// but does not allow all attributes or attribute values
var autoAllowedTags = {};
for (tag in editorRequiredTags) {
// If userAllowedTags does not contain a rule for this editor-required
// tag, then add it to the list of automatically allowed tags.
if (!_.has(userAllowedTags, tag)) {
autoAllowedTags[tag] = editorRequiredTags[tag];
}
// Otherwise, if userAllowedTags already allows this tag, then check if
// additional attributes and classes on this tag are required by the
// editor.
else {
var requiredAttributes = editorRequiredTags[tag].restrictedTags.allowed.attributes;
var allowedAttributes = userAllowedTags[tag].restrictedTags.allowed.attributes;
var needsAdditionalAttributes = requiredAttributes.length && _.difference(requiredAttributes, allowedAttributes).length;
var requiredClasses = editorRequiredTags[tag].restrictedTags.allowed.classes;
var allowedClasses = userAllowedTags[tag].restrictedTags.allowed.classes;
var needsAdditionalClasses = requiredClasses.length && _.difference(requiredClasses, allowedClasses).length;
if (needsAdditionalAttributes || needsAdditionalClasses) {
autoAllowedTags[tag] = userAllowedTags[tag].clone();
}
if (needsAdditionalAttributes) {
autoAllowedTags[tag].restrictedTags.allowed.attributes = _.union(allowedAttributes, requiredAttributes);
}
if (needsAdditionalClasses) {
autoAllowedTags[tag].restrictedTags.allowed.classes = _.union(allowedClasses, requiredClasses);
}
}
}
return autoAllowedTags;
},
/**
* Parses the value of this.$allowedHTMLFormItem.
*
* @param {string} setting
* The string representation of the setting. e.g. "<p> <br> <a>"
* The string representation of the setting. For example:
* <p class="callout"> <br> <a href hreflang>
*
* @return {Array}
* The array representation of the setting. e.g. ['p', 'br', 'a']
* @return {Object.<string, Drupal.FilterHTMLRule>}
* The corresponding text filter HTML rule objects, one per tag, keyed by
* tag name.
*/
_parseSetting: function (setting) {
return setting.length ? setting.substring(1, setting.length - 1).split('> <') : [];
var node;
var tag;
var rule;
var attributes;
var attribute;
var allowedTags = setting.match(/(<[^>]+>)/g);
var sandbox = document.createElement('div');
var rules = {};
for (var t = 0; t < allowedTags.length; t++) {
// Let the browser do the parsing work for us.
sandbox.innerHTML = allowedTags[t];
node = sandbox.firstChild;
tag = node.tagName.toLowerCase();
// Build the Drupal.FilterHtmlRule object.
rule = new Drupal.FilterHTMLRule();
// We create one rule per allowed tag, so always one tag.
rule.restrictedTags.tags = [tag];
// Add the attribute restrictions.
attributes = node.attributes;
for (var i = 0; i < attributes.length; i++) {
attribute = attributes.item(i);
var attributeName = attribute.nodeName;
// @todo Drupal.FilterHtmlRule does not allow for generic attribute
// value restrictions, only for the "class" and "style" attribute's
// values. The filter_html filter always disallows the "style"
// attribute, so we only need to support "class" attribute value
// restrictions. Fix once https://www.drupal.org/node/2567801 lands.
if (attributeName === 'class') {
var attributeValue = attribute.textContent;
rule.restrictedTags.allowed.classes = attributeValue.split(' ');
}
else {
rule.restrictedTags.allowed.attributes.push(attributeName);
}
}
rules[tag] = rule;
}
return rules;
},
/**
* Generates the value of this.$allowedHTMLFormItem.
*
* @param {Array} tags
* The array representation of the setting. e.g. ['p', 'br', 'a']
* @param {Object.<string, Drupal.FilterHTMLRule>} tags
* The parsed representation of the setting.
*
* @return {Array}
* The string representation of the setting. e.g. "<p> <br> <a>"
*/
_generateSetting: function (tags) {
return tags.length ? '<' + tags.join('> <') + '>' : '';
return _.reduce(tags, function (setting, rule, tag) {
if (setting.length) {
setting += ' ';
}
setting += '<' + tag;
if (rule.restrictedTags.allowed.attributes.length) {
setting += ' ' + rule.restrictedTags.allowed.attributes.join(' ');
}
// @todo Drupal.FilterHtmlRule does not allow for generic attribute
// value restrictions, only for the "class" and "style" attribute's
// values. The filter_html filter always disallows the "style"
// attribute, so we only need to support "class" attribute value
// restrictions. Fix once https://www.drupal.org/node/2567801 lands.
if (rule.restrictedTags.allowed.classes.length) {
setting += ' class="' + rule.restrictedTags.allowed.classes.join(' ') + '"';
}
setting += '>';
return setting;
}, '');
}
};
......@@ -196,7 +318,7 @@
*/
Drupal.theme.filterFilterHTMLUpdateMessage = function (tags) {
var html = '';
var tagList = '<' + tags.join('> <') + '>';
var tagList = Drupal.behaviors.filterFilterHtmlUpdating._generateSetting(tags);
html += '<p class="editor-update-message">';
html += Drupal.t('Based on the text editor configuration, these tags have automatically been added: <strong>@tag-list</strong>.', {'@tag-list': tagList});
html += '</p>';
......
......@@ -13,7 +13,6 @@
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Template\Attribute;
use Drupal\filter\Entity\FilterFormat;
use Drupal\filter\FilterFormatInterface;
/**
......@@ -450,25 +449,6 @@ function template_preprocess_filter_tips(&$variables) {
* Filters implemented by the Filter module.
*/
/**
* 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);
$text = Xss::filter($text, $allowed_tags);
if ($filter->settings['filter_html_nofollow']) {
$html_dom = Html::load($text);
$links = $html_dom->getElementsByTagName('a');
foreach ($links as $link) {
$link->setAttribute('rel', 'nofollow');
}
$text = Html::serialize($html_dom);
}
return trim($text);
}
/**
* Converts text into hyperlinks automatically.
*
......
......@@ -34,7 +34,9 @@ process:
- filter_url
- filter_htmlcorrector
- filter_html_escape
settings: settings
settings:
plugin: filter_settings
source: settings
status:
plugin: default_value
default_value: true
......
......@@ -19,7 +19,9 @@ process:
source: name
map:
php_code: filter_null
settings: settings
settings:
plugin: filter_settings
source: settings
status:
plugin: default_value
default_value: true
......
......@@ -7,6 +7,7 @@
namespace Drupal\filter\Plugin\Filter;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Component\Utility\Html;
use Drupal\filter\FilterProcessResult;
......@@ -15,12 +16,16 @@
/**
* Provides a filter to limit allowed HTML tags.
*
* The attributes in the annotation show examples of allowing all attributes
* by only having the attribute name, or allowing a fixed list of values, or
* allowing a value with a wildcard prefix.
*
* @Filter(
* id = "filter_html",
* title = @Translation("Limit allowed HTML tags"),
* title = @Translation("Limit allowed HTML tags and correct faulty HTML"),
* type = Drupal\filter\Plugin\FilterInterface::TYPE_HTML_RESTRICTOR,
* settings = {
* "allowed_html" = "<a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd> <h2> <h3> <h4> <h5> <h6>",
* "allowed_html" = "<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type='1 A I'> <li> <dl> <dt> <dd> <h2 id='jump-*'> <h3 id> <h4 id> <h5 id> <h6 id>",
* "filter_html_help" = TRUE,
* "filter_html_nofollow" = FALSE
* },
......@@ -29,6 +34,13 @@
*/
class FilterHtml extends FilterBase {
/**
* The processed HTML restrictions.
*
* @var array
*/
protected $restrictions;
/**
* {@inheritdoc}
*/
......@@ -39,6 +51,7 @@ public function settingsForm(array $form, FormStateInterface $form_state) {
'#default_value' => $this->settings['allowed_html'],
'#maxlength' => 1024,
'#description' => $this->t('A list of HTML tags that can be used. JavaScript event attributes, JavaScript URLs, and CSS are always stripped.'),
'#size' => 250,
'#attached' => array(
'library' => array(
'filter/drupal.filter.filter_html.admin',
......@@ -58,25 +71,252 @@ public function settingsForm(array $form, FormStateInterface $form_state) {
return $form;
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
parent::setConfiguration($configuration);
// Force restrictions to be calculated again.
$this->restrictions = NULL;
}
/**
* {@inheritdoc}
*/
public function process($text, $langcode) {
return new FilterProcessResult(_filter_html($text, $this));
$restrictions = $this->getHtmlRestrictions();
// Split the work into two parts. For filtering HTML tags out of the content
// we rely on the well-tested Xss::filter() code. Since there is no '*' tag
// that needs to be removed from the list.
unset($restrictions['allowed']['*']);
$text = Xss::filter($text, array_keys($restrictions['allowed']));
// After we've done tag filtering, we do attribute and attribute value
// filtering as the second part.
return new FilterProcessResult($this->filterAttributes($text));
}
/**
* Provides filtering of tag attributes into accepted HTML.
*
* @param string $text
* The HTML text string to be filtered.
*
* @return string
* Filtered HTML with attributes filtered according to the settings.
*/
public function filterAttributes($text) {
$restrictions = $this->getHTMLRestrictions();
$global_allowed_attributes = array_filter($restrictions['allowed']['*']);
unset($restrictions['allowed']['*']);
// Apply attribute restrictions to tags.
$html_dom = Html::load($text);
$xpath = new \DOMXPath($html_dom);
foreach ($restrictions['allowed'] as $allowed_tag => $tag_attributes) {
// By default, no attributes are allowed for a tag, but due to the
// globally whitelisted attributes, it is impossible for a tag to actually
// completely disallow attributes.
if ($tag_attributes === FALSE) {
$tag_attributes = [];
}
$allowed_attributes = ['exact' => [], 'prefix' => []];
foreach (($global_allowed_attributes + $tag_attributes) as $name => $values) {
// A trailing * indicates wildcard, but it must have some prefix.
if (substr($name, -1) === '*' && $name[0] !== '*') {
$allowed_attributes['prefix'][str_replace('*', '', $name)] = $this->prepareAttributeValues($values);
}
else {
$allowed_attributes['exact'][$name] = $this->prepareAttributeValues($values);
}
}
krsort($allowed_attributes['prefix']);
// Find all matching elements that have any attributes and filter the
// attributes by name and value.
foreach ($xpath->query('//' . $allowed_tag . '[@*]') as $element) {
$this->filterElementAttributes($element, $allowed_attributes);
}
}
if ($this->settings['filter_html_nofollow']) {
$links = $html_dom->getElementsByTagName('a');
foreach ($links as $link) {
$link->setAttribute('rel', 'nofollow');
}
}
$text = Html::serialize($html_dom);
return trim($text);
}
/**
* Filter attributes on an element by name and value according to a whitelist.
*
* @param \DOMElement $element
* The element to be processed.
* @param array $allowed_attributes
* The attributes whitelist as an array of names and values.
*/
protected function filterElementAttributes(\DOMElement $element, array $allowed_attributes) {
$modified_attributes = [];
foreach($element->attributes as $name => $attribute) {
// Remove attributes not in the whitelist.
$allowed_value = $this->findAllowedValue($allowed_attributes, $name);
if (empty($allowed_value)) {
$modified_attributes[$name] = FALSE;
}
elseif ($allowed_value !== TRUE) {
// Check the attribute values whitelist.
$attribute_values = preg_split('/\s+/', $attribute->value, -1, PREG_SPLIT_NO_EMPTY);
$modified_attributes[$name] = [];
foreach ($attribute_values as $value) {
if ($this->findAllowedValue($allowed_value, $value)) {
$modified_attributes[$name][] = $value;
}
}
}
}
// If the $allowed_value was TRUE for an attribute name, it does not
// appear in this array so the value on the DOM element is left unchanged.
foreach ($modified_attributes as $name => $values) {
if ($values) {
$element->setAttribute($name, implode(' ', $values));
}
else {
$element->removeAttribute($name);
}
}
}
/**
* Helper function to handle prefix matching.
*
* @param array $allowed
* Array of allowed names and prefixes.
* @param string $name
* The name to find or match against a prefix.
*
* @return bool|array
*/
protected function findAllowedValue(array $allowed, $name) {
if (isset($allowed['exact'][$name])) {
return $allowed['exact'][$name];
}
// Handle prefix (wildcard) matches.