3 namespace Drupal\filter\Plugin\Filter;
5 use Drupal\Component\Utility\Xss;
6 use Drupal\Core\Form\FormStateInterface;
7 use Drupal\Component\Utility\Html;
8 use Drupal\filter\FilterProcessResult;
9 use Drupal\filter\Plugin\FilterBase;
12 * Provides a filter to limit allowed HTML tags.
14 * The attributes in the annotation show examples of allowing all attributes
15 * by only having the attribute name, or allowing a fixed list of values, or
16 * allowing a value with a wildcard prefix.
20 * title = @Translation("Limit allowed HTML tags and correct faulty HTML"),
21 * type = Drupal\filter\Plugin\FilterInterface::TYPE_HTML_RESTRICTOR,
23 * "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>",
24 * "filter_html_help" = TRUE,
25 * "filter_html_nofollow" = FALSE
30 class FilterHtml extends FilterBase {
33 * The processed HTML restrictions.
37 protected $restrictions;
42 public function settingsForm(array $form, FormStateInterface $form_state) {
43 $form['allowed_html'] = [
44 '#type' => 'textarea',
45 '#title' => $this->t('Allowed HTML tags'),
46 '#default_value' => $this->settings['allowed_html'],
47 '#description' => $this->t('A list of HTML tags that can be used. By default only the <em>lang</em> and <em>dir</em> 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 <em>jump-*</em>. JavaScript event attributes, JavaScript URLs, and CSS are always stripped.'),
50 'filter/drupal.filter.filter_html.admin',
54 $form['filter_html_help'] = [
55 '#type' => 'checkbox',
56 '#title' => $this->t('Display basic HTML help in long filter tips'),
57 '#default_value' => $this->settings['filter_html_help'],
59 $form['filter_html_nofollow'] = [
60 '#type' => 'checkbox',
61 '#title' => $this->t('Add rel="nofollow" to all links'),
62 '#default_value' => $this->settings['filter_html_nofollow'],
70 public function setConfiguration(array $configuration) {
71 if (isset($configuration['settings']['allowed_html'])) {
72 // The javascript in core/modules/filter/filter.filter_html.admin.js
73 // removes new lines and double spaces so, for consistency when javascript
74 // is disabled, remove them.
75 $configuration['settings']['allowed_html'] = preg_replace('/\s+/', ' ', $configuration['settings']['allowed_html']);
77 parent::setConfiguration($configuration);
78 // Force restrictions to be calculated again.
79 $this->restrictions = NULL;
85 public function process($text, $langcode) {
86 $restrictions = $this->getHtmlRestrictions();
87 // Split the work into two parts. For filtering HTML tags out of the content
88 // we rely on the well-tested Xss::filter() code. Since there is no '*' tag
89 // that needs to be removed from the list.
90 unset($restrictions['allowed']['*']);
91 $text = Xss::filter($text, array_keys($restrictions['allowed']));
92 // After we've done tag filtering, we do attribute and attribute value
93 // filtering as the second part.
94 return new FilterProcessResult($this->filterAttributes($text));
98 * Provides filtering of tag attributes into accepted HTML.
100 * @param string $text
101 * The HTML text string to be filtered.
104 * Filtered HTML with attributes filtered according to the settings.
106 public function filterAttributes($text) {
107 $restrictions = $this->getHTMLRestrictions();
108 $global_allowed_attributes = array_filter($restrictions['allowed']['*']);
109 unset($restrictions['allowed']['*']);
111 // Apply attribute restrictions to tags.
112 $html_dom = Html::load($text);
113 $xpath = new \DOMXPath($html_dom);
114 foreach ($restrictions['allowed'] as $allowed_tag => $tag_attributes) {
115 // By default, no attributes are allowed for a tag, but due to the
116 // globally whitelisted attributes, it is impossible for a tag to actually
117 // completely disallow attributes.
118 if ($tag_attributes === FALSE) {
119 $tag_attributes = [];
121 $allowed_attributes = ['exact' => [], 'prefix' => []];
122 foreach (($global_allowed_attributes + $tag_attributes) as $name => $values) {
123 // A trailing * indicates wildcard, but it must have some prefix.
124 if (substr($name, -1) === '*' && $name[0] !== '*') {
125 $allowed_attributes['prefix'][str_replace('*', '', $name)] = $this->prepareAttributeValues($values);
128 $allowed_attributes['exact'][$name] = $this->prepareAttributeValues($values);
131 krsort($allowed_attributes['prefix']);
133 // Find all matching elements that have any attributes and filter the
134 // attributes by name and value.
135 foreach ($xpath->query('//' . $allowed_tag . '[@*]') as $element) {
136 $this->filterElementAttributes($element, $allowed_attributes);
140 if ($this->settings['filter_html_nofollow']) {
141 $links = $html_dom->getElementsByTagName('a');
142 foreach ($links as $link) {
143 $link->setAttribute('rel', 'nofollow');
146 $text = Html::serialize($html_dom);
152 * Filter attributes on an element by name and value according to a whitelist.
154 * @param \DOMElement $element
155 * The element to be processed.
156 * @param array $allowed_attributes
157 * The attributes whitelist as an array of names and values.
159 protected function filterElementAttributes(\DOMElement $element, array $allowed_attributes) {
160 $modified_attributes = [];
161 foreach ($element->attributes as $name => $attribute) {
162 // Remove attributes not in the whitelist.
163 $allowed_value = $this->findAllowedValue($allowed_attributes, $name);
164 if (empty($allowed_value)) {
165 $modified_attributes[$name] = FALSE;
167 elseif ($allowed_value !== TRUE) {
168 // Check the attribute values whitelist.
169 $attribute_values = preg_split('/\s+/', $attribute->value, -1, PREG_SPLIT_NO_EMPTY);
170 $modified_attributes[$name] = [];
171 foreach ($attribute_values as $value) {
172 if ($this->findAllowedValue($allowed_value, $value)) {
173 $modified_attributes[$name][] = $value;
178 // If the $allowed_value was TRUE for an attribute name, it does not
179 // appear in this array so the value on the DOM element is left unchanged.
180 foreach ($modified_attributes as $name => $values) {
182 $element->setAttribute($name, implode(' ', $values));
185 $element->removeAttribute($name);
191 * Helper function to handle prefix matching.
193 * @param array $allowed
194 * Array of allowed names and prefixes.
195 * @param string $name
196 * The name to find or match against a prefix.
200 protected function findAllowedValue(array $allowed, $name) {
201 if (isset($allowed['exact'][$name])) {
202 return $allowed['exact'][$name];
204 // Handle prefix (wildcard) matches.
205 foreach ($allowed['prefix'] as $prefix => $value) {
206 if (strpos($name, $prefix) === 0) {
214 * Helper function to prepare attribute values including wildcards.
216 * Splits the values into two lists, one for values that must match exactly
217 * and the other for values that are wildcard prefixes.
219 * @param bool|array $attribute_values
220 * TRUE, FALSE, or an array of allowed values.
224 protected function prepareAttributeValues($attribute_values) {
225 if ($attribute_values === TRUE || $attribute_values === FALSE) {
226 return $attribute_values;
228 $result = ['exact' => [], 'prefix' => []];
229 foreach ($attribute_values as $name => $allowed) {
230 // A trailing * indicates wildcard, but it must have some prefix.
231 if (substr($name, -1) === '*' && $name[0] !== '*') {
232 $result['prefix'][str_replace('*', '', $name)] = $allowed;
235 $result['exact'][$name] = $allowed;
238 krsort($result['prefix']);
245 public function getHTMLRestrictions() {
246 if ($this->restrictions) {
247 return $this->restrictions;
250 // Parse the allowed HTML setting, and gradually make the whitelist more
252 $restrictions = ['allowed' => []];
254 // Make all the tags self-closing, so they will be parsed into direct
255 // children of the body tag in the DomDocument.
256 $html = str_replace('>', ' />', $this->settings['allowed_html']);
257 // Protect any trailing * characters in attribute names, since DomDocument
258 // strips them as invalid.
259 $star_protector = '__zqh6vxfbk3cg__';
260 $html = str_replace('*', $star_protector, $html);
261 $body_child_nodes = Html::load($html)->getElementsByTagName('body')->item(0)->childNodes;
263 foreach ($body_child_nodes as $node) {
264 if ($node->nodeType !== XML_ELEMENT_NODE) {
265 // Skip the empty text nodes inside tags.
268 $tag = $node->tagName;
269 if ($node->hasAttributes()) {
270 // Mark the tag as allowed, assigning TRUE for each attribute name if
271 // all values are allowed, or an array of specific allowed values.
272 $restrictions['allowed'][$tag] = [];
273 // Iterate over any attributes, and mark them as allowed.
274 foreach ($node->attributes as $name => $attribute) {
275 // Put back any trailing * on wildcard attribute name.
276 $name = str_replace($star_protector, '*', $name);
278 // Put back any trailing * on wildcard attribute value and parse out
279 // the allowed attribute values.
280 $allowed_attribute_values = preg_split('/\s+/', str_replace($star_protector, '*', $attribute->value), -1, PREG_SPLIT_NO_EMPTY);
282 // Sanitize the attribute value: it lists the allowed attribute values
283 // but one allowed attribute value that some may be tempted to use
284 // is specifically nonsensical: the asterisk. A prefix is required for
285 // allowed attribute values with a wildcard. A wildcard by itself
286 // would mean whitelisting all possible attribute values. But in that
287 // case, one would not specify an attribute value at all.
288 $allowed_attribute_values = array_filter($allowed_attribute_values, function ($value) use ($star_protector) {
289 return $value !== '*';
292 if (empty($allowed_attribute_values)) {
293 // If the value is the empty string all values are allowed.
294 $restrictions['allowed'][$tag][$name] = TRUE;
297 // A non-empty attribute value is assigned, mark each of the
298 // specified attribute values as allowed.
299 foreach ($allowed_attribute_values as $value) {
300 $restrictions['allowed'][$tag][$name][$value] = TRUE;
306 // Mark the tag as allowed, but with no attributes allowed.
307 $restrictions['allowed'][$tag] = FALSE;
311 // The 'style' and 'on*' ('onClick' etc.) attributes are always forbidden,
312 // and are removed by Xss::filter().
313 // The 'lang', and 'dir' attributes apply to all elements and are always
314 // allowed. The value whitelist for the 'dir' attribute is enforced by
315 // self::filterAttributes(). Note that those two attributes are in the
316 // short list of globally usable attributes in HTML5. They are always
317 // allowed since the correct values of lang and dir may only be known to
318 // the content author. Of the other global attributes, they are not usually
319 // added by hand to content, and especially the class attribute can have
320 // undesired visual effects by allowing content authors to apply any
321 // available style, so specific values should be explicitly whitelisted.
322 // @see http://www.w3.org/TR/html5/dom.html#global-attributes
323 $restrictions['allowed']['*'] = [
327 'dir' => ['ltr' => TRUE, 'rtl' => TRUE],
329 // Save this calculated result for re-use.
330 $this->restrictions = $restrictions;
332 return $restrictions;
338 public function tips($long = FALSE) {
341 if (!($allowed_html = $this->settings['allowed_html'])) {
344 $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $allowed_html]);
349 $output = '<p>' . $output . '</p>';
350 if (!$this->settings['filter_html_help']) {
354 $output .= '<p>' . $this->t('This site allows HTML content. While learning all of HTML may feel intimidating, learning how to use a very small number of the most basic HTML "tags" is very easy. This table provides examples for each tag that is enabled on this site.') . '</p>';
355 $output .= '<p>' . $this->t('For more information see W3C\'s <a href=":html-specifications">HTML Specifications</a> or use your favorite search engine to find other sites that explain HTML.', [':html-specifications' => 'http://www.w3.org/TR/html/']) . '</p>';
357 'a' => [$this->t('Anchors are used to make links to other pages.'), '<a href="' . $base_url . '">' . Html::escape(\Drupal::config('system.site')->get('name')) . '</a>'],
358 'br' => [$this->t('By default line break tags are automatically added, so use this tag to add additional ones. Use of this tag is different because it is not used with an open/close pair like all the others. Use the extra " /" inside the tag to maintain XHTML 1.0 compatibility'), $this->t('Text with <br />line break')],
359 'p' => [$this->t('By default paragraph tags are automatically added, so use this tag to add additional ones.'), '<p>' . $this->t('Paragraph one.') . '</p> <p>' . $this->t('Paragraph two.') . '</p>'],
360 'strong' => [$this->t('Strong', [], ['context' => 'Font weight']), '<strong>' . $this->t('Strong', [], ['context' => 'Font weight']) . '</strong>'],
361 'em' => [$this->t('Emphasized'), '<em>' . $this->t('Emphasized') . '</em>'],
362 'cite' => [$this->t('Cited'), '<cite>' . $this->t('Cited') . '</cite>'],
363 'code' => [$this->t('Coded text used to show programming source code'), '<code>' . $this->t('Coded') . '</code>'],
364 'b' => [$this->t('Bolded'), '<b>' . $this->t('Bolded') . '</b>'],
365 'u' => [$this->t('Underlined'), '<u>' . $this->t('Underlined') . '</u>'],
366 'i' => [$this->t('Italicized'), '<i>' . $this->t('Italicized') . '</i>'],
367 'sup' => [$this->t('Superscripted'), $this->t('<sup>Super</sup>scripted')],
368 'sub' => [$this->t('Subscripted'), $this->t('<sub>Sub</sub>scripted')],
369 'pre' => [$this->t('Preformatted'), '<pre>' . $this->t('Preformatted') . '</pre>'],
370 'abbr' => [$this->t('Abbreviation'), $this->t('<abbr title="Abbreviation">Abbrev.</abbr>')],
371 'acronym' => [$this->t('Acronym'), $this->t('<acronym title="Three-Letter Acronym">TLA</acronym>')],
372 'blockquote' => [$this->t('Block quoted'), '<blockquote>' . $this->t('Block quoted') . '</blockquote>'],
373 'q' => [$this->t('Quoted inline'), '<q>' . $this->t('Quoted inline') . '</q>'],
374 // Assumes and describes tr, td, th.
375 'table' => [$this->t('Table'), '<table> <tr><th>' . $this->t('Table header') . '</th></tr> <tr><td>' . $this->t('Table cell') . '</td></tr> </table>'],
379 'del' => [$this->t('Deleted'), '<del>' . $this->t('Deleted') . '</del>'],
380 'ins' => [$this->t('Inserted'), '<ins>' . $this->t('Inserted') . '</ins>'],
381 // Assumes and describes li.
382 'ol' => [$this->t('Ordered list - use the <li> to begin each list item'), '<ol> <li>' . $this->t('First item') . '</li> <li>' . $this->t('Second item') . '</li> </ol>'],
383 'ul' => [$this->t('Unordered list - use the <li> to begin each list item'), '<ul> <li>' . $this->t('First item') . '</li> <li>' . $this->t('Second item') . '</li> </ul>'],
385 // Assumes and describes dt and dd.
386 'dl' => [$this->t('Definition lists are similar to other HTML lists. <dl> begins the definition list, <dt> begins the definition term and <dd> begins the definition description.'), '<dl> <dt>' . $this->t('First term') . '</dt> <dd>' . $this->t('First definition') . '</dd> <dt>' . $this->t('Second term') . '</dt> <dd>' . $this->t('Second definition') . '</dd> </dl>'],
389 'h1' => [$this->t('Heading'), '<h1>' . $this->t('Title') . '</h1>'],
390 'h2' => [$this->t('Heading'), '<h2>' . $this->t('Subtitle') . '</h2>'],
391 'h3' => [$this->t('Heading'), '<h3>' . $this->t('Subtitle three') . '</h3>'],
392 'h4' => [$this->t('Heading'), '<h4>' . $this->t('Subtitle four') . '</h4>'],
393 'h5' => [$this->t('Heading'), '<h5>' . $this->t('Subtitle five') . '</h5>'],
394 'h6' => [$this->t('Heading'), '<h6>' . $this->t('Subtitle six') . '</h6>']
396 $header = [$this->t('Tag Description'), $this->t('You Type'), $this->t('You Get')];
397 preg_match_all('/<([a-z0-9]+)[^a-z0-9]/i', $allowed_html, $out);
398 foreach ($out[1] as $tag) {
399 if (!empty($tips[$tag])) {
401 ['data' => $tips[$tag][0], 'class' => ['description']],
402 // The markup must be escaped because this is the example code for the
406 '#prefix' => '<code>',
407 '#plain_text' => $tips[$tag][1],
408 '#suffix' => '</code>',
412 // The markup must not be escaped because this is the example output
414 ['data' => ['#markup' => $tips[$tag][1]], 'class' => ['get']],
419 ['data' => $this->t('No help provided for tag %tag.', ['%tag' => $tag]), 'class' => ['description'], 'colspan' => 3],
425 '#header' => $header,
428 $output .= \Drupal::service('renderer')->render($table);
430 $output .= '<p>' . $this->t('Most unusual characters can be directly entered without any problems.') . '</p>';
431 $output .= '<p>' . $this->t('If you do encounter problems, try using HTML character entities. A common example looks like &amp; for an ampersand & character. For a full list of entities see HTML\'s <a href=":html-entities">entities</a> page. Some of the available characters include:', [':html-entities' => 'http://www.w3.org/TR/html4/sgml/entities.html']) . '</p>';
434 [$this->t('Ampersand'), '&'],
435 [$this->t('Greater than'), '>'],
436 [$this->t('Less than'), '<'],
437 [$this->t('Quotation mark'), '"'],
439 $header = [$this->t('Character Description'), $this->t('You Type'), $this->t('You Get')];
441 foreach ($entities as $entity) {
443 ['data' => $entity[0], 'class' => ['description']],
444 // The markup must be escaped because this is the example code for the
448 '#prefix' => '<code>',
449 '#plain_text' => $entity[1],
450 '#suffix' => '</code>',
454 // The markup must not be escaped because this is the example output
457 'data' => ['#markup' => $entity[1]],
464 '#header' => $header,
467 $output .= \Drupal::service('renderer')->render($table);