diff --git a/core/modules/filter/config/schema/filter.schema.yml b/core/modules/filter/config/schema/filter.schema.yml index b45a475..6756f1e 100644 --- a/core/modules/filter/config/schema/filter.schema.yml +++ b/core/modules/filter/config/schema/filter.schema.yml @@ -51,8 +51,14 @@ filter_settings.filter_html: label: 'Filter HTML' mapping: allowed_html: - type: string + type: sequence label: 'Allowed HTML' + sequence: + type: sequence + label: 'Attributes' + sequence: + type: string + label: 'Value' filter_html_help: type: boolean label: 'HTML help' diff --git a/core/modules/filter/src/Plugin/Filter/FilterHtml.php b/core/modules/filter/src/Plugin/Filter/FilterHtml.php index 882bcc0..3d9425c 100644 --- a/core/modules/filter/src/Plugin/Filter/FilterHtml.php +++ b/core/modules/filter/src/Plugin/Filter/FilterHtml.php @@ -20,7 +20,45 @@ * title = @Translation("Limit allowed HTML tags and correct faulty HTML"), * type = Drupal\filter\Plugin\FilterInterface::TYPE_HTML_RESTRICTOR, * settings = { - * "allowed_html" = "
    1. ", + * "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 * }, @@ -45,6 +83,10 @@ public function settingsForm(array $form, FormStateInterface $form_state) { '#title' => $this->t('Allowed HTML tags'), '#default_value' => $this->settings['allowed_html'], '#description' => $this->t('A list of HTML tags that can be used. By default only the lang and dir attributes are allowed for all HTML tags. Each HTML tag may have attributes which are treated as allowed attribute names for that HTML tag. Each attribute may allow all values, or only allow specific values. Attribute names or values may be written as a prefix and wildcard like jump-*. JavaScript event attributes, JavaScript URLs, and CSS are always stripped.'), + '#value_callback' => [$this, 'allowedHtmlValueCallback'], + '#pre_render' => [ + [$this, 'preRenderAllowedHtml'], + ], '#attached' => [ 'library' => [ 'filter/drupal.filter.filter_html.admin', @@ -65,15 +107,90 @@ public function settingsForm(array $form, FormStateInterface $form_state) { } /** + * Value callback for the allowed_html field. + */ + public function allowedHtmlValueCallback($element, $input, FormStateInterface $form_state) { + return $input === FALSE ? $element['#default_value'] : $this->generateAllowedHtmlSettings($input); + } + + /** + * Pre render callback for the allowed_html element. + */ + public function preRenderAllowedHtml($element) { + $element['#value'] = $this->generateAllowedHtmlString($element['#value']); + return $element; + } + + /** + * Generate an array to represent the allowed HTML settings. + * + * @param string $allowed_html_string + * A string to parse into an allowed HTML settings array. + * + * @return array + * An array representing the allowed HTML settings. + */ + protected function generateAllowedHtmlSettings($allowed_html_string) { + $allowed_html_settings = []; + + // Make all the tags self-closing, so they will be parsed into direct + // children of the body tag in the DomDocument. + $html = str_replace('>', ' />', $allowed_html_string); + // Protect any trailing * characters in attribute names, since DomDocument + // strips them as invalid. + $star_protector = '__zqh6vxfbk3cg__'; + $html = str_replace('*', $star_protector, $html); + $body_child_nodes = Html::load($html) + ->getElementsByTagName('body') + ->item(0)->childNodes; + + foreach ($body_child_nodes as $node) { + if ($node->nodeType !== XML_ELEMENT_NODE) { + // Skip the empty text nodes inside tags. + continue; + } + $tag = $node->tagName; + $allowed_html_settings[$tag] = []; + if ($node->hasAttributes()) { + // Iterate over any attributes, and mark them as allowed. + foreach ($node->attributes as $name => $attribute) { + // Put back any trailing * on wildcard attribute name. + $name = str_replace($star_protector, '*', $name); + $value = str_replace($star_protector, '*', $attribute->value); + if (empty($value)) { + $value = '*'; + } + $allowed_html_settings[$tag][$name] = $value; + } + } + } + + return $allowed_html_settings; + } + + /** + * Generate a string to represent the allowed HTML. + * + * @param array $allowed_html_settings + * The allowed HTML + * @return string + */ + protected function generateAllowedHtmlString($allowed_html_settings) { + $allowed_html_tags = []; + foreach ($allowed_html_settings as $tag => $attributes) { + $attribute_strings = []; + foreach ($attributes as $attribute => $value) { + $attribute_strings[] = $value === '*' ? $attribute : sprintf('%s="%s"', $attribute, $value); + } + $allowed_html_tags[] = sprintf('<%s%s%s>', $tag, count($attribute_strings) > 0 ? ' ' : '', implode(' ', $attribute_strings)); + } + return implode(' ', $allowed_html_tags); + } + + /** * {@inheritdoc} */ public function setConfiguration(array $configuration) { - if (isset($configuration['settings']['allowed_html'])) { - // The javascript in core/modules/filter/filter.filter_html.admin.js - // removes new lines and double spaces so, for consistency when javascript - // is disabled, remove them. - $configuration['settings']['allowed_html'] = preg_replace('/\s+/', ' ', $configuration['settings']['allowed_html']); - } parent::setConfiguration($configuration); // Force restrictions to be calculated again. $this->restrictions = NULL; @@ -251,33 +368,16 @@ public function getHTMLRestrictions() { // specific. $restrictions = ['allowed' => []]; - // Make all the tags self-closing, so they will be parsed into direct - // children of the body tag in the DomDocument. - $html = str_replace('>', ' />', $this->settings['allowed_html']); - // Protect any trailing * characters in attribute names, since DomDocument - // strips them as invalid. - $star_protector = '__zqh6vxfbk3cg__'; - $html = str_replace('*', $star_protector, $html); - $body_child_nodes = Html::load($html)->getElementsByTagName('body')->item(0)->childNodes; - - foreach ($body_child_nodes as $node) { - if ($node->nodeType !== XML_ELEMENT_NODE) { - // Skip the empty text nodes inside tags. - continue; + foreach ($this->settings['allowed_html'] as $element_name => $element_attributes) { + // If no attributes are specified, the element is allowed, but with + // no attributes. + if (empty($element_attributes)) { + $restrictions['allowed'][$element_name] = FALSE; } - $tag = $node->tagName; - if ($node->hasAttributes()) { - // Mark the tag as allowed, assigning TRUE for each attribute name if - // all values are allowed, or an array of specific allowed values. - $restrictions['allowed'][$tag] = []; - // Iterate over any attributes, and mark them as allowed. - foreach ($node->attributes as $name => $attribute) { - // Put back any trailing * on wildcard attribute name. - $name = str_replace($star_protector, '*', $name); - - // Put back any trailing * on wildcard attribute value and parse out - // the allowed attribute values. - $allowed_attribute_values = preg_split('/\s+/', str_replace($star_protector, '*', $attribute->value), -1, PREG_SPLIT_NO_EMPTY); + else { + foreach ($element_attributes as $attribute_name => $attribute_value) { + // Parse the allowed attribute values. + $allowed_attribute_values = preg_split('/\s+/', $attribute_value, -1, PREG_SPLIT_NO_EMPTY); // Sanitize the attribute value: it lists the allowed attribute values // but one allowed attribute value that some may be tempted to use @@ -285,25 +385,21 @@ public function getHTMLRestrictions() { // allowed attribute values with a wildcard. A wildcard by itself // would mean whitelisting all possible attribute values. But in that // case, one would not specify an attribute value at all. - $allowed_attribute_values = array_filter($allowed_attribute_values, function ($value) use ($star_protector) { return $value !== '*'; }); + $allowed_attribute_values = array_filter($allowed_attribute_values, function ($value) { return $value !== '*'; }); if (empty($allowed_attribute_values)) { // If the value is the empty string all values are allowed. - $restrictions['allowed'][$tag][$name] = TRUE; + $restrictions['allowed'][$element_name][$attribute_name] = TRUE; } else { // A non-empty attribute value is assigned, mark each of the // specified attribute values as allowed. - foreach ($allowed_attribute_values as $value) { - $restrictions['allowed'][$tag][$name][$value] = TRUE; + foreach ($allowed_attribute_values as $allowed_attribute_value) { + $restrictions['allowed'][$element_name][$attribute_name][$allowed_attribute_value] = TRUE; } } } } - else { - // Mark the tag as allowed, but with no attributes allowed. - $restrictions['allowed'][$tag] = FALSE; - } } // The 'style' and 'on*' ('onClick' etc.) attributes are always forbidden, @@ -339,7 +435,7 @@ public function tips($long = FALSE) { if (!($allowed_html = $this->settings['allowed_html'])) { return; } - $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $allowed_html]); + $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $this->generateAllowedHtmlString($allowed_html)]); if (!$long) { return $output; }