Aleksander Machniak
2014-04-05 7883ecb8ec16108d074f63330b489516c32a3b2f
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
99         'button', 'input', 'textarea', 'select', 'option', 'optgroup'
100     );
101
102     /* Ignore these HTML tags and their content */
103     static $ignore_elements = array('script', 'applet', 'embed', 'object', 'style');
104
105     /* Allowed HTML attributes */
106     static $html_attribs = array('name', 'class', 'title', 'alt', 'width', 'height',
107         'align', 'nowrap', 'col', 'row', 'id', 'rowspan', 'colspan', 'cellspacing',
108         'cellpadding', 'valign', 'bgcolor', 'color', 'border', 'bordercolorlight',
109         'bordercolordark', 'face', 'marginwidth', 'marginheight', 'axis', 'border',
110         'abbr', 'char', 'charoff', 'clear', 'compact', 'coords', 'vspace', 'hspace',
111         'cellborder', 'size', 'lang', 'dir', 'usemap', 'shape', 'media',
112         // attributes of form elements
113         'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value'
114     );
115
cb3e2f 116     /* Elements which could be empty and be returned in short form (<tag />) */
AM 117     static $void_elements = array('area', 'base', 'br', 'col', 'command', 'embed', 'hr',
118         'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
7ac944 119     );
AM 120
121     /* State for linked objects in HTML */
122     public $extlinks = false;
123
124     /* Current settings */
125     private $config = array();
126
127     /* Registered callback functions for tags */
128     private $handlers = array();
129
130     /* Allowed HTML elements */
131     private $_html_elements = array();
132
133     /* Ignore these HTML tags but process their content */
134     private $_ignore_elements = array();
135
cb3e2f 136     /* Elements which could be empty and be returned in short form (<tag />) */
AM 137     private $_void_elements = array();
7ac944 138
AM 139     /* Allowed HTML attributes */
140     private $_html_attribs = array();
141
a89940 142     /* Max nesting level */
AM 143     private $max_nesting_level;
144
7ac944 145
AM 146     /**
147      * Class constructor
148      */
149     public function __construct($p = array())
150     {
151         $this->_html_elements   = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements) ;
152         $this->_html_attribs    = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
153         $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
cb3e2f 154         $this->_void_elements   = array_flip((array)$p['void_elements']) + array_flip(self::$void_elements);
7ac944 155
cb3e2f 156         unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['void_elements']);
7ac944 157
AM 158         $this->config = $p + array('show_washed' => true, 'allow_remote' => false, 'cid_map' => array());
159     }
160
161     /**
162      * Register a callback function for a certain tag
163      */
164     public function add_callback($tagName, $callback)
165     {
166         $this->handlers[$tagName] = $callback;
167     }
168
169     /**
170      * Check CSS style
171      */
172     private function wash_style($style)
173     {
174         $s = '';
175
176         foreach (explode(';', $style) as $declaration) {
177             if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
178                 $cssid = $match[1];
179                 $str   = $match[2];
180                 $value = '';
181
182                 while (sizeof($str) > 0 &&
183                     preg_match('/^(url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)'./*1,2*/
184                         '|rgb\(\s*[0-9]+\s*,\s*[0-9]+\s*,\s*[0-9]+\s*\)'.
185                         '|-?[0-9.]+\s*(em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)?'.
186                         '|#[0-9a-f]{3,6}'.
c0dda0 187                         '|[a-z0-9"\', -]+'.
7ac944 188                         ')\s*/i', $str, $match)
AM 189                 ) {
190                     if ($match[2]) {
191                         if (($src = $this->config['cid_map'][$match[2]])
192                             || ($src = $this->config['cid_map'][$this->config['base_url'].$match[2]])
193                         ) {
194                             $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
195                         }
196                         else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $match[2], $url)) {
197                             if ($this->config['allow_remote']) {
198                                 $value .= ' url('.htmlspecialchars($url[0], ENT_QUOTES).')';
199                             }
200                             else {
201                                 $this->extlinks = true;
202                             }
203                         }
204                         else if (preg_match('/^data:.+/i', $match[2])) { // RFC2397
205                             $value .= ' url('.htmlspecialchars($match[2], ENT_QUOTES).')';
206                         }
207                     }
208                     else {
209                         // whitelist ?
210                         $value .= ' ' . $match[0];
211
212                         // #1488535: Fix size units, so width:800 would be changed to width:800px
213                         if (preg_match('/(left|right|top|bottom|width|height)/i', $cssid)
214                             && preg_match('/^[0-9]+$/', $match[0])
215                         ) {
216                             $value .= 'px';
217                         }
218                     }
219
220                     $str = substr($str, strlen($match[0]));
221                 }
222
223                 if (isset($value[0])) {
224                     $s .= ($s?' ':'') . $cssid . ':' . $value . ';';
225                 }
226             }
227         }
228
229         return $s;
230     }
231
232     /**
233      * Take a node and return allowed attributes and check values
234      */
235     private function wash_attribs($node)
236     {
237         $t = '';
238         $washed = '';
239
240         foreach ($node->attributes as $key => $plop) {
241             $key   = strtolower($key);
242             $value = $node->getAttribute($key);
243
244             if (isset($this->_html_attribs[$key]) ||
1f910c 245                 ($key == 'href' && ($value = trim($value))
AM 246                     && !preg_match('!^(javascript|vbscript|data:text)!i', $value)
7ac944 247                     && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value))
AM 248             ) {
249                 $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
250             }
251             else if ($key == 'style' && ($style = $this->wash_style($value))) {
252                 $quot = strpos($style, '"') !== false ? "'" : '"';
253                 $t .= ' style=' . $quot . $style . $quot;
254             }
255             else if ($key == 'background' || ($key == 'src' && strtolower($node->tagName) == 'img')) { //check tagName anyway
256                 if (($src = $this->config['cid_map'][$value])
257                     || ($src = $this->config['cid_map'][$this->config['base_url'].$value])
258                 ) {
259                     $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
260                 }
261                 else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
262                     if ($this->config['allow_remote']) {
263                         $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
264                     }
265                     else {
266                         $this->extlinks = true;
267                         if ($this->config['blocked_src']) {
268                             $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
269                         }
270                     }
271                 }
272                 else if (preg_match('/^data:.+/i', $value)) { // RFC2397
273                     $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
274                 }
275             }
276             else {
277                 $washed .= ($washed ? ' ' : '') . $key;
278             }
279         }
280
281         return $t . ($washed && $this->config['show_washed'] ? ' x-washed="'.$washed.'"' : '');
282     }
283
284     /**
285      * The main loop that recurse on a node tree.
7883ec 286      * It output only allowed tags with allowed attributes and allowed inline styles
AM 287      *
288      * @param DOMNode $node  HTML element
289      * @param int     $level Recurrence level (safe initial value found empirically)
7ac944 290      */
7883ec 291     private function dumpHtml($node, $level = 20)
7ac944 292     {
AM 293         if (!$node->hasChildNodes()) {
294             return '';
a89940 295         }
AM 296
297         $level++;
298
299         if ($this->max_nesting_level > 0 && $level == $this->max_nesting_level - 1) {
300             // log error message once
301             if (!$this->max_nesting_level_error) {
302                 $this->max_nesting_level_error = true;
303                 rcube::raise_error(array('code' => 500, 'type' => 'php',
304                     'line' => __LINE__, 'file' => __FILE__,
305                     'message' => "Maximum nesting level exceeded (xdebug.max_nesting_level={$this->max_nesting_level})"),
306                     true, false);
307             }
308             return '<!-- ignored -->';
7ac944 309         }
AM 310
311         $node = $node->firstChild;
312         $dump = '';
313
314         do {
315             switch($node->nodeType) {
316             case XML_ELEMENT_NODE: //Check element
317                 $tagName = strtolower($node->tagName);
318                 if ($callback = $this->handlers[$tagName]) {
319                     $dump .= call_user_func($callback, $tagName,
a89940 320                         $this->wash_attribs($node), $this->dumpHtml($node, $level), $this);
7ac944 321                 }
AM 322                 else if (isset($this->_html_elements[$tagName])) {
a89940 323                     $content = $this->dumpHtml($node, $level);
7ac944 324                     $dump .= '<' . $tagName . $this->wash_attribs($node) .
cb3e2f 325                         ($content === '' && isset($this->_void_elements[$tagName]) ? ' />' : ">$content</$tagName>");
7ac944 326                 }
AM 327                 else if (isset($this->_ignore_elements[$tagName])) {
328                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
329                 }
330                 else {
331                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
a89940 332                     $dump .= $this->dumpHtml($node, $level); // ignore tags not its content
7ac944 333                 }
AM 334                 break;
335
336             case XML_CDATA_SECTION_NODE:
337                 $dump .= $node->nodeValue;
338                 break;
339
340             case XML_TEXT_NODE:
341                 $dump .= htmlspecialchars($node->nodeValue);
342                 break;
343
344             case XML_HTML_DOCUMENT_NODE:
a89940 345                 $dump .= $this->dumpHtml($node, $level);
7ac944 346                 break;
AM 347
348             case XML_DOCUMENT_TYPE_NODE:
349                 break;
350
351             default:
a89940 352                 $dump .= '<!-- node type ' . $node->nodeType . ' -->';
7ac944 353             }
AM 354         } while($node = $node->nextSibling);
355
356         return $dump;
357     }
358
359     /**
360      * Main function, give it untrusted HTML, tell it if you allow loading
361      * remote images and give it a map to convert "cid:" urls.
362      */
363     public function wash($html)
364     {
365         // Charset seems to be ignored (probably if defined in the HTML document)
366         $node = new DOMDocument('1.0', $this->config['charset']);
367         $this->extlinks = false;
368
369         $html = $this->cleanup($html);
370
371         // Find base URL for images
372         if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches)) {
373             $this->config['base_url'] = $matches[1];
374         }
375         else {
376             $this->config['base_url'] = '';
377         }
a89940 378
AM 379         // Detect max nesting level (for dumpHTML) (#1489110)
380         $this->max_nesting_level = (int) @ini_get('xdebug.max_nesting_level');
7ac944 381
bfd24f 382         // Use optimizations if supported
AM 383         if (version_compare(PHP_VERSION, '5.4.0', '>=')) {
384             @$node->loadHTML($html, LIBXML_PARSEHUGE | LIBXML_COMPACT);
385         }
386         else {
387             @$node->loadHTML($html);
388         }
389
7ac944 390         return $this->dumpHtml($node);
AM 391     }
392
393     /**
394      * Getter for config parameters
395      */
396     public function get_config($prop)
397     {
398         return $this->config[$prop];
399     }
400
401     /**
402      * Clean HTML input
403      */
404     private function cleanup($html)
405     {
406         // special replacements (not properly handled by washtml class)
407         $html_search = array(
408             '/(<\/nobr>)(\s+)(<nobr>)/i',       // space(s) between <NOBR>
409             '/<title[^>]*>[^<]*<\/title>/i',    // PHP bug #32547 workaround: remove title tag
410             '/^(\0\0\xFE\xFF|\xFF\xFE\0\0|\xFE\xFF|\xFF\xFE|\xEF\xBB\xBF)/',    // byte-order mark (only outlook?)
411             '/<html\s[^>]+>/i',                 // washtml/DOMDocument cannot handle xml namespaces
412         );
413
414         $html_replace = array(
415             '\\1'.' &nbsp; '.'\\3',
416             '',
417             '',
418             '<html>',
419         );
420         $html = preg_replace($html_search, $html_replace, trim($html));
421
b6a640 422         //-> Replace all of those weird MS Word quotes and other high characters
c72507 423         $badwordchars = array(
b6a640 424             "\xe2\x80\x98", // left single quote
R 425             "\xe2\x80\x99", // right single quote
426             "\xe2\x80\x9c", // left double quote
427             "\xe2\x80\x9d", // right double quote
428             "\xe2\x80\x94", // em dash
429             "\xe2\x80\xa6" // elipses
430         );
c72507 431         $fixedwordchars = array(
b6a640 432             "'",
R 433             "'",
434             '"',
435             '"',
436             '&mdash;',
437             '...'
438         );
c72507 439         $html = str_replace($badwordchars, $fixedwordchars, $html);
b6a640 440
7ac944 441         // PCRE errors handling (#1486856), should we use something like for every preg_* use?
AM 442         if ($html === null && ($preg_error = preg_last_error()) != PREG_NO_ERROR) {
443             $errstr = "Could not clean up HTML message! PCRE Error: $preg_error.";
444
445             if ($preg_error == PREG_BACKTRACK_LIMIT_ERROR) {
446                 $errstr .= " Consider raising pcre.backtrack_limit!";
447             }
448             if ($preg_error == PREG_RECURSION_LIMIT_ERROR) {
449                 $errstr .= " Consider raising pcre.recursion_limit!";
450             }
451
452             rcube::raise_error(array('code' => 620, 'type' => 'php',
453                 'line' => __LINE__, 'file' => __FILE__,
454                 'message' => $errstr), true, false);
a89940 455
7ac944 456             return '';
AM 457         }
458
459         // fix (unknown/malformed) HTML tags before "wash"
ffec85 460         $html = preg_replace_callback('/(<(?!\!)[\/]*)([^\s>]+)([^>]*)/', array($this, 'html_tag_callback'), $html);
7ac944 461
AM 462         // Remove invalid HTML comments (#1487759)
463         // Don't remove valid conditional comments
1bce14 464         // Don't remove MSOutlook (<!-->) conditional comments (#1489004)
AM 465         $html = preg_replace('/<!--[^->\[\n]+>/', '', $html);
c72507 466
AM 467         // fix broken nested lists
468         self::fix_broken_lists($html);
7ac944 469
AM 470         // turn relative into absolute urls
471         $html = self::resolve_base($html);
472
473         return $html;
474     }
475
476     /**
477      * Callback function for HTML tags fixing
478      */
479     public static function html_tag_callback($matches)
480     {
481         $tagname = $matches[2];
482         $tagname = preg_replace(array(
483             '/:.*$/',               // Microsoft's Smart Tags <st1:xxxx>
484             '/[^a-z0-9_\[\]\!-]/i', // forbidden characters
485         ), '', $tagname);
486
ffec85 487         // fix invalid closing tags - remove any attributes (#1489446)
AM 488         if ($matches[1] == '</') {
489             $matches[3] = '';
490         }
491
492         return $matches[1] . $tagname . $matches[3];
7ac944 493     }
AM 494
495     /**
496      * Convert all relative URLs according to a <base> in HTML
497      */
498     public static function resolve_base($body)
499     {
500         // check for <base href=...>
501         if (preg_match('!(<base.*href=["\']?)([hftps]{3,5}://[a-z0-9/.%-]+)!i', $body, $regs)) {
502             $replacer = new rcube_base_replacer($regs[2]);
503             $body     = $replacer->replace($body);
504         }
505
506         return $body;
507     }
508
c72507 509     /**
AM 510      * Fix broken nested lists, they are not handled properly by DOMDocument (#1488768)
511      */
512     public static function fix_broken_lists(&$html)
513     {
514         // do two rounds, one for <ol>, one for <ul>
515         foreach (array('ol', 'ul') as $tag) {
516             $pos = 0;
517             while (($pos = stripos($html, '<' . $tag, $pos)) !== false) {
518                 $pos++;
519
520                 // make sure this is an ol/ul tag
521                 if (!in_array($html[$pos+2], array(' ', '>'))) {
522                     continue;
523                 }
524
525                 $p      = $pos;
526                 $in_li  = false;
527                 $li_pos = 0;
528
529                 while (($p = strpos($html, '<', $p)) !== false) {
530                     $tt = strtolower(substr($html, $p, 4));
531
532                     // li open tag
533                     if ($tt == '<li>' || $tt == '<li ') {
534                         $in_li = true;
535                         $p += 4;
536                     }
537                     // li close tag
538                     else if ($tt == '</li' && in_array($html[$p+4], array(' ', '>'))) {
539                         $li_pos = $p;
540                         $p += 4;
541                         $in_li = false;
542                     }
543                     // ul/ol closing tag
544                     else if ($tt == '</' . $tag && in_array($html[$p+4], array(' ', '>'))) {
545                         break;
546                     }
547                     // nested ol/ul element out of li
548                     else if (!$in_li && $li_pos && ($tt == '<ol>' || $tt == '<ol ' || $tt == '<ul>' || $tt == '<ul ')) {
549                         // find closing tag of this ul/ol element
550                         $element = substr($tt, 1, 2);
551                         $cpos    = $p;
552                         do {
553                             $tpos = stripos($html, '<' . $element, $cpos+1);
554                             $cpos = stripos($html, '</' . $element, $cpos+1);
555                         }
556                         while ($tpos !== false && $cpos !== false && $cpos > $tpos);
557
558                         // not found, this is invalid HTML, skip it
559                         if ($cpos === false) {
560                             break;
561                         }
562
563                         // get element content
564                         $end     = strpos($html, '>', $cpos);
565                         $len     = $end - $p + 1;
566                         $element = substr($html, $p, $len);
567
568                         // move element to the end of the last li
569                         $html    = substr_replace($html, '', $p, $len);
570                         $html    = substr_replace($html, $element, $li_pos, 0);
571
572                         $p = $end;
573                     }
574                     else {
575                         $p++;
576                     }
577                 }
578             }
579         }
580     }
581 }