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