Aleksander Machniak
2016-01-12 190c658fe3b82144b99c02f47319ba516d43aee3
commit | author | age
7ac944 1 <?php
AM 2
3 /**
4  +-----------------------------------------------------------------------+
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
21 /**
22  *                Washtml, a HTML sanityzer.
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',
98         // form elements
ffd5ff 99         'button', 'input', 'textarea', 'select', 'option', 'optgroup',
AM 100         // SVG
101         'svg', 'altglyph', 'altglyphdef', 'altglyphitem', 'animate', 'animatecolor',
102         'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc',
103         'ellipse', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line',
104         'lineargradient', 'marker', 'mask', 'mpath', 'path', 'pattern',
105         'polygon', 'polyline', 'radialgradient', 'rect', 'set', 'stop', 'switch', 'symbol',
106         'text', 'textpath', 'tref', 'tspan', 'use', 'view', 'vkern', 'filter',
107          // SVG Filters
108         'feblend', 'fecolormatrix', 'fecomponenttransfer', 'fecomposite',
109         'feconvolvematrix', 'fediffuselighting', 'fedisplacementmap',
110         'feflood', 'fefunca', 'fefuncb', 'fefuncg', 'fefuncr', 'fegaussianblur',
111         'feimage', 'femerge', 'femergenode', 'femorphology', 'feoffset',
112         'fespecularlighting', 'fetile', 'feturbulence',
7ac944 113     );
AM 114
115     /* Ignore these HTML tags and their content */
116     static $ignore_elements = array('script', 'applet', 'embed', 'object', 'style');
117
118     /* Allowed HTML attributes */
119     static $html_attribs = array('name', 'class', 'title', 'alt', 'width', 'height',
120         'align', 'nowrap', 'col', 'row', 'id', 'rowspan', 'colspan', 'cellspacing',
121         'cellpadding', 'valign', 'bgcolor', 'color', 'border', 'bordercolorlight',
122         'bordercolordark', 'face', 'marginwidth', 'marginheight', 'axis', 'border',
123         'abbr', 'char', 'charoff', 'clear', 'compact', 'coords', 'vspace', 'hspace',
124         'cellborder', 'size', 'lang', 'dir', 'usemap', 'shape', 'media',
190c65 125         'background', 'src', 'poster', 'href',
7ac944 126         // attributes of form elements
ffd5ff 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',
190c65 136         'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'from',
ffd5ff 137         'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform',
AM 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',
190c65 151         'text-rendering', 'textlength', 'to', 'u1', 'u2', 'unicode', 'values', 'viewbox',
ffd5ff 152         'visibility', 'vert-adv-y', 'version', 'vert-origin-x', 'vert-origin-y', 'word-spacing',
AM 153         'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2',
154         'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan',
155         // XML
190c65 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',
ffd5ff 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     );
AM 170
171     /* State for linked objects in HTML */
172     public $extlinks = false;
173
174     /* Current settings */
175     private $config = array();
176
177     /* Registered callback functions for tags */
178     private $handlers = array();
179
180     /* Allowed HTML elements */
181     private $_html_elements = array();
182
183     /* Ignore these HTML tags but process their content */
184     private $_ignore_elements = array();
185
cb3e2f 186     /* Elements which could be empty and be returned in short form (<tag />) */
AM 187     private $_void_elements = array();
7ac944 188
AM 189     /* Allowed HTML attributes */
190     private $_html_attribs = array();
191
a89940 192     /* Max nesting level */
AM 193     private $max_nesting_level;
194
7ac944 195
AM 196     /**
197      * Class constructor
198      */
199     public function __construct($p = array())
200     {
190c65 201         $this->_html_elements   = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements);
AM 202         $this->_html_attribs    = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
203         $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
204         $this->_void_elements   = array_flip((array)$p['void_elements']) + array_flip(self::$void_elements);
7ac944 205
cb3e2f 206         unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['void_elements']);
7ac944 207
AM 208         $this->config = $p + array('show_washed' => true, 'allow_remote' => false, 'cid_map' => array());
209     }
210
211     /**
212      * Register a callback function for a certain tag
213      */
214     public function add_callback($tagName, $callback)
215     {
216         $this->handlers[$tagName] = $callback;
217     }
218
219     /**
220      * Check CSS style
221      */
222     private function wash_style($style)
223     {
05d419 224         $result = array();
7ac944 225
AM 226         foreach (explode(';', $style) as $declaration) {
227             if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
228                 $cssid = $match[1];
229                 $str   = $match[2];
230                 $value = '';
231
05d419 232                 foreach ($this->explode_style($str) as $val) {
AM 233                     if (preg_match('/^url\(/i', $val)) {
234                         if (preg_match('/^url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $val, $match)) {
235                             $url = $match[1];
236                             if (($src = $this->config['cid_map'][$url])
237                                 || ($src = $this->config['cid_map'][$this->config['base_url'].$url])
238                             ) {
239                                 $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
7ac944 240                             }
05d419 241                             else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $url, $m)) {
AM 242                                 if ($this->config['allow_remote']) {
243                                     $value .= ' url('.htmlspecialchars($m[0], ENT_QUOTES).')';
244                                 }
245                                 else {
246                                     $this->extlinks = true;
247                                 }
7ac944 248                             }
05d419 249                             else if (preg_match('/^data:.+/i', $url)) { // RFC2397
AM 250                                 $value .= ' url('.htmlspecialchars($url, ENT_QUOTES).')';
251                             }
7ac944 252                         }
AM 253                     }
05d419 254                     else if (!preg_match('/^(behavior|expression)/i', $val)) {
7ac944 255                         // whitelist ?
05d419 256                         $value .= ' ' . $val;
7ac944 257
AM 258                         // #1488535: Fix size units, so width:800 would be changed to width:800px
49e260 259                         if (preg_match('/^(left|right|top|bottom|width|height)/i', $cssid)
05d419 260                             && preg_match('/^[0-9]+$/', $val)
7ac944 261                         ) {
AM 262                             $value .= 'px';
263                         }
264                     }
265                 }
266
267                 if (isset($value[0])) {
05d419 268                     $result[] = $cssid . ':' . $value;
7ac944 269                 }
AM 270             }
271         }
272
05d419 273         return implode('; ', $result);
7ac944 274     }
AM 275
276     /**
277      * Take a node and return allowed attributes and check values
278      */
279     private function wash_attribs($node)
280     {
190c65 281         $result = '';
AM 282         $washed = array();
7ac944 283
ffd5ff 284         foreach ($node->attributes as $name => $attr) {
AM 285             $key   = strtolower($name);
286             $value = $attr->nodeValue;
7ac944 287
190c65 288             if ($key == 'style' && ($style = $this->wash_style($value))) {
049940 289                 // replace double quotes to prevent syntax error and XSS issues (#1490227)
190c65 290                 $result .= ' style="' . str_replace('"', '&quot;', $style) . '"';
7ac944 291             }
190c65 292             else if (isset($this->_html_attribs[$key])) {
AM 293                 $value = trim($value);
294                 $out   = '';
295
296                 if ($this->is_image($node->tagName, $key)) {
297                     if (($src = $this->config['cid_map'][$value])
298                         || ($src = $this->config['cid_map'][$this->config['base_url'].$value])
299                     ) {
300                         $out = $src;
7ac944 301                     }
190c65 302                     else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
AM 303                         if ($this->config['allow_remote']) {
304                             $out = $value;
305                         }
306                         else {
307                             $this->extlinks = true;
308                             if ($this->config['blocked_src']) {
309                                 $out = $this->config['blocked_src'];
310                             }
7ac944 311                         }
AM 312                     }
190c65 313                     else if (preg_match('/^data:image.+/i', $value)) { // RFC2397
AM 314                         $out = $value;
315                     }
7ac944 316                 }
190c65 317                 else if ($this->is_link($node->tagName, $key)) {
AM 318                     if (!preg_match('!^(javascript|vbscript|data:text)!i', $value)
319                         && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value)
320                     ) {
321                         $out = $value;
322                     }
323                 }
324                 else {
325                    $out = $value;
326                 }
327
328                 if ($out) {
329                     $result .= ' ' . $key . '="' . htmlspecialchars($out, ENT_QUOTES) . '"';
330                 }
331                 else if ($value) {
332                     $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
7ac944 333                 }
AM 334             }
335             else {
190c65 336                 $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
7ac944 337             }
AM 338         }
339
190c65 340         if (!empty($washed) && $this->config['show_washed']) {
AM 341             $result .= ' x-washed="' . implode(' ', $washed) . '"';
342         }
343
344         return $result;
345     }
346
347     /**
348      * Check it the tag/attribute may contain an URI
349      */
350     private function is_link($tag, $attr)
351     {
352         return $tag == 'a' && $attr == 'href';
353     }
354
355     /**
356      * Check it the tag/attribute may contain an image URI
357      */
358     private function is_image($tag, $attr)
359     {
360         return $attr == 'background'
361             || ($attr == 'poster' && $tag == 'video')
362             || ($attr == 'src' && preg_match('/^(img|source)$/i', $tag));
7ac944 363     }
AM 364
365     /**
366      * The main loop that recurse on a node tree.
7883ec 367      * It output only allowed tags with allowed attributes and allowed inline styles
AM 368      *
369      * @param DOMNode $node  HTML element
370      * @param int     $level Recurrence level (safe initial value found empirically)
7ac944 371      */
7883ec 372     private function dumpHtml($node, $level = 20)
7ac944 373     {
AM 374         if (!$node->hasChildNodes()) {
375             return '';
a89940 376         }
AM 377
378         $level++;
379
380         if ($this->max_nesting_level > 0 && $level == $this->max_nesting_level - 1) {
381             // log error message once
382             if (!$this->max_nesting_level_error) {
383                 $this->max_nesting_level_error = true;
384                 rcube::raise_error(array('code' => 500, 'type' => 'php',
385                     'line' => __LINE__, 'file' => __FILE__,
386                     'message' => "Maximum nesting level exceeded (xdebug.max_nesting_level={$this->max_nesting_level})"),
387                     true, false);
388             }
389             return '<!-- ignored -->';
7ac944 390         }
AM 391
392         $node = $node->firstChild;
393         $dump = '';
394
395         do {
e7d1a8 396             switch ($node->nodeType) {
7ac944 397             case XML_ELEMENT_NODE: //Check element
AM 398                 $tagName = strtolower($node->tagName);
399                 if ($callback = $this->handlers[$tagName]) {
400                     $dump .= call_user_func($callback, $tagName,
a89940 401                         $this->wash_attribs($node), $this->dumpHtml($node, $level), $this);
7ac944 402                 }
AM 403                 else if (isset($this->_html_elements[$tagName])) {
a89940 404                     $content = $this->dumpHtml($node, $level);
ffd5ff 405                     $dump .= '<' . $tagName;
AM 406
407                     if ($tagName == 'svg') {
408                         $xpath = new DOMXPath($node->ownerDocument);
409                         foreach ($xpath->query('namespace::*') as $ns) {
410                             if ($ns->nodeName != 'xmlns:xml') {
411                                 $dump .= ' ' . $ns->nodeName . '="' . $ns->nodeValue . '"';
412                             }
413                         }
414                     }
415
416                     $dump .= $this->wash_attribs($node);
417
418                     if ($content === '' && isset($this->_void_elements[$tagName])) {
419                         $dump .= ' />';
420                     }
421                     else {
422                         $dump .= ">$content</$tagName>";
423                     }
7ac944 424                 }
AM 425                 else if (isset($this->_ignore_elements[$tagName])) {
426                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
427                 }
428                 else {
429                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
a89940 430                     $dump .= $this->dumpHtml($node, $level); // ignore tags not its content
7ac944 431                 }
AM 432                 break;
433
434             case XML_CDATA_SECTION_NODE:
435                 $dump .= $node->nodeValue;
436                 break;
437
438             case XML_TEXT_NODE:
439                 $dump .= htmlspecialchars($node->nodeValue);
440                 break;
441
442             case XML_HTML_DOCUMENT_NODE:
a89940 443                 $dump .= $this->dumpHtml($node, $level);
7ac944 444                 break;
AM 445             }
e7d1a8 446         }
AM 447         while($node = $node->nextSibling);
7ac944 448
AM 449         return $dump;
450     }
451
452     /**
453      * Main function, give it untrusted HTML, tell it if you allow loading
454      * remote images and give it a map to convert "cid:" urls.
455      */
456     public function wash($html)
457     {
458         // Charset seems to be ignored (probably if defined in the HTML document)
459         $node = new DOMDocument('1.0', $this->config['charset']);
460         $this->extlinks = false;
461
462         $html = $this->cleanup($html);
463
464         // Find base URL for images
465         if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches)) {
466             $this->config['base_url'] = $matches[1];
467         }
468         else {
469             $this->config['base_url'] = '';
470         }
a89940 471
AM 472         // Detect max nesting level (for dumpHTML) (#1489110)
473         $this->max_nesting_level = (int) @ini_get('xdebug.max_nesting_level');
7ac944 474
ffd5ff 475         // SVG need to be parsed as XML
AM 476         $xml     = stripos($html, '<svg') !== false || stripos($html, '<?xml') !== false;
477         $method  = $xml ? 'loadXML' : 'loadHTML';
478         $options = 0;
479
bfd24f 480         // Use optimizations if supported
ffd5ff 481         if (PHP_VERSION_ID >= 50400) {
AM 482             $options = LIBXML_PARSEHUGE | LIBXML_COMPACT | LIBXML_NONET;
190c65 483             @$node->{$method}($html, $options);
bfd24f 484         }
190c65 485         else {
AM 486             @$node->{$method}($html);
487         }
bfd24f 488
7ac944 489         return $this->dumpHtml($node);
AM 490     }
491
492     /**
493      * Getter for config parameters
494      */
495     public function get_config($prop)
496     {
497         return $this->config[$prop];
498     }
499
500     /**
501      * Clean HTML input
502      */
503     private function cleanup($html)
504     {
ffd5ff 505         $html = trim($html);
AM 506
7ac944 507         // special replacements (not properly handled by washtml class)
AM 508         $html_search = array(
509             '/(<\/nobr>)(\s+)(<nobr>)/i',       // space(s) between <NOBR>
510             '/<title[^>]*>[^<]*<\/title>/i',    // PHP bug #32547 workaround: remove title tag
511             '/^(\0\0\xFE\xFF|\xFF\xFE\0\0|\xFE\xFF|\xFF\xFE|\xEF\xBB\xBF)/',    // byte-order mark (only outlook?)
512             '/<html\s[^>]+>/i',                 // washtml/DOMDocument cannot handle xml namespaces
513         );
514
515         $html_replace = array(
516             '\\1'.' &nbsp; '.'\\3',
517             '',
518             '',
519             '<html>',
520         );
ffd5ff 521
7ac944 522         $html = preg_replace($html_search, $html_replace, trim($html));
AM 523
ffd5ff 524         // Replace all of those weird MS Word quotes and other high characters
c72507 525         $badwordchars = array(
b6a640 526             "\xe2\x80\x98", // left single quote
R 527             "\xe2\x80\x99", // right single quote
528             "\xe2\x80\x9c", // left double quote
529             "\xe2\x80\x9d", // right double quote
530             "\xe2\x80\x94", // em dash
ffd5ff 531             "\xe2\x80\xa6"  // elipses
b6a640 532         );
ffd5ff 533
c72507 534         $fixedwordchars = array(
b6a640 535             "'",
R 536             "'",
537             '"',
538             '"',
539             '&mdash;',
540             '...'
541         );
ffd5ff 542
c72507 543         $html = str_replace($badwordchars, $fixedwordchars, $html);
b6a640 544
7ac944 545         // PCRE errors handling (#1486856), should we use something like for every preg_* use?
AM 546         if ($html === null && ($preg_error = preg_last_error()) != PREG_NO_ERROR) {
547             $errstr = "Could not clean up HTML message! PCRE Error: $preg_error.";
548
549             if ($preg_error == PREG_BACKTRACK_LIMIT_ERROR) {
550                 $errstr .= " Consider raising pcre.backtrack_limit!";
551             }
552             if ($preg_error == PREG_RECURSION_LIMIT_ERROR) {
553                 $errstr .= " Consider raising pcre.recursion_limit!";
554             }
555
556             rcube::raise_error(array('code' => 620, 'type' => 'php',
557                 'line' => __LINE__, 'file' => __FILE__,
558                 'message' => $errstr), true, false);
a89940 559
7ac944 560             return '';
AM 561         }
562
563         // fix (unknown/malformed) HTML tags before "wash"
ffec85 564         $html = preg_replace_callback('/(<(?!\!)[\/]*)([^\s>]+)([^>]*)/', array($this, 'html_tag_callback'), $html);
7ac944 565
AM 566         // Remove invalid HTML comments (#1487759)
567         // Don't remove valid conditional comments
1bce14 568         // Don't remove MSOutlook (<!-->) conditional comments (#1489004)
2d233b 569         $html = preg_replace('/<!--[^-<>\[\n]+>/', '', $html);
c72507 570
AM 571         // fix broken nested lists
572         self::fix_broken_lists($html);
7ac944 573
AM 574         // turn relative into absolute urls
575         $html = self::resolve_base($html);
576
577         return $html;
578     }
579
580     /**
581      * Callback function for HTML tags fixing
582      */
583     public static function html_tag_callback($matches)
584     {
585         $tagname = $matches[2];
586         $tagname = preg_replace(array(
587             '/:.*$/',               // Microsoft's Smart Tags <st1:xxxx>
ffd5ff 588             '/[^a-z0-9_\[\]\!?-]/i', // forbidden characters
7ac944 589         ), '', $tagname);
AM 590
ffec85 591         // fix invalid closing tags - remove any attributes (#1489446)
AM 592         if ($matches[1] == '</') {
593             $matches[3] = '';
594         }
595
596         return $matches[1] . $tagname . $matches[3];
7ac944 597     }
AM 598
599     /**
600      * Convert all relative URLs according to a <base> in HTML
601      */
602     public static function resolve_base($body)
603     {
604         // check for <base href=...>
605         if (preg_match('!(<base.*href=["\']?)([hftps]{3,5}://[a-z0-9/.%-]+)!i', $body, $regs)) {
606             $replacer = new rcube_base_replacer($regs[2]);
607             $body     = $replacer->replace($body);
608         }
609
610         return $body;
611     }
612
c72507 613     /**
AM 614      * Fix broken nested lists, they are not handled properly by DOMDocument (#1488768)
615      */
616     public static function fix_broken_lists(&$html)
617     {
618         // do two rounds, one for <ol>, one for <ul>
619         foreach (array('ol', 'ul') as $tag) {
620             $pos = 0;
621             while (($pos = stripos($html, '<' . $tag, $pos)) !== false) {
622                 $pos++;
623
624                 // make sure this is an ol/ul tag
625                 if (!in_array($html[$pos+2], array(' ', '>'))) {
626                     continue;
627                 }
628
629                 $p      = $pos;
630                 $in_li  = false;
631                 $li_pos = 0;
632
633                 while (($p = strpos($html, '<', $p)) !== false) {
634                     $tt = strtolower(substr($html, $p, 4));
635
636                     // li open tag
637                     if ($tt == '<li>' || $tt == '<li ') {
638                         $in_li = true;
639                         $p += 4;
640                     }
641                     // li close tag
642                     else if ($tt == '</li' && in_array($html[$p+4], array(' ', '>'))) {
643                         $li_pos = $p;
644                         $p += 4;
645                         $in_li = false;
646                     }
647                     // ul/ol closing tag
648                     else if ($tt == '</' . $tag && in_array($html[$p+4], array(' ', '>'))) {
649                         break;
650                     }
651                     // nested ol/ul element out of li
652                     else if (!$in_li && $li_pos && ($tt == '<ol>' || $tt == '<ol ' || $tt == '<ul>' || $tt == '<ul ')) {
653                         // find closing tag of this ul/ol element
654                         $element = substr($tt, 1, 2);
655                         $cpos    = $p;
656                         do {
657                             $tpos = stripos($html, '<' . $element, $cpos+1);
658                             $cpos = stripos($html, '</' . $element, $cpos+1);
659                         }
660                         while ($tpos !== false && $cpos !== false && $cpos > $tpos);
661
662                         // not found, this is invalid HTML, skip it
663                         if ($cpos === false) {
664                             break;
665                         }
666
667                         // get element content
668                         $end     = strpos($html, '>', $cpos);
669                         $len     = $end - $p + 1;
670                         $element = substr($html, $p, $len);
671
672                         // move element to the end of the last li
673                         $html    = substr_replace($html, '', $p, $len);
674                         $html    = substr_replace($html, $element, $li_pos, 0);
675
676                         $p = $end;
677                     }
678                     else {
679                         $p++;
680                     }
681                 }
682             }
683         }
684     }
05d419 685
AM 686     /**
687      * Explode css style value
688      */
689     protected function explode_style($style)
690     {
691         $style = trim($style);
692
693         // first remove comments
694         $pos = 0;
695         while (($pos = strpos($style, '/*', $pos)) !== false) {
696             $end = strpos($style, '*/', $pos+2);
697
698             if ($end === false) {
699                 $style = substr($style, 0, $pos);
700             }
701             else {
702                 $style = substr_replace($style, '', $pos, $end - $pos + 2);
703             }
704         }
705
706         $strlen = strlen($style);
707         $result = array();
708
709         // explode value
710         for ($p=$i=0; $i < $strlen; $i++) {
711             if (($style[$i] == "\"" || $style[$i] == "'") && $style[$i-1] != "\\") {
712                 if ($q == $style[$i]) {
713                     $q = false;
714                 }
715                 else if (!$q) {
716                     $q = $style[$i];
717                 }
718             }
719
720             if (!$q && $style[$i] == ' ' && !preg_match('/[,\(]/', $style[$i-1])) {
721                 $result[] = substr($style, $p, $i - $p);
722                 $p = $i + 1;
723             }
724         }
725
726         $result[] = (string) substr($style, $p);
727
728         return $result;
729     }
c72507 730 }