Aleksander Machniak
2013-06-06 67ac6e354ab6c12e708415d2949936b4dc427275
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
AM 380         @$node->loadHTML($html);
381         return $this->dumpHtml($node);
382     }
383
384     /**
385      * Getter for config parameters
386      */
387     public function get_config($prop)
388     {
389         return $this->config[$prop];
390     }
391
392     /**
393      * Clean HTML input
394      */
395     private function cleanup($html)
396     {
397         // special replacements (not properly handled by washtml class)
398         $html_search = array(
399             '/(<\/nobr>)(\s+)(<nobr>)/i',       // space(s) between <NOBR>
400             '/<title[^>]*>[^<]*<\/title>/i',    // PHP bug #32547 workaround: remove title tag
401             '/^(\0\0\xFE\xFF|\xFF\xFE\0\0|\xFE\xFF|\xFF\xFE|\xEF\xBB\xBF)/',    // byte-order mark (only outlook?)
402             '/<html\s[^>]+>/i',                 // washtml/DOMDocument cannot handle xml namespaces
403         );
404
405         $html_replace = array(
406             '\\1'.' &nbsp; '.'\\3',
407             '',
408             '',
409             '<html>',
410         );
411         $html = preg_replace($html_search, $html_replace, trim($html));
412
413         // PCRE errors handling (#1486856), should we use something like for every preg_* use?
414         if ($html === null && ($preg_error = preg_last_error()) != PREG_NO_ERROR) {
415             $errstr = "Could not clean up HTML message! PCRE Error: $preg_error.";
416
417             if ($preg_error == PREG_BACKTRACK_LIMIT_ERROR) {
418                 $errstr .= " Consider raising pcre.backtrack_limit!";
419             }
420             if ($preg_error == PREG_RECURSION_LIMIT_ERROR) {
421                 $errstr .= " Consider raising pcre.recursion_limit!";
422             }
423
424             rcube::raise_error(array('code' => 620, 'type' => 'php',
425                 'line' => __LINE__, 'file' => __FILE__,
426                 'message' => $errstr), true, false);
a89940 427
7ac944 428             return '';
AM 429         }
430
431         // fix (unknown/malformed) HTML tags before "wash"
432         $html = preg_replace_callback('/(<[\/]*)([^\s>]+)/', array($this, 'html_tag_callback'), $html);
433
434         // Remove invalid HTML comments (#1487759)
435         // Don't remove valid conditional comments
1bce14 436         // Don't remove MSOutlook (<!-->) conditional comments (#1489004)
AM 437         $html = preg_replace('/<!--[^->\[\n]+>/', '', $html);
7ac944 438
AM 439         // turn relative into absolute urls
440         $html = self::resolve_base($html);
441
442         return $html;
443     }
444
445     /**
446      * Callback function for HTML tags fixing
447      */
448     public static function html_tag_callback($matches)
449     {
450         $tagname = $matches[2];
451         $tagname = preg_replace(array(
452             '/:.*$/',               // Microsoft's Smart Tags <st1:xxxx>
453             '/[^a-z0-9_\[\]\!-]/i', // forbidden characters
454         ), '', $tagname);
455
456         return $matches[1] . $tagname;
457     }
458
459     /**
460      * Convert all relative URLs according to a <base> in HTML
461      */
462     public static function resolve_base($body)
463     {
464         // check for <base href=...>
465         if (preg_match('!(<base.*href=["\']?)([hftps]{3,5}://[a-z0-9/.%-]+)!i', $body, $regs)) {
466             $replacer = new rcube_base_replacer($regs[2]);
467             $body     = $replacer->replace($body);
468         }
469
470         return $body;
471     }
472 }
473