Thomas Bruederli
2013-10-07 120db629b0645033fd6a477b9f96cc8dad589213
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}'.
187                         '|[a-z0-9", -]+'.
188                         ')\s*/i', $str, $match)
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.
286      * It output only allowed tags with allowed attributes
287      * and allowed inline styles
288      */
a89940 289     private function dumpHtml($node, $level = 0)
7ac944 290     {
AM 291         if (!$node->hasChildNodes()) {
292             return '';
a89940 293         }
AM 294
295         $level++;
296
297         if ($this->max_nesting_level > 0 && $level == $this->max_nesting_level - 1) {
298             // log error message once
299             if (!$this->max_nesting_level_error) {
300                 $this->max_nesting_level_error = true;
301                 rcube::raise_error(array('code' => 500, 'type' => 'php',
302                     'line' => __LINE__, 'file' => __FILE__,
303                     'message' => "Maximum nesting level exceeded (xdebug.max_nesting_level={$this->max_nesting_level})"),
304                     true, false);
305             }
306             return '<!-- ignored -->';
7ac944 307         }
AM 308
309         $node = $node->firstChild;
310         $dump = '';
311
312         do {
313             switch($node->nodeType) {
314             case XML_ELEMENT_NODE: //Check element
315                 $tagName = strtolower($node->tagName);
316                 if ($callback = $this->handlers[$tagName]) {
317                     $dump .= call_user_func($callback, $tagName,
a89940 318                         $this->wash_attribs($node), $this->dumpHtml($node, $level), $this);
7ac944 319                 }
AM 320                 else if (isset($this->_html_elements[$tagName])) {
a89940 321                     $content = $this->dumpHtml($node, $level);
7ac944 322                     $dump .= '<' . $tagName . $this->wash_attribs($node) .
cb3e2f 323                         ($content === '' && isset($this->_void_elements[$tagName]) ? ' />' : ">$content</$tagName>");
7ac944 324                 }
AM 325                 else if (isset($this->_ignore_elements[$tagName])) {
326                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
327                 }
328                 else {
329                     $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
a89940 330                     $dump .= $this->dumpHtml($node, $level); // ignore tags not its content
7ac944 331                 }
AM 332                 break;
333
334             case XML_CDATA_SECTION_NODE:
335                 $dump .= $node->nodeValue;
336                 break;
337
338             case XML_TEXT_NODE:
339                 $dump .= htmlspecialchars($node->nodeValue);
340                 break;
341
342             case XML_HTML_DOCUMENT_NODE:
a89940 343                 $dump .= $this->dumpHtml($node, $level);
7ac944 344                 break;
AM 345
346             case XML_DOCUMENT_TYPE_NODE:
347                 break;
348
349             default:
a89940 350                 $dump .= '<!-- node type ' . $node->nodeType . ' -->';
7ac944 351             }
AM 352         } while($node = $node->nextSibling);
353
354         return $dump;
355     }
356
357     /**
358      * Main function, give it untrusted HTML, tell it if you allow loading
359      * remote images and give it a map to convert "cid:" urls.
360      */
361     public function wash($html)
362     {
363         // Charset seems to be ignored (probably if defined in the HTML document)
364         $node = new DOMDocument('1.0', $this->config['charset']);
365         $this->extlinks = false;
366
367         $html = $this->cleanup($html);
368
369         // Find base URL for images
370         if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches)) {
371             $this->config['base_url'] = $matches[1];
372         }
373         else {
374             $this->config['base_url'] = '';
375         }
a89940 376
AM 377         // Detect max nesting level (for dumpHTML) (#1489110)
378         $this->max_nesting_level = (int) @ini_get('xdebug.max_nesting_level');
7ac944 379
bfd24f 380         // Use optimizations if supported
AM 381         if (version_compare(PHP_VERSION, '5.4.0', '>=')) {
382             @$node->loadHTML($html, LIBXML_PARSEHUGE | LIBXML_COMPACT);
383         }
384         else {
385             @$node->loadHTML($html);
386         }
387
7ac944 388         return $this->dumpHtml($node);
AM 389     }
390
391     /**
392      * Getter for config parameters
393      */
394     public function get_config($prop)
395     {
396         return $this->config[$prop];
397     }
398
399     /**
400      * Clean HTML input
401      */
402     private function cleanup($html)
403     {
404         // special replacements (not properly handled by washtml class)
405         $html_search = array(
406             '/(<\/nobr>)(\s+)(<nobr>)/i',       // space(s) between <NOBR>
407             '/<title[^>]*>[^<]*<\/title>/i',    // PHP bug #32547 workaround: remove title tag
408             '/^(\0\0\xFE\xFF|\xFF\xFE\0\0|\xFE\xFF|\xFF\xFE|\xEF\xBB\xBF)/',    // byte-order mark (only outlook?)
409             '/<html\s[^>]+>/i',                 // washtml/DOMDocument cannot handle xml namespaces
410         );
411
412         $html_replace = array(
413             '\\1'.' &nbsp; '.'\\3',
414             '',
415             '',
416             '<html>',
417         );
418         $html = preg_replace($html_search, $html_replace, trim($html));
419
b6a640 420         //-> Replace all of those weird MS Word quotes and other high characters
R 421         $badwordchars=array(
422             "\xe2\x80\x98", // left single quote
423             "\xe2\x80\x99", // right single quote
424             "\xe2\x80\x9c", // left double quote
425             "\xe2\x80\x9d", // right double quote
426             "\xe2\x80\x94", // em dash
427             "\xe2\x80\xa6" // elipses
428         );
429         $fixedwordchars=array(
430             "'",
431             "'",
432             '"',
433             '"',
434             '&mdash;',
435             '...'
436         );
437         $html = str_replace($badwordchars,$fixedwordchars, $html);
438
7ac944 439         // PCRE errors handling (#1486856), should we use something like for every preg_* use?
AM 440         if ($html === null && ($preg_error = preg_last_error()) != PREG_NO_ERROR) {
441             $errstr = "Could not clean up HTML message! PCRE Error: $preg_error.";
442
443             if ($preg_error == PREG_BACKTRACK_LIMIT_ERROR) {
444                 $errstr .= " Consider raising pcre.backtrack_limit!";
445             }
446             if ($preg_error == PREG_RECURSION_LIMIT_ERROR) {
447                 $errstr .= " Consider raising pcre.recursion_limit!";
448             }
449
450             rcube::raise_error(array('code' => 620, 'type' => 'php',
451                 'line' => __LINE__, 'file' => __FILE__,
452                 'message' => $errstr), true, false);
a89940 453
7ac944 454             return '';
AM 455         }
456
457         // fix (unknown/malformed) HTML tags before "wash"
af79a7 458         $html = preg_replace_callback('/(<(?!\!)[\/]*)([^\s>]+)/', array($this, 'html_tag_callback'), $html);
7ac944 459
AM 460         // Remove invalid HTML comments (#1487759)
461         // Don't remove valid conditional comments
1bce14 462         // Don't remove MSOutlook (<!-->) conditional comments (#1489004)
AM 463         $html = preg_replace('/<!--[^->\[\n]+>/', '', $html);
7ac944 464
AM 465         // turn relative into absolute urls
466         $html = self::resolve_base($html);
467
468         return $html;
469     }
470
471     /**
472      * Callback function for HTML tags fixing
473      */
474     public static function html_tag_callback($matches)
475     {
476         $tagname = $matches[2];
477         $tagname = preg_replace(array(
478             '/:.*$/',               // Microsoft's Smart Tags <st1:xxxx>
479             '/[^a-z0-9_\[\]\!-]/i', // forbidden characters
480         ), '', $tagname);
481
482         return $matches[1] . $tagname;
483     }
484
485     /**
486      * Convert all relative URLs according to a <base> in HTML
487      */
488     public static function resolve_base($body)
489     {
490         // check for <base href=...>
491         if (preg_match('!(<base.*href=["\']?)([hftps]{3,5}://[a-z0-9/.%-]+)!i', $body, $regs)) {
492             $replacer = new rcube_base_replacer($regs[2]);
493             $body     = $replacer->replace($body);
494         }
495
496         return $body;
497     }
498 }
499