PageRenderTime 53ms CodeModel.GetById 20ms RepoModel.GetById 0ms app.codeStats 0ms

/core/modules/filter/src/Plugin/Filter/FilterHtml.php

http://github.com/drupal/drupal
PHP | 471 lines | 294 code | 31 blank | 146 comment | 28 complexity | d30e904fc1ee4389f7bfdc6c58696ced MD5 | raw file
Possible License(s): GPL-2.0, LGPL-2.1
  1. <?php
  2. namespace Drupal\filter\Plugin\Filter;
  3. use Drupal\Component\Utility\Xss;
  4. use Drupal\Core\Form\FormStateInterface;
  5. use Drupal\Component\Utility\Html;
  6. use Drupal\filter\FilterProcessResult;
  7. use Drupal\filter\Plugin\FilterBase;
  8. /**
  9. * Provides a filter to limit allowed HTML tags.
  10. *
  11. * The attributes in the annotation show examples of allowing all attributes
  12. * by only having the attribute name, or allowing a fixed list of values, or
  13. * allowing a value with a wildcard prefix.
  14. *
  15. * @Filter(
  16. * id = "filter_html",
  17. * title = @Translation("Limit allowed HTML tags and correct faulty HTML"),
  18. * type = Drupal\filter\Plugin\FilterInterface::TYPE_HTML_RESTRICTOR,
  19. * settings = {
  20. * "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>",
  21. * "filter_html_help" = TRUE,
  22. * "filter_html_nofollow" = FALSE
  23. * },
  24. * weight = -10
  25. * )
  26. */
  27. class FilterHtml extends FilterBase {
  28. /**
  29. * The processed HTML restrictions.
  30. *
  31. * @var array
  32. */
  33. protected $restrictions;
  34. /**
  35. * {@inheritdoc}
  36. */
  37. public function settingsForm(array $form, FormStateInterface $form_state) {
  38. $form['allowed_html'] = [
  39. '#type' => 'textarea',
  40. '#title' => $this->t('Allowed HTML tags'),
  41. '#default_value' => $this->settings['allowed_html'],
  42. '#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.'),
  43. '#attached' => [
  44. 'library' => [
  45. 'filter/drupal.filter.filter_html.admin',
  46. ],
  47. ],
  48. ];
  49. $form['filter_html_help'] = [
  50. '#type' => 'checkbox',
  51. '#title' => $this->t('Display basic HTML help in long filter tips'),
  52. '#default_value' => $this->settings['filter_html_help'],
  53. ];
  54. $form['filter_html_nofollow'] = [
  55. '#type' => 'checkbox',
  56. '#title' => $this->t('Add rel="nofollow" to all links'),
  57. '#default_value' => $this->settings['filter_html_nofollow'],
  58. ];
  59. return $form;
  60. }
  61. /**
  62. * {@inheritdoc}
  63. */
  64. public function setConfiguration(array $configuration) {
  65. if (isset($configuration['settings']['allowed_html'])) {
  66. // The javascript in core/modules/filter/filter.filter_html.admin.js
  67. // removes new lines and double spaces so, for consistency when javascript
  68. // is disabled, remove them.
  69. $configuration['settings']['allowed_html'] = preg_replace('/\s+/', ' ', $configuration['settings']['allowed_html']);
  70. }
  71. parent::setConfiguration($configuration);
  72. // Force restrictions to be calculated again.
  73. $this->restrictions = NULL;
  74. }
  75. /**
  76. * {@inheritdoc}
  77. */
  78. public function process($text, $langcode) {
  79. $restrictions = $this->getHtmlRestrictions();
  80. // Split the work into two parts. For filtering HTML tags out of the content
  81. // we rely on the well-tested Xss::filter() code. Since there is no '*' tag
  82. // that needs to be removed from the list.
  83. unset($restrictions['allowed']['*']);
  84. $text = Xss::filter($text, array_keys($restrictions['allowed']));
  85. // After we've done tag filtering, we do attribute and attribute value
  86. // filtering as the second part.
  87. return new FilterProcessResult($this->filterAttributes($text));
  88. }
  89. /**
  90. * Provides filtering of tag attributes into accepted HTML.
  91. *
  92. * @param string $text
  93. * The HTML text string to be filtered.
  94. *
  95. * @return string
  96. * Filtered HTML with attributes filtered according to the settings.
  97. */
  98. public function filterAttributes($text) {
  99. $restrictions = $this->getHTMLRestrictions();
  100. $global_allowed_attributes = array_filter($restrictions['allowed']['*']);
  101. unset($restrictions['allowed']['*']);
  102. // Apply attribute restrictions to tags.
  103. $html_dom = Html::load($text);
  104. $xpath = new \DOMXPath($html_dom);
  105. foreach ($restrictions['allowed'] as $allowed_tag => $tag_attributes) {
  106. // By default, no attributes are allowed for a tag, but due to the
  107. // globally whitelisted attributes, it is impossible for a tag to actually
  108. // completely disallow attributes.
  109. if ($tag_attributes === FALSE) {
  110. $tag_attributes = [];
  111. }
  112. $allowed_attributes = ['exact' => [], 'prefix' => []];
  113. foreach (($global_allowed_attributes + $tag_attributes) as $name => $values) {
  114. // A trailing * indicates wildcard, but it must have some prefix.
  115. if (substr($name, -1) === '*' && $name[0] !== '*') {
  116. $allowed_attributes['prefix'][str_replace('*', '', $name)] = $this->prepareAttributeValues($values);
  117. }
  118. else {
  119. $allowed_attributes['exact'][$name] = $this->prepareAttributeValues($values);
  120. }
  121. }
  122. krsort($allowed_attributes['prefix']);
  123. // Find all matching elements that have any attributes and filter the
  124. // attributes by name and value.
  125. foreach ($xpath->query('//' . $allowed_tag . '[@*]') as $element) {
  126. $this->filterElementAttributes($element, $allowed_attributes);
  127. }
  128. }
  129. if ($this->settings['filter_html_nofollow']) {
  130. $links = $html_dom->getElementsByTagName('a');
  131. foreach ($links as $link) {
  132. $link->setAttribute('rel', 'nofollow');
  133. }
  134. }
  135. $text = Html::serialize($html_dom);
  136. return trim($text);
  137. }
  138. /**
  139. * Filter attributes on an element by name and value according to a whitelist.
  140. *
  141. * @param \DOMElement $element
  142. * The element to be processed.
  143. * @param array $allowed_attributes
  144. * The attributes whitelist as an array of names and values.
  145. */
  146. protected function filterElementAttributes(\DOMElement $element, array $allowed_attributes) {
  147. $modified_attributes = [];
  148. foreach ($element->attributes as $name => $attribute) {
  149. // Remove attributes not in the whitelist.
  150. $allowed_value = $this->findAllowedValue($allowed_attributes, $name);
  151. if (empty($allowed_value)) {
  152. $modified_attributes[$name] = FALSE;
  153. }
  154. elseif ($allowed_value !== TRUE) {
  155. // Check the attribute values whitelist.
  156. $attribute_values = preg_split('/\s+/', $attribute->value, -1, PREG_SPLIT_NO_EMPTY);
  157. $modified_attributes[$name] = [];
  158. foreach ($attribute_values as $value) {
  159. if ($this->findAllowedValue($allowed_value, $value)) {
  160. $modified_attributes[$name][] = $value;
  161. }
  162. }
  163. }
  164. }
  165. // If the $allowed_value was TRUE for an attribute name, it does not
  166. // appear in this array so the value on the DOM element is left unchanged.
  167. foreach ($modified_attributes as $name => $values) {
  168. if ($values) {
  169. $element->setAttribute($name, implode(' ', $values));
  170. }
  171. else {
  172. $element->removeAttribute($name);
  173. }
  174. }
  175. }
  176. /**
  177. * Helper function to handle prefix matching.
  178. *
  179. * @param array $allowed
  180. * Array of allowed names and prefixes.
  181. * @param string $name
  182. * The name to find or match against a prefix.
  183. *
  184. * @return bool|array
  185. */
  186. protected function findAllowedValue(array $allowed, $name) {
  187. if (isset($allowed['exact'][$name])) {
  188. return $allowed['exact'][$name];
  189. }
  190. // Handle prefix (wildcard) matches.
  191. foreach ($allowed['prefix'] as $prefix => $value) {
  192. if (strpos($name, $prefix) === 0) {
  193. return $value;
  194. }
  195. }
  196. return FALSE;
  197. }
  198. /**
  199. * Helper function to prepare attribute values including wildcards.
  200. *
  201. * Splits the values into two lists, one for values that must match exactly
  202. * and the other for values that are wildcard prefixes.
  203. *
  204. * @param bool|array $attribute_values
  205. * TRUE, FALSE, or an array of allowed values.
  206. *
  207. * @return bool|array
  208. */
  209. protected function prepareAttributeValues($attribute_values) {
  210. if ($attribute_values === TRUE || $attribute_values === FALSE) {
  211. return $attribute_values;
  212. }
  213. $result = ['exact' => [], 'prefix' => []];
  214. foreach ($attribute_values as $name => $allowed) {
  215. // A trailing * indicates wildcard, but it must have some prefix.
  216. if (substr($name, -1) === '*' && $name[0] !== '*') {
  217. $result['prefix'][str_replace('*', '', $name)] = $allowed;
  218. }
  219. else {
  220. $result['exact'][$name] = $allowed;
  221. }
  222. }
  223. krsort($result['prefix']);
  224. return $result;
  225. }
  226. /**
  227. * {@inheritdoc}
  228. */
  229. public function getHTMLRestrictions() {
  230. if ($this->restrictions) {
  231. return $this->restrictions;
  232. }
  233. // Parse the allowed HTML setting, and gradually make the whitelist more
  234. // specific.
  235. $restrictions = ['allowed' => []];
  236. // Make all the tags self-closing, so they will be parsed into direct
  237. // children of the body tag in the DomDocument.
  238. $html = str_replace('>', ' />', $this->settings['allowed_html']);
  239. // Protect any trailing * characters in attribute names, since DomDocument
  240. // strips them as invalid.
  241. $star_protector = '__zqh6vxfbk3cg__';
  242. $html = str_replace('*', $star_protector, $html);
  243. $body_child_nodes = Html::load($html)->getElementsByTagName('body')->item(0)->childNodes;
  244. foreach ($body_child_nodes as $node) {
  245. if ($node->nodeType !== XML_ELEMENT_NODE) {
  246. // Skip the empty text nodes inside tags.
  247. continue;
  248. }
  249. $tag = $node->tagName;
  250. if ($node->hasAttributes()) {
  251. // Mark the tag as allowed, assigning TRUE for each attribute name if
  252. // all values are allowed, or an array of specific allowed values.
  253. $restrictions['allowed'][$tag] = [];
  254. // Iterate over any attributes, and mark them as allowed.
  255. foreach ($node->attributes as $name => $attribute) {
  256. // Put back any trailing * on wildcard attribute name.
  257. $name = str_replace($star_protector, '*', $name);
  258. // Put back any trailing * on wildcard attribute value and parse out
  259. // the allowed attribute values.
  260. $allowed_attribute_values = preg_split('/\s+/', str_replace($star_protector, '*', $attribute->value), -1, PREG_SPLIT_NO_EMPTY);
  261. // Sanitize the attribute value: it lists the allowed attribute values
  262. // but one allowed attribute value that some may be tempted to use
  263. // is specifically nonsensical: the asterisk. A prefix is required for
  264. // allowed attribute values with a wildcard. A wildcard by itself
  265. // would mean whitelisting all possible attribute values. But in that
  266. // case, one would not specify an attribute value at all.
  267. $allowed_attribute_values = array_filter($allowed_attribute_values, function ($value) use ($star_protector) {
  268. return $value !== '*';
  269. });
  270. if (empty($allowed_attribute_values)) {
  271. // If the value is the empty string all values are allowed.
  272. $restrictions['allowed'][$tag][$name] = TRUE;
  273. }
  274. else {
  275. // A non-empty attribute value is assigned, mark each of the
  276. // specified attribute values as allowed.
  277. foreach ($allowed_attribute_values as $value) {
  278. $restrictions['allowed'][$tag][$name][$value] = TRUE;
  279. }
  280. }
  281. }
  282. }
  283. else {
  284. // Mark the tag as allowed, but with no attributes allowed.
  285. $restrictions['allowed'][$tag] = FALSE;
  286. }
  287. }
  288. // The 'style' and 'on*' ('onClick' etc.) attributes are always forbidden,
  289. // and are removed by Xss::filter().
  290. // The 'lang', and 'dir' attributes apply to all elements and are always
  291. // allowed. The value whitelist for the 'dir' attribute is enforced by
  292. // self::filterAttributes(). Note that those two attributes are in the
  293. // short list of globally usable attributes in HTML5. They are always
  294. // allowed since the correct values of lang and dir may only be known to
  295. // the content author. Of the other global attributes, they are not usually
  296. // added by hand to content, and especially the class attribute can have
  297. // undesired visual effects by allowing content authors to apply any
  298. // available style, so specific values should be explicitly whitelisted.
  299. // @see http://www.w3.org/TR/html5/dom.html#global-attributes
  300. $restrictions['allowed']['*'] = [
  301. 'style' => FALSE,
  302. 'on*' => FALSE,
  303. 'lang' => TRUE,
  304. 'dir' => ['ltr' => TRUE, 'rtl' => TRUE],
  305. ];
  306. // Save this calculated result for re-use.
  307. $this->restrictions = $restrictions;
  308. return $restrictions;
  309. }
  310. /**
  311. * {@inheritdoc}
  312. */
  313. public function tips($long = FALSE) {
  314. global $base_url;
  315. if (!($allowed_html = $this->settings['allowed_html'])) {
  316. return;
  317. }
  318. $output = $this->t('Allowed HTML tags: @tags', ['@tags' => $allowed_html]);
  319. if (!$long) {
  320. return $output;
  321. }
  322. $output = '<p>' . $output . '</p>';
  323. if (!$this->settings['filter_html_help']) {
  324. return $output;
  325. }
  326. $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>';
  327. $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>';
  328. $tips = [
  329. '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>'],
  330. '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')],
  331. '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>'],
  332. 'strong' => [$this->t('Strong', [], ['context' => 'Font weight']), '<strong>' . $this->t('Strong', [], ['context' => 'Font weight']) . '</strong>'],
  333. 'em' => [$this->t('Emphasized'), '<em>' . $this->t('Emphasized') . '</em>'],
  334. 'cite' => [$this->t('Cited'), '<cite>' . $this->t('Cited') . '</cite>'],
  335. 'code' => [$this->t('Coded text used to show programming source code'), '<code>' . $this->t('Coded') . '</code>'],
  336. 'b' => [$this->t('Bolded'), '<b>' . $this->t('Bolded') . '</b>'],
  337. 'u' => [$this->t('Underlined'), '<u>' . $this->t('Underlined') . '</u>'],
  338. 'i' => [$this->t('Italicized'), '<i>' . $this->t('Italicized') . '</i>'],
  339. 'sup' => [$this->t('Superscripted'), $this->t('<sup>Super</sup>scripted')],
  340. 'sub' => [$this->t('Subscripted'), $this->t('<sub>Sub</sub>scripted')],
  341. 'pre' => [$this->t('Preformatted'), '<pre>' . $this->t('Preformatted') . '</pre>'],
  342. 'abbr' => [$this->t('Abbreviation'), $this->t('<abbr title="Abbreviation">Abbrev.</abbr>')],
  343. 'acronym' => [$this->t('Acronym'), $this->t('<acronym title="Three-Letter Acronym">TLA</acronym>')],
  344. 'blockquote' => [$this->t('Block quoted'), '<blockquote>' . $this->t('Block quoted') . '</blockquote>'],
  345. 'q' => [$this->t('Quoted inline'), '<q>' . $this->t('Quoted inline') . '</q>'],
  346. // Assumes and describes tr, td, th.
  347. 'table' => [$this->t('Table'), '<table> <tr><th>' . $this->t('Table header') . '</th></tr> <tr><td>' . $this->t('Table cell') . '</td></tr> </table>'],
  348. 'tr' => NULL,
  349. 'td' => NULL,
  350. 'th' => NULL,
  351. 'del' => [$this->t('Deleted'), '<del>' . $this->t('Deleted') . '</del>'],
  352. 'ins' => [$this->t('Inserted'), '<ins>' . $this->t('Inserted') . '</ins>'],
  353. // Assumes and describes li.
  354. '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>'],
  355. '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>'],
  356. 'li' => NULL,
  357. // Assumes and describes dt and dd.
  358. '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>'],
  359. 'dt' => NULL,
  360. 'dd' => NULL,
  361. 'h1' => [$this->t('Heading'), '<h1>' . $this->t('Title') . '</h1>'],
  362. 'h2' => [$this->t('Heading'), '<h2>' . $this->t('Subtitle') . '</h2>'],
  363. 'h3' => [$this->t('Heading'), '<h3>' . $this->t('Subtitle three') . '</h3>'],
  364. 'h4' => [$this->t('Heading'), '<h4>' . $this->t('Subtitle four') . '</h4>'],
  365. 'h5' => [$this->t('Heading'), '<h5>' . $this->t('Subtitle five') . '</h5>'],
  366. 'h6' => [$this->t('Heading'), '<h6>' . $this->t('Subtitle six') . '</h6>'],
  367. ];
  368. $header = [$this->t('Tag Description'), $this->t('You Type'), $this->t('You Get')];
  369. preg_match_all('/<([a-z0-9]+)[^a-z0-9]/i', $allowed_html, $out);
  370. foreach ($out[1] as $tag) {
  371. if (!empty($tips[$tag])) {
  372. $rows[] = [
  373. ['data' => $tips[$tag][0], 'class' => ['description']],
  374. // The markup must be escaped because this is the example code for the
  375. // user.
  376. [
  377. 'data' => [
  378. '#prefix' => '<code>',
  379. '#plain_text' => $tips[$tag][1],
  380. '#suffix' => '</code>',
  381. ],
  382. 'class' => ['type'],
  383. ],
  384. // The markup must not be escaped because this is the example output
  385. // for the user.
  386. ['data' => ['#markup' => $tips[$tag][1]], 'class' => ['get']],
  387. ];
  388. }
  389. else {
  390. $rows[] = [
  391. ['data' => $this->t('No help provided for tag %tag.', ['%tag' => $tag]), 'class' => ['description'], 'colspan' => 3],
  392. ];
  393. }
  394. }
  395. $table = [
  396. '#type' => 'table',
  397. '#header' => $header,
  398. '#rows' => $rows,
  399. ];
  400. $output .= \Drupal::service('renderer')->render($table);
  401. $output .= '<p>' . $this->t('Most unusual characters can be directly entered without any problems.') . '</p>';
  402. $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>';
  403. $entities = [
  404. [$this->t('Ampersand'), '&amp;'],
  405. [$this->t('Greater than'), '&gt;'],
  406. [$this->t('Less than'), '&lt;'],
  407. [$this->t('Quotation mark'), '&quot;'],
  408. ];
  409. $header = [$this->t('Character Description'), $this->t('You Type'), $this->t('You Get')];
  410. unset($rows);
  411. foreach ($entities as $entity) {
  412. $rows[] = [
  413. ['data' => $entity[0], 'class' => ['description']],
  414. // The markup must be escaped because this is the example code for the
  415. // user.
  416. [
  417. 'data' => [
  418. '#prefix' => '<code>',
  419. '#plain_text' => $entity[1],
  420. '#suffix' => '</code>',
  421. ],
  422. 'class' => ['type'],
  423. ],
  424. // The markup must not be escaped because this is the example output
  425. // for the user.
  426. [
  427. 'data' => ['#markup' => $entity[1]],
  428. 'class' => ['get'],
  429. ],
  430. ];
  431. }
  432. $table = [
  433. '#type' => 'table',
  434. '#header' => $header,
  435. '#rows' => $rows,
  436. ];
  437. $output .= \Drupal::service('renderer')->render($table);
  438. return $output;
  439. }
  440. }