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