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