f1a62c926aa3670bd335a508da074f43fd641c1a
[yaffs-website] / web / core / modules / filter / src / Plugin / Filter / FilterHtml.php
1 <?php
2
3 namespace Drupal\filter\Plugin\Filter;
4
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;
10
11 /**
12  * Provides a filter to limit allowed HTML tags.
13  *
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.
17  *
18  * @Filter(
19  *   id = "filter_html",
20  *   title = @Translation("Limit allowed HTML tags and correct faulty HTML"),
21  *   type = Drupal\filter\Plugin\FilterInterface::TYPE_HTML_RESTRICTOR,
22  *   settings = {
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
26  *   },
27  *   weight = -10
28  * )
29  */
30 class FilterHtml extends FilterBase {
31
32   /**
33    * The processed HTML restrictions.
34    *
35    * @var array
36    */
37   protected $restrictions;
38
39   /**
40    * {@inheritdoc}
41    */
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.'),
48       '#attached' => [
49         'library' => [
50           'filter/drupal.filter.filter_html.admin',
51         ],
52       ],
53     ];
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'],
58     ];
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'],
63     ];
64     return $form;
65   }
66
67   /**
68    * {@inheritdoc}
69    */
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']);
76     }
77     parent::setConfiguration($configuration);
78     // Force restrictions to be calculated again.
79     $this->restrictions = NULL;
80   }
81
82   /**
83    * {@inheritdoc}
84    */
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));
95   }
96
97   /**
98    * Provides filtering of tag attributes into accepted HTML.
99    *
100    * @param string $text
101    *   The HTML text string to be filtered.
102    *
103    * @return string
104    *   Filtered HTML with attributes filtered according to the settings.
105    */
106   public function filterAttributes($text) {
107     $restrictions = $this->getHTMLRestrictions();
108     $global_allowed_attributes = array_filter($restrictions['allowed']['*']);
109     unset($restrictions['allowed']['*']);
110
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 = [];
120       }
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);
126         }
127         else {
128           $allowed_attributes['exact'][$name] = $this->prepareAttributeValues($values);
129         }
130       }
131       krsort($allowed_attributes['prefix']);
132
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);
137       }
138     }
139
140     if ($this->settings['filter_html_nofollow']) {
141       $links = $html_dom->getElementsByTagName('a');
142       foreach ($links as $link) {
143         $link->setAttribute('rel', 'nofollow');
144       }
145     }
146     $text = Html::serialize($html_dom);
147
148     return trim($text);
149   }
150
151   /**
152    * Filter attributes on an element by name and value according to a whitelist.
153    *
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.
158    */
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;
166       }
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;
174           }
175         }
176       }
177     }
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) {
181       if ($values) {
182         $element->setAttribute($name, implode(' ', $values));
183       }
184       else {
185         $element->removeAttribute($name);
186       }
187     }
188   }
189
190   /**
191    * Helper function to handle prefix matching.
192    *
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.
197    *
198    * @return bool|array
199    */
200   protected function findAllowedValue(array $allowed, $name) {
201     if (isset($allowed['exact'][$name])) {
202       return $allowed['exact'][$name];
203     }
204     // Handle prefix (wildcard) matches.
205     foreach ($allowed['prefix'] as $prefix => $value) {
206       if (strpos($name, $prefix) === 0) {
207         return $value;
208       }
209     }
210     return FALSE;
211   }
212
213   /**
214    * Helper function to prepare attribute values including wildcards.
215    *
216    * Splits the values into two lists, one for values that must match exactly
217    * and the other for values that are wildcard prefixes.
218    *
219    * @param bool|array $attribute_values
220    *   TRUE, FALSE, or an array of allowed values.
221    *
222    * @return bool|array
223    */
224   protected function prepareAttributeValues($attribute_values) {
225     if ($attribute_values === TRUE || $attribute_values === FALSE) {
226       return $attribute_values;
227     }
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;
233       }
234       else {
235         $result['exact'][$name] = $allowed;
236       }
237     }
238     krsort($result['prefix']);
239     return $result;
240   }
241
242   /**
243    * {@inheritdoc}
244    */
245   public function getHTMLRestrictions() {
246     if ($this->restrictions) {
247       return $this->restrictions;
248     }
249
250     // Parse the allowed HTML setting, and gradually make the whitelist more
251     // specific.
252     $restrictions = ['allowed' => []];
253
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;
262
263     foreach ($body_child_nodes as $node) {
264       if ($node->nodeType !== XML_ELEMENT_NODE) {
265         // Skip the empty text nodes inside tags.
266         continue;
267       }
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);
277
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);
281
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 !== '*';
290           });
291
292           if (empty($allowed_attribute_values)) {
293             // If the value is the empty string all values are allowed.
294             $restrictions['allowed'][$tag][$name] = TRUE;
295           }
296           else {
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;
301             }
302           }
303         }
304       }
305       else {
306         // Mark the tag as allowed, but with no attributes allowed.
307         $restrictions['allowed'][$tag] = FALSE;
308       }
309     }
310
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']['*'] = [
324       'style' => FALSE,
325       'on*' => FALSE,
326       'lang' => TRUE,
327       'dir' => ['ltr' => TRUE, 'rtl' => TRUE],
328     ];
329     // Save this calculated result for re-use.
330     $this->restrictions = $restrictions;
331
332     return $restrictions;
333   }
334
335   /**
336    * {@inheritdoc}
337    */
338   public function tips($long = FALSE) {
339     global $base_url;
340
341     if (!($allowed_html = $this->settings['allowed_html'])) {
342       return;
343     }
344     $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $allowed_html]);
345     if (!$long) {
346       return $output;
347     }
348
349     $output = '<p>' . $output . '</p>';
350     if (!$this->settings['filter_html_help']) {
351       return $output;
352     }
353
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>';
356     $tips = [
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>'],
376       'tr' => NULL,
377       'td' => NULL,
378       'th' => NULL,
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 &lt;li&gt; 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 &lt;li&gt; to begin each list item'), '<ul> <li>' . $this->t('First item') . '</li> <li>' . $this->t('Second item') . '</li> </ul>'],
384       'li' => NULL,
385       // Assumes and describes dt and dd.
386       'dl' => [$this->t('Definition lists are similar to other HTML lists. &lt;dl&gt; begins the definition list, &lt;dt&gt; begins the definition term and &lt;dd&gt; 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>'],
387       'dt' => NULL,
388       'dd' => NULL,
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>']
395     ];
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])) {
400         $rows[] = [
401           ['data' => $tips[$tag][0], 'class' => ['description']],
402           // The markup must be escaped because this is the example code for the
403           // user.
404           [
405             'data' => [
406               '#prefix' => '<code>',
407               '#plain_text' => $tips[$tag][1],
408               '#suffix' => '</code>',
409             ],
410             'class' => ['type'],
411           ],
412           // The markup must not be escaped because this is the example output
413           // for the user.
414           ['data' => ['#markup' => $tips[$tag][1]], 'class' => ['get']],
415         ];
416       }
417       else {
418         $rows[] = [
419           ['data' => $this->t('No help provided for tag %tag.', ['%tag' => $tag]), 'class' => ['description'], 'colspan' => 3],
420         ];
421       }
422     }
423     $table = [
424       '#type' => 'table',
425       '#header' => $header,
426       '#rows' => $rows,
427     ];
428     $output .= \Drupal::service('renderer')->render($table);
429
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;amp; for an ampersand &amp; 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>';
432
433     $entities = [
434       [$this->t('Ampersand'), '&amp;'],
435       [$this->t('Greater than'), '&gt;'],
436       [$this->t('Less than'), '&lt;'],
437       [$this->t('Quotation mark'), '&quot;'],
438     ];
439     $header = [$this->t('Character Description'), $this->t('You Type'), $this->t('You Get')];
440     unset($rows);
441     foreach ($entities as $entity) {
442       $rows[] = [
443         ['data' => $entity[0], 'class' => ['description']],
444         // The markup must be escaped because this is the example code for the
445         // user.
446         [
447           'data' => [
448             '#prefix' => '<code>',
449             '#plain_text' => $entity[1],
450             '#suffix' => '</code>',
451           ],
452           'class' => ['type'],
453         ],
454         // The markup must not be escaped because this is the example output
455         // for the user.
456         [
457           'data' => ['#markup' => $entity[1]],
458           'class' => ['get'],
459         ],
460       ];
461     }
462     $table = [
463       '#type' => 'table',
464       '#header' => $header,
465       '#rows' => $rows,
466     ];
467     $output .= \Drupal::service('renderer')->render($table);
468     return $output;
469   }
470
471 }