Version 1
[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) { return $value !== '*'; });
289
290           if (empty($allowed_attribute_values)) {
291             // If the value is the empty string all values are allowed.
292             $restrictions['allowed'][$tag][$name] = TRUE;
293           }
294           else {
295             // A non-empty attribute value is assigned, mark each of the
296             // specified attribute values as allowed.
297             foreach ($allowed_attribute_values as $value) {
298               $restrictions['allowed'][$tag][$name][$value] = TRUE;
299             }
300           }
301         }
302       }
303       else {
304         // Mark the tag as allowed, but with no attributes allowed.
305         $restrictions['allowed'][$tag] = FALSE;
306       }
307     }
308
309     // The 'style' and 'on*' ('onClick' etc.) attributes are always forbidden,
310     // and are removed by Xss::filter().
311     // The 'lang', and 'dir' attributes apply to all elements and are always
312     // allowed. The value whitelist for the 'dir' attribute is enforced by
313     // self::filterAttributes().  Note that those two attributes are in the
314     // short list of globally usable attributes in HTML5. They are always
315     // allowed since the correct values of lang and dir may only be known to
316     // the content author. Of the other global attributes, they are not usually
317     // added by hand to content, and especially the class attribute can have
318     // undesired visual effects by allowing content authors to apply any
319     // available style, so specific values should be explicitly whitelisted.
320     // @see http://www.w3.org/TR/html5/dom.html#global-attributes
321     $restrictions['allowed']['*'] = [
322       'style' => FALSE,
323       'on*' => FALSE,
324       'lang' => TRUE,
325       'dir' => ['ltr' => TRUE, 'rtl' => TRUE],
326     ];
327     // Save this calculated result for re-use.
328     $this->restrictions = $restrictions;
329
330     return $restrictions;
331   }
332
333   /**
334    * {@inheritdoc}
335    */
336   public function tips($long = FALSE) {
337     global $base_url;
338
339     if (!($allowed_html = $this->settings['allowed_html'])) {
340       return;
341     }
342     $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $allowed_html]);
343     if (!$long) {
344       return $output;
345     }
346
347     $output = '<p>' . $output . '</p>';
348     if (!$this->settings['filter_html_help']) {
349       return $output;
350     }
351
352     $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>';
353     $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>';
354     $tips = [
355       '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>'],
356       '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')],
357       '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>'],
358       'strong' => [$this->t('Strong', [], ['context' => 'Font weight']), '<strong>' . $this->t('Strong', [], ['context' => 'Font weight']) . '</strong>'],
359       'em' => [$this->t('Emphasized'), '<em>' . $this->t('Emphasized') . '</em>'],
360       'cite' => [$this->t('Cited'), '<cite>' . $this->t('Cited') . '</cite>'],
361       'code' => [$this->t('Coded text used to show programming source code'), '<code>' . $this->t('Coded') . '</code>'],
362       'b' => [$this->t('Bolded'), '<b>' . $this->t('Bolded') . '</b>'],
363       'u' => [$this->t('Underlined'), '<u>' . $this->t('Underlined') . '</u>'],
364       'i' => [$this->t('Italicized'), '<i>' . $this->t('Italicized') . '</i>'],
365       'sup' => [$this->t('Superscripted'), $this->t('<sup>Super</sup>scripted')],
366       'sub' => [$this->t('Subscripted'), $this->t('<sub>Sub</sub>scripted')],
367       'pre' => [$this->t('Preformatted'), '<pre>' . $this->t('Preformatted') . '</pre>'],
368       'abbr' => [$this->t('Abbreviation'), $this->t('<abbr title="Abbreviation">Abbrev.</abbr>')],
369       'acronym' => [$this->t('Acronym'), $this->t('<acronym title="Three-Letter Acronym">TLA</acronym>')],
370       'blockquote' => [$this->t('Block quoted'), '<blockquote>' . $this->t('Block quoted') . '</blockquote>'],
371       'q' => [$this->t('Quoted inline'), '<q>' . $this->t('Quoted inline') . '</q>'],
372       // Assumes and describes tr, td, th.
373       'table' => [$this->t('Table'), '<table> <tr><th>' . $this->t('Table header') . '</th></tr> <tr><td>' . $this->t('Table cell') . '</td></tr> </table>'],
374       'tr' => NULL, 'td' => NULL, 'th' => NULL,
375       'del' => [$this->t('Deleted'), '<del>' . $this->t('Deleted') . '</del>'],
376       'ins' => [$this->t('Inserted'), '<ins>' . $this->t('Inserted') . '</ins>'],
377        // Assumes and describes li.
378       '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>'],
379       '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>'],
380       'li' => NULL,
381       // Assumes and describes dt and dd.
382       '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>'],
383       'dt' => NULL, 'dd' => NULL,
384       'h1' => [$this->t('Heading'), '<h1>' . $this->t('Title') . '</h1>'],
385       'h2' => [$this->t('Heading'), '<h2>' . $this->t('Subtitle') . '</h2>'],
386       'h3' => [$this->t('Heading'), '<h3>' . $this->t('Subtitle three') . '</h3>'],
387       'h4' => [$this->t('Heading'), '<h4>' . $this->t('Subtitle four') . '</h4>'],
388       'h5' => [$this->t('Heading'), '<h5>' . $this->t('Subtitle five') . '</h5>'],
389       'h6' => [$this->t('Heading'), '<h6>' . $this->t('Subtitle six') . '</h6>']
390     ];
391     $header = [$this->t('Tag Description'), $this->t('You Type'), $this->t('You Get')];
392     preg_match_all('/<([a-z0-9]+)[^a-z0-9]/i', $allowed_html, $out);
393     foreach ($out[1] as $tag) {
394       if (!empty($tips[$tag])) {
395         $rows[] = [
396           ['data' => $tips[$tag][0], 'class' => ['description']],
397           // The markup must be escaped because this is the example code for the
398           // user.
399           ['data' =>
400             [
401               '#prefix' => '<code>',
402               '#plain_text' => $tips[$tag][1],
403               '#suffix' => '</code>'
404             ],
405             'class' => ['type']],
406           // The markup must not be escaped because this is the example output
407           // for the user.
408           ['data' =>
409             ['#markup' => $tips[$tag][1]],
410             'class' => ['get'],
411           ],
412         ];
413       }
414       else {
415         $rows[] = [
416           ['data' => $this->t('No help provided for tag %tag.', ['%tag' => $tag]), 'class' => ['description'], 'colspan' => 3],
417         ];
418       }
419     }
420     $table = [
421       '#type' => 'table',
422       '#header' => $header,
423       '#rows' => $rows,
424     ];
425     $output .= drupal_render($table);
426
427     $output .= '<p>' . $this->t('Most unusual characters can be directly entered without any problems.') . '</p>';
428     $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>';
429
430     $entities = [
431       [$this->t('Ampersand'), '&amp;'],
432       [$this->t('Greater than'), '&gt;'],
433       [$this->t('Less than'), '&lt;'],
434       [$this->t('Quotation mark'), '&quot;'],
435     ];
436     $header = [$this->t('Character Description'), $this->t('You Type'), $this->t('You Get')];
437     unset($rows);
438     foreach ($entities as $entity) {
439       $rows[] = [
440         ['data' => $entity[0], 'class' => ['description']],
441         // The markup must be escaped because this is the example code for the
442         // user.
443         [
444           'data' => [
445             '#prefix' => '<code>',
446             '#plain_text' => $entity[1],
447             '#suffix' => '</code>',
448           ],
449           'class' => ['type'],
450         ],
451         // The markup must not be escaped because this is the example output
452         // for the user.
453         [
454           'data' => ['#markup' => $entity[1]],
455           'class' => ['get'],
456         ],
457       ];
458     }
459     $table = [
460       '#type' => 'table',
461       '#header' => $header,
462       '#rows' => $rows,
463     ];
464     $output .= drupal_render($table);
465     return $output;
466   }
467
468 }