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