RoundCube Webmail
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

869 lines
34 KiB

10 years ago
11 years ago
  1. <?php
  2. /**
  3. +-----------------------------------------------------------------------+
  4. | This file is part of the Roundcube Webmail client |
  5. | Copyright (C) 2008-2012, The Roundcube Dev Team |
  6. | |
  7. | Licensed under the GNU General Public License version 3 or |
  8. | any later version with exceptions for skins & plugins. |
  9. | See the README file for a full license statement. |
  10. | |
  11. | PURPOSE: |
  12. | Utility class providing HTML sanityzer (based on Washtml class) |
  13. +-----------------------------------------------------------------------+
  14. | Author: Thomas Bruederli <roundcube@gmail.com> |
  15. | Author: Aleksander Machniak <alec@alec.pl> |
  16. | Author: Frederic Motte <fmotte@ubixis.com> |
  17. +-----------------------------------------------------------------------+
  18. */
  19. /*
  20. * Washtml, a HTML sanityzer.
  21. *
  22. * Copyright (c) 2007 Frederic Motte <fmotte@ubixis.com>
  23. * All rights reserved.
  24. *
  25. * Redistribution and use in source and binary forms, with or without
  26. * modification, are permitted provided that the following conditions
  27. * are met:
  28. * 1. Redistributions of source code must retain the above copyright
  29. * notice, this list of conditions and the following disclaimer.
  30. * 2. Redistributions in binary form must reproduce the above copyright
  31. * notice, this list of conditions and the following disclaimer in the
  32. * documentation and/or other materials provided with the distribution.
  33. *
  34. * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
  35. * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
  36. * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
  37. * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
  38. * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
  39. * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  40. * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  41. * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  42. * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
  43. * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  44. *
  45. * OVERVIEW:
  46. *
  47. * Wahstml take an untrusted HTML and return a safe html string.
  48. *
  49. * SYNOPSIS:
  50. *
  51. * $washer = new washtml($config);
  52. * $washer->wash($html);
  53. * It return a sanityzed string of the $html parameter without html and head tags.
  54. * $html is a string containing the html code to wash.
  55. * $config is an array containing options:
  56. * $config['allow_remote'] is a boolean to allow link to remote images.
  57. * $config['blocked_src'] string with image-src to be used for blocked remote images
  58. * $config['show_washed'] is a boolean to include washed out attributes as x-washed
  59. * $config['cid_map'] is an array where cid urls index urls to replace them.
  60. * $config['charset'] is a string containing the charset of the HTML document if it is not defined in it.
  61. * $washer->extlinks is a reference to a boolean that is set to true if remote images were removed. (FE: show remote images link)
  62. *
  63. * INTERNALS:
  64. *
  65. * Only tags and attributes in the static lists $html_elements and $html_attributes
  66. * are kept, inline styles are also filtered: all style identifiers matching
  67. * /[a-z\-]/i are allowed. Values matching colors, sizes, /[a-z\-]/i and safe
  68. * urls if allowed and cid urls if mapped are kept.
  69. *
  70. * Roundcube Changes:
  71. * - added $block_elements
  72. * - changed $ignore_elements behaviour
  73. * - added RFC2397 support
  74. * - base URL support
  75. * - invalid HTML comments removal before parsing
  76. * - "fixing" unitless CSS values for XHTML output
  77. * - SVG and MathML support
  78. */
  79. /**
  80. * Utility class providing HTML sanityzer
  81. *
  82. * @package Framework
  83. * @subpackage Utils
  84. */
  85. class rcube_washtml
  86. {
  87. /* Allowed HTML elements (default) */
  88. static $html_elements = array('a', 'abbr', 'acronym', 'address', 'area', 'b',
  89. 'basefont', 'bdo', 'big', 'blockquote', 'br', 'caption', 'center',
  90. 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl',
  91. 'dt', 'em', 'fieldset', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i',
  92. 'ins', 'label', 'legend', 'li', 'map', 'menu', 'nobr', 'ol', 'p', 'pre', 'q',
  93. 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
  94. 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', 'img',
  95. 'video', 'source',
  96. // form elements
  97. 'button', 'input', 'textarea', 'select', 'option', 'optgroup',
  98. // SVG
  99. 'svg', 'altglyph', 'altglyphdef', 'altglyphitem', 'animate',
  100. 'animatecolor', 'animatetransform', 'circle', 'clippath', 'defs', 'desc',
  101. 'ellipse', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line',
  102. 'lineargradient', 'marker', 'mask', 'mpath', 'path', 'pattern',
  103. 'polygon', 'polyline', 'radialgradient', 'rect', 'set', 'stop', 'switch', 'symbol',
  104. 'text', 'textpath', 'tref', 'tspan', 'use', 'view', 'vkern', 'filter',
  105. // SVG Filters
  106. 'feblend', 'fecolormatrix', 'fecomponenttransfer', 'fecomposite',
  107. 'feconvolvematrix', 'fediffuselighting', 'fedisplacementmap',
  108. 'feflood', 'fefunca', 'fefuncb', 'fefuncg', 'fefuncr', 'fegaussianblur',
  109. 'feimage', 'femerge', 'femergenode', 'femorphology', 'feoffset',
  110. 'fespecularlighting', 'fetile', 'feturbulence',
  111. // MathML
  112. 'math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr',
  113. 'mmuliscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow',
  114. 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd',
  115. 'mtext', 'mtr', 'munder', 'munderover', 'maligngroup', 'malignmark',
  116. 'mprescripts', 'semantics', 'annotation', 'annotation-xml', 'none',
  117. 'infinity', 'matrix', 'matrixrow', 'ci', 'cn', 'sep', 'apply',
  118. 'plus', 'minus', 'eq', 'power', 'times', 'divide', 'csymbol', 'root',
  119. 'bvar', 'lowlimit', 'uplimit',
  120. );
  121. /* Ignore these HTML tags and their content */
  122. static $ignore_elements = array('script', 'applet', 'embed', 'object', 'style');
  123. /* Allowed HTML attributes */
  124. static $html_attribs = array('name', 'class', 'title', 'alt', 'width', 'height',
  125. 'align', 'nowrap', 'col', 'row', 'id', 'rowspan', 'colspan', 'cellspacing',
  126. 'cellpadding', 'valign', 'bgcolor', 'color', 'border', 'bordercolorlight',
  127. 'bordercolordark', 'face', 'marginwidth', 'marginheight', 'axis', 'border',
  128. 'abbr', 'char', 'charoff', 'clear', 'compact', 'coords', 'vspace', 'hspace',
  129. 'cellborder', 'size', 'lang', 'dir', 'usemap', 'shape', 'media',
  130. 'background', 'src', 'poster', 'href',
  131. // attributes of form elements
  132. 'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value',
  133. // SVG
  134. 'accent-height', 'accumulate', 'additive', 'alignment-baseline', 'alphabetic',
  135. 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseprofile',
  136. 'baseline-shift', 'begin', 'bias', 'by', 'clip', 'clip-path', 'clip-rule',
  137. 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile',
  138. 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction',
  139. 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity',
  140. 'fill-rule', 'filter', 'flood-color', 'flood-opacity', 'font-family', 'font-size',
  141. 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'from',
  142. 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform',
  143. 'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints',
  144. 'keysplines', 'keytimes', 'lengthadjust', 'letter-spacing', 'kernelmatrix',
  145. 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid',
  146. 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits',
  147. 'maskunits', 'max', 'mask', 'mode', 'min', 'numoctaves', 'offset', 'operator',
  148. 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order',
  149. 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits',
  150. 'points', 'preservealpha', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount',
  151. 'repeatdur', 'restart', 'rotate', 'scale', 'seed', 'shape-rendering', 'show', 'specularconstant',
  152. 'specularexponent', 'spreadmethod', 'stddeviation', 'stitchtiles', 'stop-color',
  153. 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap',
  154. 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width',
  155. 'surfacescale', 'targetx', 'targety', 'transform', 'text-anchor', 'text-decoration',
  156. 'text-rendering', 'textlength', 'to', 'u1', 'u2', 'unicode', 'values', 'viewbox',
  157. 'visibility', 'vert-adv-y', 'version', 'vert-origin-x', 'vert-origin-y', 'word-spacing',
  158. 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2',
  159. 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan',
  160. // MathML
  161. 'accent', 'accentunder', 'bevelled', 'close', 'columnalign', 'columnlines',
  162. 'columnspan', 'denomalign', 'depth', 'display', 'displaystyle', 'encoding', 'fence',
  163. 'frame', 'largeop', 'length', 'linethickness', 'lspace', 'lquote',
  164. 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize',
  165. 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign',
  166. 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel',
  167. 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator',
  168. 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset',
  169. 'fontsize', 'fontweight', 'fontstyle', 'fontfamily', 'groupalign', 'edge', 'side',
  170. );
  171. /* Elements which could be empty and be returned in short form (<tag />) */
  172. static $void_elements = array('area', 'base', 'br', 'col', 'command', 'embed', 'hr',
  173. 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr',
  174. // MathML
  175. 'sep', 'infinity', 'in', 'plus', 'eq', 'power', 'times', 'divide', 'root',
  176. 'maligngroup', 'none', 'mprescripts',
  177. );
  178. /* State for linked objects in HTML */
  179. public $extlinks = false;
  180. /* Current settings */
  181. private $config = array();
  182. /* Registered callback functions for tags */
  183. private $handlers = array();
  184. /* Allowed HTML elements */
  185. private $_html_elements = array();
  186. /* Ignore these HTML tags but process their content */
  187. private $_ignore_elements = array();
  188. /* Elements which could be empty and be returned in short form (<tag />) */
  189. private $_void_elements = array();
  190. /* Allowed HTML attributes */
  191. private $_html_attribs = array();
  192. /* Max nesting level */
  193. private $max_nesting_level;
  194. private $is_xml = false;
  195. /**
  196. * Class constructor
  197. */
  198. public function __construct($p = array())
  199. {
  200. $this->_html_elements = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements);
  201. $this->_html_attribs = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
  202. $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
  203. $this->_void_elements = array_flip((array)$p['void_elements']) + array_flip(self::$void_elements);
  204. unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['void_elements']);
  205. $this->config = $p + array('show_washed' => true, 'allow_remote' => false, 'cid_map' => array());
  206. }
  207. /**
  208. * Register a callback function for a certain tag
  209. */
  210. public function add_callback($tagName, $callback)
  211. {
  212. $this->handlers[$tagName] = $callback;
  213. }
  214. /**
  215. * Check CSS style
  216. */
  217. private function wash_style($style)
  218. {
  219. $result = array();
  220. // Remove unwanted white-space characters so regular expressions below work better
  221. $style = preg_replace('/[\n\r\s\t]+/', ' ', $style);
  222. // Decode insecure character sequences
  223. $style = rcube_utils::xss_entity_decode($style);
  224. foreach (explode(';', $style) as $declaration) {
  225. if (preg_match('/^\s*([a-z\\\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
  226. $cssid = $match[1];
  227. $str = $match[2];
  228. $value = '';
  229. foreach ($this->explode_style($str) as $val) {
  230. if (preg_match('/^url\(/i', $val)) {
  231. if (preg_match('/^url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $val, $match)) {
  232. if ($url = $this->wash_uri($match[1])) {
  233. $value .= ' url(' . htmlspecialchars($url, ENT_QUOTES) . ')';
  234. }
  235. }
  236. }
  237. else if (!preg_match('/^(behavior|expression)/i', $val)) {
  238. // Set position:fixed to position:absolute for security (#5264)
  239. if (!strcasecmp($cssid, 'position') && !strcasecmp($val, 'fixed')) {
  240. $val = 'absolute';
  241. }
  242. // whitelist ?
  243. $value .= ' ' . $val;
  244. // #1488535: Fix size units, so width:800 would be changed to width:800px
  245. if (preg_match('/^(left|right|top|bottom|width|height)/i', $cssid)
  246. && preg_match('/^[0-9]+$/', $val)
  247. ) {
  248. $value .= 'px';
  249. }
  250. }
  251. }
  252. if (isset($value[0])) {
  253. $result[] = $cssid . ':' . $value;
  254. }
  255. }
  256. }
  257. return implode('; ', $result);
  258. }
  259. /**
  260. * Take a node and return allowed attributes and check values
  261. */
  262. private function wash_attribs($node)
  263. {
  264. $result = '';
  265. $washed = array();
  266. foreach ($node->attributes as $name => $attr) {
  267. $key = strtolower($name);
  268. $value = $attr->nodeValue;
  269. if ($key == 'style' && ($style = $this->wash_style($value))) {
  270. // replace double quotes to prevent syntax error and XSS issues (#1490227)
  271. $result .= ' style="' . str_replace('"', '&quot;', $style) . '"';
  272. }
  273. else if (isset($this->_html_attribs[$key])) {
  274. $value = trim($value);
  275. $out = null;
  276. // in SVG to/from attribs may contain anything, including URIs
  277. if ($key == 'to' || $key == 'from') {
  278. $key = strtolower($node->getAttribute('attributeName'));
  279. if ($key && !isset($this->_html_attribs[$key])) {
  280. $key = null;
  281. }
  282. }
  283. if ($this->is_image_attribute($node->nodeName, $key)) {
  284. $out = $this->wash_uri($value, true);
  285. }
  286. else if ($this->is_link_attribute($node->nodeName, $key)) {
  287. if (!preg_match('!^(javascript|vbscript|data:)!i', $value)
  288. && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value)
  289. ) {
  290. $out = $value;
  291. }
  292. }
  293. else if ($this->is_funciri_attribute($node->nodeName, $key)) {
  294. if (preg_match('/^[a-z:]*url\(/i', $val)) {
  295. if (preg_match('/^([a-z:]*url)\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $value, $match)) {
  296. if ($url = $this->wash_uri($match[2])) {
  297. $result .= ' ' . $attr->nodeName . '="' . $match[1] . '(' . htmlspecialchars($url, ENT_QUOTES) . ')'
  298. . substr($val, strlen($match[0])) . '"';
  299. continue;
  300. }
  301. }
  302. else {
  303. $out = $value;
  304. }
  305. }
  306. else {
  307. $out = $value;
  308. }
  309. }
  310. else if ($key) {
  311. $out = $value;
  312. }
  313. if ($out !== null && $out !== '') {
  314. $result .= ' ' . $attr->nodeName . '="' . htmlspecialchars($out, ENT_QUOTES) . '"';
  315. }
  316. else if ($value) {
  317. $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
  318. }
  319. }
  320. else {
  321. $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
  322. }
  323. }
  324. if (!empty($washed) && $this->config['show_washed']) {
  325. $result .= ' x-washed="' . implode(' ', $washed) . '"';
  326. }
  327. return $result;
  328. }
  329. /**
  330. * Wash URI value
  331. */
  332. private function wash_uri($uri, $blocked_source = false)
  333. {
  334. if (($src = $this->config['cid_map'][$uri])
  335. || ($src = $this->config['cid_map'][$this->config['base_url'].$uri])
  336. ) {
  337. return $src;
  338. }
  339. // allow url(#id) used in SVG
  340. if ($uri[0] == '#') {
  341. return $uri;
  342. }
  343. if (preg_match('/^(http|https|ftp):.+/i', $uri)) {
  344. if ($this->config['allow_remote']) {
  345. return $uri;
  346. }
  347. $this->extlinks = true;
  348. if ($blocked_source && $this->config['blocked_src']) {
  349. return $this->config['blocked_src'];
  350. }
  351. }
  352. else if (preg_match('/^data:image\/([^,]+),(.+)$/i', $uri, $matches)) { // RFC2397
  353. // svg images can be insecure, we'll sanitize them
  354. if (stripos($matches[1], 'svg') !== false) {
  355. $svg = $matches[2];
  356. if (stripos($matches[1], ';base64') !== false) {
  357. $svg = base64_decode($svg);
  358. $type = $matches[1];
  359. }
  360. else {
  361. $type = $matches[1] . ';base64';
  362. }
  363. $washer = new self($this->config);
  364. $svg = $washer->wash($svg);
  365. // Invalid svg content
  366. if (empty($svg)) {
  367. return null;
  368. }
  369. return 'data:image/' . $type . ',' . base64_encode($svg);
  370. }
  371. return $uri;
  372. }
  373. }
  374. /**
  375. * Check it the tag/attribute may contain an URI
  376. */
  377. private function is_link_attribute($tag, $attr)
  378. {
  379. return $attr === 'href';
  380. }
  381. /**
  382. * Check it the tag/attribute may contain an image URI
  383. */
  384. private function is_image_attribute($tag, $attr)
  385. {
  386. return $attr == 'background'
  387. || $attr == 'color-profile' // SVG
  388. || ($attr == 'poster' && $tag == 'video')
  389. || ($attr == 'src' && preg_match('/^(img|image|source|input|video|audio)$/i', $tag))
  390. || ($tag == 'use' && $attr == 'href') // SVG
  391. || ($tag == 'image' && $attr == 'href'); // SVG
  392. }
  393. /**
  394. * Check it the tag/attribute may contain a FUNCIRI value
  395. */
  396. private function is_funciri_attribute($tag, $attr)
  397. {
  398. return in_array($attr, array('fill', 'filter', 'stroke', 'marker-start',
  399. 'marker-end', 'marker-mid', 'clip-path', 'mask', 'cursor'));
  400. }
  401. /**
  402. * Check if a specified element has an attribute with specified value.
  403. * Do it in case-insensitive manner.
  404. *
  405. * @param DOMElement $node The element
  406. * @param string $attr_name The attribute name
  407. * @param string $attr_value The attribute value to find
  408. *
  409. * @return bool True if the specified attribute exists and has the expected value
  410. */
  411. private static function attribute_value($node, $attr_name, $attr_value)
  412. {
  413. $attr_name = strtolower($attr_name);
  414. foreach ($node->attributes as $name => $attr) {
  415. if (strtolower($name) === $attr_name) {
  416. if (strtolower($attr_value) === strtolower($attr->nodeValue)) {
  417. return true;
  418. }
  419. }
  420. }
  421. return false;
  422. }
  423. /**
  424. * The main loop that recurse on a node tree.
  425. * It output only allowed tags with allowed attributes and allowed inline styles
  426. *
  427. * @param DOMNode $node HTML element
  428. * @param int $level Recurrence level (safe initial value found empirically)
  429. */
  430. private function dumpHtml($node, $level = 20)
  431. {
  432. if (!$node->hasChildNodes()) {
  433. return '';
  434. }
  435. $level++;
  436. if ($this->max_nesting_level > 0 && $level == $this->max_nesting_level - 1) {
  437. // log error message once
  438. if (!$this->max_nesting_level_error) {
  439. $this->max_nesting_level_error = true;
  440. rcube::raise_error(array('code' => 500, 'type' => 'php',
  441. 'line' => __LINE__, 'file' => __FILE__,
  442. 'message' => "Maximum nesting level exceeded (xdebug.max_nesting_level={$this->max_nesting_level})"),
  443. true, false);
  444. }
  445. return '<!-- ignored -->';
  446. }
  447. $node = $node->firstChild;
  448. $dump = '';
  449. do {
  450. switch ($node->nodeType) {
  451. case XML_ELEMENT_NODE: //Check element
  452. $tagName = strtolower($node->nodeName);
  453. if (in_array($tagName, array('animate', 'animatecolor', 'set', 'animatetransform'))
  454. && self::attribute_value($node, 'attributename', 'href')
  455. ) {
  456. // Insecure svg tags
  457. $dump .= "<!-- $tagName blocked -->";
  458. break;
  459. }
  460. if ($callback = $this->handlers[$tagName]) {
  461. $dump .= call_user_func($callback, $tagName,
  462. $this->wash_attribs($node), $this->dumpHtml($node, $level), $this);
  463. }
  464. else if (isset($this->_html_elements[$tagName])) {
  465. $content = $this->dumpHtml($node, $level);
  466. $dump .= '<' . $node->nodeName;
  467. if ($tagName == 'svg') {
  468. $xpath = new DOMXPath($node->ownerDocument);
  469. foreach ($xpath->query('namespace::*') as $ns) {
  470. if ($ns->nodeName != 'xmlns:xml') {
  471. $dump .= sprintf(' %s="%s"',
  472. $ns->nodeName,
  473. htmlspecialchars($ns->nodeValue, ENT_QUOTES, $this->config['charset'])
  474. );
  475. }
  476. }
  477. }
  478. else if ($tagName == 'textarea' && strpos($content, '<') !== false) {
  479. $content = htmlspecialchars($content, ENT_QUOTES);
  480. }
  481. $dump .= $this->wash_attribs($node);
  482. if ($content === '' && ($this->is_xml || isset($this->_void_elements[$tagName]))) {
  483. $dump .= ' />';
  484. }
  485. else {
  486. $dump .= '>' . $content . '</' . $node->nodeName . '>';
  487. }
  488. }
  489. else if (isset($this->_ignore_elements[$tagName])) {
  490. $dump .= '<!-- ' . htmlspecialchars($node->nodeName, ENT_QUOTES) . ' not allowed -->';
  491. }
  492. else {
  493. $dump .= '<!-- ' . htmlspecialchars($node->nodeName, ENT_QUOTES) . ' ignored -->';
  494. $dump .= $this->dumpHtml($node, $level); // ignore tags not its content
  495. }
  496. break;
  497. case XML_CDATA_SECTION_NODE:
  498. case XML_TEXT_NODE:
  499. $dump .= htmlspecialchars($node->nodeValue);
  500. break;
  501. case XML_HTML_DOCUMENT_NODE:
  502. $dump .= $this->dumpHtml($node, $level);
  503. break;
  504. }
  505. }
  506. while($node = $node->nextSibling);
  507. return $dump;
  508. }
  509. /**
  510. * Main function, give it untrusted HTML, tell it if you allow loading
  511. * remote images and give it a map to convert "cid:" urls.
  512. */
  513. public function wash($html)
  514. {
  515. // Charset seems to be ignored (probably if defined in the HTML document)
  516. $node = new DOMDocument('1.0', $this->config['charset']);
  517. $this->extlinks = false;
  518. $html = $this->cleanup($html);
  519. // Find base URL for images
  520. if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches)) {
  521. $this->config['base_url'] = $matches[1];
  522. }
  523. else {
  524. $this->config['base_url'] = '';
  525. }
  526. // Detect max nesting level (for dumpHTML) (#1489110)
  527. $this->max_nesting_level = (int) @ini_get('xdebug.max_nesting_level');
  528. // SVG need to be parsed as XML
  529. $this->is_xml = !preg_match('/<(html|head|body)/i', $html) && stripos($html, '<svg') !== false;
  530. $method = $this->is_xml ? 'loadXML' : 'loadHTML';
  531. $options = 0;
  532. // Use optimizations if supported
  533. if (PHP_VERSION_ID >= 50400) {
  534. $options = LIBXML_PARSEHUGE | LIBXML_COMPACT | LIBXML_NONET;
  535. @$node->{$method}($html, $options);
  536. }
  537. else {
  538. @$node->{$method}($html);
  539. }
  540. return $this->dumpHtml($node);
  541. }
  542. /**
  543. * Getter for config parameters
  544. */
  545. public function get_config($prop)
  546. {
  547. return $this->config[$prop];
  548. }
  549. /**
  550. * Clean HTML input
  551. */
  552. private function cleanup($html)
  553. {
  554. $html = trim($html);
  555. // special replacements (not properly handled by washtml class)
  556. $html_search = array(
  557. // space(s) between <NOBR>
  558. '/(<\/nobr>)(\s+)(<nobr>)/i',
  559. // PHP bug #32547 workaround: remove title tag
  560. '/<title[^>]*>[^<]*<\/title>/i',
  561. // remove <!doctype> before BOM (#1490291)
  562. '/<\!doctype[^>]+>[^<]*/im',
  563. // byte-order mark (only outlook?)
  564. '/^(\0\0\xFE\xFF|\xFF\xFE\0\0|\xFE\xFF|\xFF\xFE|\xEF\xBB\xBF)/',
  565. // washtml/DOMDocument cannot handle xml namespaces
  566. '/<html\s[^>]+>/i',
  567. );
  568. $html_replace = array(
  569. '\\1'.' &nbsp; '.'\\3',
  570. '',
  571. '',
  572. '',
  573. '<html>',
  574. );
  575. $html = preg_replace($html_search, $html_replace, trim($html));
  576. // Replace all of those weird MS Word quotes and other high characters
  577. $badwordchars = array(
  578. "\xe2\x80\x98", // left single quote
  579. "\xe2\x80\x99", // right single quote
  580. "\xe2\x80\x9c", // left double quote
  581. "\xe2\x80\x9d", // right double quote
  582. "\xe2\x80\x94", // em dash
  583. "\xe2\x80\xa6" // elipses
  584. );
  585. $fixedwordchars = array(
  586. "'",
  587. "'",
  588. '"',
  589. '"',
  590. '&mdash;',
  591. '...'
  592. );
  593. $html = str_replace($badwordchars, $fixedwordchars, $html);
  594. // PCRE errors handling (#1486856), should we use something like for every preg_* use?
  595. if ($html === null && ($preg_error = preg_last_error()) != PREG_NO_ERROR) {
  596. $errstr = "Could not clean up HTML message! PCRE Error: $preg_error.";
  597. if ($preg_error == PREG_BACKTRACK_LIMIT_ERROR) {
  598. $errstr .= " Consider raising pcre.backtrack_limit!";
  599. }
  600. if ($preg_error == PREG_RECURSION_LIMIT_ERROR) {
  601. $errstr .= " Consider raising pcre.recursion_limit!";
  602. }
  603. rcube::raise_error(array('code' => 620, 'type' => 'php',
  604. 'line' => __LINE__, 'file' => __FILE__,
  605. 'message' => $errstr), true, false);
  606. return '';
  607. }
  608. // FIXME: HTML comments handling could be better. The code below can break comments (#6464),
  609. // we should probably do not modify content inside comments at all.
  610. // fix (unknown/malformed) HTML tags before "wash"
  611. $html = preg_replace_callback('/(<(?!\!)[\/]*)([^\s>]+)([^>]*)/', array($this, 'html_tag_callback'), $html);
  612. // Remove invalid HTML comments (#1487759)
  613. // Note: We don't want to remove valid comments, conditional comments
  614. // and MSOutlook comments (<!-->)
  615. $html = preg_replace('/<!--[a-zA-Z0-9]+>/', '', $html);
  616. // fix broken nested lists
  617. self::fix_broken_lists($html);
  618. // turn relative into absolute urls
  619. $html = self::resolve_base($html);
  620. return $html;
  621. }
  622. /**
  623. * Callback function for HTML tags fixing
  624. */
  625. public static function html_tag_callback($matches)
  626. {
  627. // It might be an ending of a comment, ignore (#6464)
  628. if (substr($matches[3], -2) == '--') {
  629. $matches[0] = '';
  630. return implode('', $matches);
  631. }
  632. $tagname = $matches[2];
  633. $tagname = preg_replace(array(
  634. '/:.*$/', // Microsoft's Smart Tags <st1:xxxx>
  635. '/[^a-z0-9_\[\]\!?-]/i', // forbidden characters
  636. ), '', $tagname);
  637. // fix invalid closing tags - remove any attributes (#1489446)
  638. if ($matches[1] == '</') {
  639. $matches[3] = '';
  640. }
  641. return $matches[1] . $tagname . $matches[3];
  642. }
  643. /**
  644. * Convert all relative URLs according to a <base> in HTML
  645. */
  646. public static function resolve_base($body)
  647. {
  648. // check for <base href=...>
  649. if (preg_match('!(<base.*href=["\']?)([hftps]{3,5}://[a-z0-9/.%-]+)!i', $body, $regs)) {
  650. $replacer = new rcube_base_replacer($regs[2]);
  651. $body = $replacer->replace($body);
  652. }
  653. return $body;
  654. }
  655. /**
  656. * Fix broken nested lists, they are not handled properly by DOMDocument (#1488768)
  657. */
  658. public static function fix_broken_lists(&$html)
  659. {
  660. // do two rounds, one for <ol>, one for <ul>
  661. foreach (array('ol', 'ul') as $tag) {
  662. $pos = 0;
  663. while (($pos = stripos($html, '<' . $tag, $pos)) !== false) {
  664. $pos++;
  665. // make sure this is an ol/ul tag
  666. if (!in_array($html[$pos+2], array(' ', '>'))) {
  667. continue;
  668. }
  669. $p = $pos;
  670. $in_li = false;
  671. $li_pos = 0;
  672. while (($p = strpos($html, '<', $p)) !== false) {
  673. $tt = strtolower(substr($html, $p, 4));
  674. // li open tag
  675. if ($tt == '<li>' || $tt == '<li ') {
  676. $in_li = true;
  677. $p += 4;
  678. }
  679. // li close tag
  680. else if ($tt == '</li' && in_array($html[$p+4], array(' ', '>'))) {
  681. $li_pos = $p;
  682. $p += 4;
  683. $in_li = false;
  684. }
  685. // ul/ol closing tag
  686. else if ($tt == '</' . $tag && in_array($html[$p+4], array(' ', '>'))) {
  687. break;
  688. }
  689. // nested ol/ul element out of li
  690. else if (!$in_li && $li_pos && ($tt == '<ol>' || $tt == '<ol ' || $tt == '<ul>' || $tt == '<ul ')) {
  691. // find closing tag of this ul/ol element
  692. $element = substr($tt, 1, 2);
  693. $cpos = $p;
  694. do {
  695. $tpos = stripos($html, '<' . $element, $cpos+1);
  696. $cpos = stripos($html, '</' . $element, $cpos+1);
  697. }
  698. while ($tpos !== false && $cpos !== false && $cpos > $tpos);
  699. // not found, this is invalid HTML, skip it
  700. if ($cpos === false) {
  701. break;
  702. }
  703. // get element content
  704. $end = strpos($html, '>', $cpos);
  705. $len = $end - $p + 1;
  706. $element = substr($html, $p, $len);
  707. // move element to the end of the last li
  708. $html = substr_replace($html, '', $p, $len);
  709. $html = substr_replace($html, $element, $li_pos, 0);
  710. $p = $end;
  711. }
  712. else {
  713. $p++;
  714. }
  715. }
  716. }
  717. }
  718. }
  719. /**
  720. * Explode css style value
  721. */
  722. protected function explode_style($style)
  723. {
  724. $pos = 0;
  725. // first remove comments
  726. while (($pos = strpos($style, '/*', $pos)) !== false) {
  727. $end = strpos($style, '*/', $pos+2);
  728. if ($end === false) {
  729. $style = substr($style, 0, $pos);
  730. }
  731. else {
  732. $style = substr_replace($style, '', $pos, $end - $pos + 2);
  733. }
  734. }
  735. $style = trim($style);
  736. $strlen = strlen($style);
  737. $result = array();
  738. // explode value
  739. for ($p=$i=0; $i < $strlen; $i++) {
  740. if (($style[$i] == "\"" || $style[$i] == "'") && $style[$i-1] != "\\") {
  741. if ($q == $style[$i]) {
  742. $q = false;
  743. }
  744. else if (!$q) {
  745. $q = $style[$i];
  746. }
  747. }
  748. if (!$q && $style[$i] == ' ' && !preg_match('/[,\(]/', $style[$i-1])) {
  749. $result[] = substr($style, $p, $i - $p);
  750. $p = $i + 1;
  751. }
  752. }
  753. $result[] = (string) substr($style, $p);
  754. return $result;
  755. }
  756. }