Aleksander Machniak
2016-01-09 3e4b7cd19d1b019f35872d384aeb24f09d035bce
commit | author | age
7ac944 1 <?php
AM 2
7b9245 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
3e4b7c 100         'button', 'input', 'textarea', 'select', 'option', 'optgroup',
AM 101         // SVG
102         'svg', 'altglyph', 'altglyphdef', 'altglyphitem', 'animate', 'animatecolor',
103         'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc',
104         'ellipse', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line',
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',
126         // attributes of form elements
3e4b7c 127         'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value',
AM 128         // SVG
129         'accent-height', 'accumulate', 'additivive', 'alignment-baseline',
130         'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseprofile',
131         'baseline-shift', 'begin', 'bias', 'by', 'clip', 'clip-path', 'clip-rule',
132         'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile',
133         'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction',
134         'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity',
135         'fill-rule', 'filter', 'flood-color', 'flood-opacity', 'font-family', 'font-size',
136         'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight',
137         'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform',
138         'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints',
139         'keysplines', 'keytimes', 'lengthadjust', 'letter-spacing', 'kernelmatrix',
140         'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid',
141         'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits',
142         'maskunits', 'max', 'mask', 'mode', 'min', 'numoctaves', 'offset', 'operator',
143         'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order',
144         'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits',
145         'points', 'preservealpha', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount',
146         'repeatdur', 'restart', 'rotate', 'scale', 'seed', 'shape-rendering', 'specularconstant',
147         'specularexponent', 'spreadmethod', 'stddeviation', 'stitchtiles', 'stop-color',
148         'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap',
149         'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width',
150         'surfacescale', 'targetx', 'targety', 'transform', 'text-anchor', 'text-decoration',
151         'text-rendering', 'textlength', 'u1', 'u2', 'unicode', 'values', 'viewbox',
152         'visibility', 'vert-adv-y', 'version', 'vert-origin-x', 'vert-origin-y', 'word-spacing',
153         'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2',
154         'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan',
155         // XML
156         'xml:id', 'xlink:title'
7ac944 157     );
AM 158
cb3e2f 159     /* Elements which could be empty and be returned in short form (<tag />) */
AM 160     static $void_elements = array('area', 'base', 'br', 'col', 'command', 'embed', 'hr',
3e4b7c 161         'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr',
AM 162         // SVG
163         'altglyph', 'altglyphdef', 'altglyphitem', 'animate', 'animatecolor',
164         'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc',
165         'ellipse', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line',
166         'lineargradient', 'marker', 'mask', 'mpath', 'path', 'pattern',
167         'polygon', 'polyline', 'radialgradient', 'rect', 'set', 'stop', 'switch', 'symbol',
168         'text', 'textpath', 'tref', 'tspan', 'use', 'view', 'vkern', 'filter',
7ac944 169     );
3e4b7c 170
AM 171     /* Attributes that may contain insecure content */
172     static $insecure_attribs = array('href', 'to', 'from');
7ac944 173
AM 174     /* State for linked objects in HTML */
175     public $extlinks = false;
176
177     /* Current settings */
178     private $config = array();
179
180     /* Registered callback functions for tags */
181     private $handlers = array();
182
183     /* Allowed HTML elements */
184     private $_html_elements = array();
185
186     /* Ignore these HTML tags but process their content */
187     private $_ignore_elements = array();
188
cb3e2f 189     /* Elements which could be empty and be returned in short form (<tag />) */
AM 190     private $_void_elements = array();
7ac944 191
AM 192     /* Allowed HTML attributes */
193     private $_html_attribs = array();
194
a89940 195     /* Max nesting level */
AM 196     private $max_nesting_level;
197
7ac944 198
AM 199     /**
200      * Class constructor
201      */
202     public function __construct($p = array())
203     {
3e4b7c 204         $this->_html_elements    = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements);
AM 205         $this->_html_attribs     = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
206         $this->_insecure_attribs = array_flip((array)$p['insecure_attribs']) + array_flip(self::$insecure_attribs);
207         $this->_ignore_elements  = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
208         $this->_void_elements    = array_flip((array)$p['void_elements']) + array_flip(self::$void_elements);
7ac944 209
cb3e2f 210         unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['void_elements']);
7ac944 211
AM 212         $this->config = $p + array('show_washed' => true, 'allow_remote' => false, 'cid_map' => array());
213     }
214
215     /**
216      * Register a callback function for a certain tag
217      */
218     public function add_callback($tagName, $callback)
219     {
220         $this->handlers[$tagName] = $callback;
221     }
222
223     /**
224      * Check CSS style
225      */
226     private function wash_style($style)
227     {
f96fec 228         $result = array();
7ac944 229
ca7fc7 230         // Remove unwanted white-space characters so regular expressions below work better
AM 231         $style = preg_replace('/[\n\r\s\t]+/', ' ', $style);
232
7ac944 233         foreach (explode(';', $style) as $declaration) {
AM 234             if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
235                 $cssid = $match[1];
236                 $str   = $match[2];
237                 $value = '';
238
f96fec 239                 foreach ($this->explode_style($str) as $val) {
AM 240                     if (preg_match('/^url\(/i', $val)) {
241                         if (preg_match('/^url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $val, $match)) {
242                             $url = $match[1];
243                             if (($src = $this->config['cid_map'][$url])
244                                 || ($src = $this->config['cid_map'][$this->config['base_url'].$url])
245                             ) {
246                                 $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
7ac944 247                             }
f96fec 248                             else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $url, $m)) {
AM 249                                 if ($this->config['allow_remote']) {
250                                     $value .= ' url('.htmlspecialchars($m[0], ENT_QUOTES).')';
251                                 }
252                                 else {
253                                     $this->extlinks = true;
254                                 }
7ac944 255                             }
f96fec 256                             else if (preg_match('/^data:.+/i', $url)) { // RFC2397
AM 257                                 $value .= ' url('.htmlspecialchars($url, ENT_QUOTES).')';
258                             }
7ac944 259                         }
AM 260                     }
f96fec 261                     else if (!preg_match('/^(behavior|expression)/i', $val)) {
7ac944 262                         // whitelist ?
f96fec 263                         $value .= ' ' . $val;
7ac944 264
AM 265                         // #1488535: Fix size units, so width:800 would be changed to width:800px
5bf83d 266                         if (preg_match('/^(left|right|top|bottom|width|height)/i', $cssid)
f96fec 267                             && preg_match('/^[0-9]+$/', $val)
7ac944 268                         ) {
AM 269                             $value .= 'px';
270                         }
271                     }
272                 }
273
274                 if (isset($value[0])) {
f96fec 275                     $result[] = $cssid . ':' . $value;
7ac944 276                 }
AM 277             }
278         }
279
f96fec 280         return implode('; ', $result);
7ac944 281     }
AM 282
283     /**
284      * Take a node and return allowed attributes and check values
285      */
286     private function wash_attribs($node)
287     {
3e4b7c 288         $t      = '';
7ac944 289         $washed = '';
AM 290
3e4b7c 291         foreach ($node->attributes as $name => $attr) {
AM 292             $key   = strtolower($name);
293             $value = $attr->nodeValue;
7ac944 294
AM 295             if (isset($this->_html_attribs[$key]) ||
3e4b7c 296                 (isset($this->_insecure_attribs[$key])
AM 297                     && ($value = trim($value))
1f910c 298                     && !preg_match('!^(javascript|vbscript|data:text)!i', $value)
7ac944 299                     && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value))
AM 300             ) {
3e4b7c 301                 $t .= ' ' . $attr->nodeName . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
7ac944 302             }
AM 303             else if ($key == 'style' && ($style = $this->wash_style($value))) {
786aa0 304                 // replace double quotes to prevent syntax error and XSS issues (#1490227)
AM 305                 $t .= ' style="' . str_replace('"', '&quot;', $style) . '"';
7ac944 306             }
3e4b7c 307             else if ($key == 'background' || $key == 'href'
c5bfe6 308                 || ($key == 'src' && preg_match('/^(img|source)$/i', $node->tagName))
AM 309                 || ($key == 'poster' && strtolower($node->tagName) == 'video')
310             ) {
7ac944 311                 if (($src = $this->config['cid_map'][$value])
AM 312                     || ($src = $this->config['cid_map'][$this->config['base_url'].$value])
313                 ) {
314                     $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
315                 }
316                 else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
317                     if ($this->config['allow_remote']) {
318                         $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
319                     }
320                     else {
321                         $this->extlinks = true;
322                         if ($this->config['blocked_src']) {
323                             $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
324                         }
325                     }
326                 }
327                 else if (preg_match('/^data:.+/i', $value)) { // RFC2397
328                     $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
329                 }
330             }
331             else {
3e4b7c 332                 $washed .= ($washed ? ' ' : '') . $attr->nodeName;
7ac944 333             }
AM 334         }
335
336         return $t . ($washed && $this->config['show_washed'] ? ' x-washed="'.$washed.'"' : '');
337     }
338
339     /**
340      * The main loop that recurse on a node tree.
c77a84 341      * It output only allowed tags with allowed attributes and allowed inline styles
AM 342      *
343      * @param DOMNode $node  HTML element
344      * @param int     $level Recurrence level (safe initial value found empirically)
7ac944 345      */
c77a84 346     private function dumpHtml($node, $level = 20)
7ac944 347     {
AM 348         if (!$node->hasChildNodes()) {
349             return '';
a89940 350         }
AM 351
352         $level++;
353
354         if ($this->max_nesting_level > 0 && $level == $this->max_nesting_level - 1) {
355             // log error message once
356             if (!$this->max_nesting_level_error) {
357                 $this->max_nesting_level_error = true;
358                 rcube::raise_error(array('code' => 500, 'type' => 'php',
359                     'line' => __LINE__, 'file' => __FILE__,
360                     'message' => "Maximum nesting level exceeded (xdebug.max_nesting_level={$this->max_nesting_level})"),
361                     true, false);
362             }
363             return '<!-- ignored -->';
7ac944 364         }
AM 365
366         $node = $node->firstChild;
367         $dump = '';
368
369         do {
c7c09f 370             switch ($node->nodeType) {
7ac944 371             case XML_ELEMENT_NODE: //Check element
AM 372                 $tagName = strtolower($node->tagName);
373                 if ($callback = $this->handlers[$tagName]) {
374                     $dump .= call_user_func($callback, $tagName,
a89940 375                         $this->wash_attribs($node), $this->dumpHtml($node, $level), $this);
7ac944 376                 }
AM 377                 else if (isset($this->_html_elements[$tagName])) {
a89940 378                     $content = $this->dumpHtml($node, $level);
3e4b7c 379                     $dump .= '<' . $tagName;
AM 380
381                     if ($tagName == 'svg') {
382                         $xpath = new DOMXPath($node->ownerDocument);
383                         foreach ($xpath->query('namespace::*') as $ns) {
384                             if ($ns->nodeName != 'xmlns:xml') {
385                                 $dump .= ' ' . $ns->nodeName . '="' . $ns->nodeValue . '"';
386                             }
387                         }
388                     }
389
390                     $dump .= $this->wash_attribs($node);
391
392                     if ($content === '' && isset($this->_void_elements[$tagName])) {
393                         $dump .= ' />';
394                     }
395                     else {
396                         $dump .= ">$content</$tagName>";
397                     }
7ac944 398                 }
AM 399                 else if (isset($this->_ignore_elements[$tagName])) {
400                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
401                 }
402                 else {
403                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
a89940 404                     $dump .= $this->dumpHtml($node, $level); // ignore tags not its content
7ac944 405                 }
AM 406                 break;
407
408             case XML_CDATA_SECTION_NODE:
409                 $dump .= $node->nodeValue;
410                 break;
411
412             case XML_TEXT_NODE:
413                 $dump .= htmlspecialchars($node->nodeValue);
414                 break;
415
416             case XML_HTML_DOCUMENT_NODE:
a89940 417                 $dump .= $this->dumpHtml($node, $level);
7ac944 418                 break;
AM 419             }
c7c09f 420         }
AM 421         while($node = $node->nextSibling);
7ac944 422
AM 423         return $dump;
424     }
425
426     /**
427      * Main function, give it untrusted HTML, tell it if you allow loading
428      * remote images and give it a map to convert "cid:" urls.
429      */
430     public function wash($html)
431     {
432         // Charset seems to be ignored (probably if defined in the HTML document)
433         $node = new DOMDocument('1.0', $this->config['charset']);
434         $this->extlinks = false;
435
436         $html = $this->cleanup($html);
437
438         // Find base URL for images
439         if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches)) {
440             $this->config['base_url'] = $matches[1];
441         }
442         else {
443             $this->config['base_url'] = '';
444         }
a89940 445
AM 446         // Detect max nesting level (for dumpHTML) (#1489110)
447         $this->max_nesting_level = (int) @ini_get('xdebug.max_nesting_level');
7ac944 448
3e4b7c 449         // SVG need to be parsed as XML
AM 450         $xml     = stripos($html, '<svg') !== false || stripos($html, '<?xml') !== false;
451         $method  = $xml ? 'loadXML' : 'loadHTML';
452         $options = 0;
453
bfd24f 454         // Use optimizations if supported
75bbad 455         if (PHP_VERSION_ID >= 50400) {
3e4b7c 456             $options = LIBXML_PARSEHUGE | LIBXML_COMPACT | LIBXML_NONET;
bfd24f 457         }
3e4b7c 458
AM 459         @$node->{$method}($html, $options);
bfd24f 460
7ac944 461         return $this->dumpHtml($node);
AM 462     }
463
464     /**
465      * Getter for config parameters
466      */
467     public function get_config($prop)
468     {
469         return $this->config[$prop];
470     }
471
472     /**
473      * Clean HTML input
474      */
475     private function cleanup($html)
476     {
3e4b7c 477         $html = trim($html);
AM 478
7ac944 479         // special replacements (not properly handled by washtml class)
AM 480         $html_search = array(
b7048d 481             // space(s) between <NOBR>
AM 482             '/(<\/nobr>)(\s+)(<nobr>)/i',
483             // PHP bug #32547 workaround: remove title tag
484             '/<title[^>]*>[^<]*<\/title>/i',
485             // remove <!doctype> before BOM (#1490291)
486             '/<\!doctype[^>]+>[^<]*/im',
487             // byte-order mark (only outlook?)
488             '/^(\0\0\xFE\xFF|\xFF\xFE\0\0|\xFE\xFF|\xFF\xFE|\xEF\xBB\xBF)/',
489             // washtml/DOMDocument cannot handle xml namespaces
490             '/<html\s[^>]+>/i',
7ac944 491         );
AM 492
493         $html_replace = array(
494             '\\1'.' &nbsp; '.'\\3',
495             '',
496             '',
b7048d 497             '',
7ac944 498             '<html>',
AM 499         );
3e4b7c 500
7ac944 501         $html = preg_replace($html_search, $html_replace, trim($html));
AM 502
3e4b7c 503         // Replace all of those weird MS Word quotes and other high characters
c72507 504         $badwordchars = array(
b6a640 505             "\xe2\x80\x98", // left single quote
R 506             "\xe2\x80\x99", // right single quote
507             "\xe2\x80\x9c", // left double quote
508             "\xe2\x80\x9d", // right double quote
509             "\xe2\x80\x94", // em dash
3e4b7c 510             "\xe2\x80\xa6"  // elipses
b6a640 511         );
3e4b7c 512
c72507 513         $fixedwordchars = array(
b6a640 514             "'",
R 515             "'",
516             '"',
517             '"',
518             '&mdash;',
519             '...'
520         );
3e4b7c 521
c72507 522         $html = str_replace($badwordchars, $fixedwordchars, $html);
b6a640 523
7ac944 524         // PCRE errors handling (#1486856), should we use something like for every preg_* use?
AM 525         if ($html === null && ($preg_error = preg_last_error()) != PREG_NO_ERROR) {
526             $errstr = "Could not clean up HTML message! PCRE Error: $preg_error.";
527
528             if ($preg_error == PREG_BACKTRACK_LIMIT_ERROR) {
529                 $errstr .= " Consider raising pcre.backtrack_limit!";
530             }
531             if ($preg_error == PREG_RECURSION_LIMIT_ERROR) {
532                 $errstr .= " Consider raising pcre.recursion_limit!";
533             }
534
535             rcube::raise_error(array('code' => 620, 'type' => 'php',
536                 'line' => __LINE__, 'file' => __FILE__,
537                 'message' => $errstr), true, false);
a89940 538
7ac944 539             return '';
AM 540         }
541
542         // fix (unknown/malformed) HTML tags before "wash"
ffec85 543         $html = preg_replace_callback('/(<(?!\!)[\/]*)([^\s>]+)([^>]*)/', array($this, 'html_tag_callback'), $html);
7ac944 544
AM 545         // Remove invalid HTML comments (#1487759)
546         // Don't remove valid conditional comments
1bce14 547         // Don't remove MSOutlook (<!-->) conditional comments (#1489004)
82ed25 548         $html = preg_replace('/<!--[^-<>\[\n]+>/', '', $html);
c72507 549
AM 550         // fix broken nested lists
551         self::fix_broken_lists($html);
7ac944 552
AM 553         // turn relative into absolute urls
554         $html = self::resolve_base($html);
555
556         return $html;
557     }
558
559     /**
560      * Callback function for HTML tags fixing
561      */
562     public static function html_tag_callback($matches)
563     {
564         $tagname = $matches[2];
565         $tagname = preg_replace(array(
566             '/:.*$/',               // Microsoft's Smart Tags <st1:xxxx>
3e4b7c 567             '/[^a-z0-9_\[\]\!?-]/i', // forbidden characters
7ac944 568         ), '', $tagname);
AM 569
ffec85 570         // fix invalid closing tags - remove any attributes (#1489446)
AM 571         if ($matches[1] == '</') {
572             $matches[3] = '';
573         }
574
575         return $matches[1] . $tagname . $matches[3];
7ac944 576     }
AM 577
578     /**
579      * Convert all relative URLs according to a <base> in HTML
580      */
581     public static function resolve_base($body)
582     {
583         // check for <base href=...>
584         if (preg_match('!(<base.*href=["\']?)([hftps]{3,5}://[a-z0-9/.%-]+)!i', $body, $regs)) {
585             $replacer = new rcube_base_replacer($regs[2]);
586             $body     = $replacer->replace($body);
587         }
588
589         return $body;
590     }
591
c72507 592     /**
AM 593      * Fix broken nested lists, they are not handled properly by DOMDocument (#1488768)
594      */
595     public static function fix_broken_lists(&$html)
596     {
597         // do two rounds, one for <ol>, one for <ul>
598         foreach (array('ol', 'ul') as $tag) {
599             $pos = 0;
600             while (($pos = stripos($html, '<' . $tag, $pos)) !== false) {
601                 $pos++;
602
603                 // make sure this is an ol/ul tag
604                 if (!in_array($html[$pos+2], array(' ', '>'))) {
605                     continue;
606                 }
607
608                 $p      = $pos;
609                 $in_li  = false;
610                 $li_pos = 0;
611
612                 while (($p = strpos($html, '<', $p)) !== false) {
613                     $tt = strtolower(substr($html, $p, 4));
614
615                     // li open tag
616                     if ($tt == '<li>' || $tt == '<li ') {
617                         $in_li = true;
618                         $p += 4;
619                     }
620                     // li close tag
621                     else if ($tt == '</li' && in_array($html[$p+4], array(' ', '>'))) {
622                         $li_pos = $p;
623                         $p += 4;
624                         $in_li = false;
625                     }
626                     // ul/ol closing tag
627                     else if ($tt == '</' . $tag && in_array($html[$p+4], array(' ', '>'))) {
628                         break;
629                     }
630                     // nested ol/ul element out of li
631                     else if (!$in_li && $li_pos && ($tt == '<ol>' || $tt == '<ol ' || $tt == '<ul>' || $tt == '<ul ')) {
632                         // find closing tag of this ul/ol element
633                         $element = substr($tt, 1, 2);
634                         $cpos    = $p;
635                         do {
636                             $tpos = stripos($html, '<' . $element, $cpos+1);
637                             $cpos = stripos($html, '</' . $element, $cpos+1);
638                         }
639                         while ($tpos !== false && $cpos !== false && $cpos > $tpos);
640
641                         // not found, this is invalid HTML, skip it
642                         if ($cpos === false) {
643                             break;
644                         }
645
646                         // get element content
647                         $end     = strpos($html, '>', $cpos);
648                         $len     = $end - $p + 1;
649                         $element = substr($html, $p, $len);
650
651                         // move element to the end of the last li
652                         $html    = substr_replace($html, '', $p, $len);
653                         $html    = substr_replace($html, $element, $li_pos, 0);
654
655                         $p = $end;
656                     }
657                     else {
658                         $p++;
659                     }
660                 }
661             }
662         }
663     }
f96fec 664
AM 665     /**
666      * Explode css style value
667      */
668     protected function explode_style($style)
669     {
670         $style = trim($style);
671
672         // first remove comments
673         $pos = 0;
674         while (($pos = strpos($style, '/*', $pos)) !== false) {
675             $end = strpos($style, '*/', $pos+2);
676
677             if ($end === false) {
678                 $style = substr($style, 0, $pos);
679             }
680             else {
681                 $style = substr_replace($style, '', $pos, $end - $pos + 2);
682             }
683         }
684
685         $strlen = strlen($style);
686         $result = array();
687
688         // explode value
689         for ($p=$i=0; $i < $strlen; $i++) {
690             if (($style[$i] == "\"" || $style[$i] == "'") && $style[$i-1] != "\\") {
691                 if ($q == $style[$i]) {
692                     $q = false;
693                 }
694                 else if (!$q) {
695                     $q = $style[$i];
696                 }
697             }
698
699             if (!$q && $style[$i] == ' ' && !preg_match('/[,\(]/', $style[$i-1])) {
700                 $result[] = substr($style, $p, $i - $p);
701                 $p = $i + 1;
702             }
703         }
704
705         $result[] = (string) substr($style, $p);
706
707         return $result;
708     }
c72507 709 }