thomascube
2011-12-22 f38dfc294abde08a7296f871397f18c2162f143d
commit | author | age
45f56c 1 <?php
T 2 /*                Washtml, a HTML sanityzer.
3  *
4  * Copyright (c) 2007 Frederic Motte <fmotte@ubixis.com>
5  * All rights reserved.
6  *
7  * Redistribution and use in source and binary forms, with or without
8  * modification, are permitted provided that the following conditions
9  * are met:
10  * 1. Redistributions of source code must retain the above copyright
11  *    notice, this list of conditions and the following disclaimer.
12  * 2. Redistributions in binary form must reproduce the above copyright
13  *    notice, this list of conditions and the following disclaimer in the
14  *    documentation and/or other materials provided with the distribution.
15  *
16  * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
17  * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18  * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19  * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20  * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21  * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25  * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26  */
27
28 /* Please send me your comments about this code if you have some, thanks, Fred. */
29
30 /* OVERVIEW:
31  *
32  * Wahstml take an untrusted HTML and return a safe html string.
33  *
34  * SYNOPSIS:
35  *
21e724 36  * $washer = new washtml($config);
T 37  * $washer->wash($html);
45f56c 38  * It return a sanityzed string of the $html parameter without html and head tags.
T 39  * $html is a string containing the html code to wash.
40  * $config is an array containing options:
41  *   $config['allow_remote'] is a boolean to allow link to remote images.
42  *   $config['blocked_src'] string with image-src to be used for blocked remote images
43  *   $config['show_washed'] is a boolean to include washed out attributes as x-washed
44  *   $config['cid_map'] is an array where cid urls index urls to replace them.
45  *   $config['charset'] is a string containing the charset of the HTML document if it is not defined in it.
21e724 46  * $washer->extlinks is a reference to a boolean that is set to true if remote images were removed. (FE: show remote images link)
45f56c 47  *
T 48  * INTERNALS:
49  *
21e724 50  * Only tags and attributes in the static lists $html_elements and $html_attributes
45f56c 51  * are kept, inline styles are also filtered: all style identifiers matching
T 52  * /[a-z\-]/i are allowed. Values matching colors, sizes, /[a-z\-]/i and safe
53  * urls if allowed and cid urls if mapped are kept.
54  *
55  * BUGS: It MUST be safe !
56  *  - Check regexp
57  *  - urlencode URLs instead of htmlspecials
58  *  - Check is a 3 bytes utf8 first char can eat '">'
59  *  - Update PCRE: CVE-2007-1659 - CVE-2007-1660 - CVE-2007-1661 - CVE-2007-1662 
60  *                 CVE-2007-4766 - CVE-2007-4767 - CVE-2007-4768  
61  *    http://lists.debian.org/debian-security-announce/debian-security-announce-2007/msg00177.html 
62  *  - ...
63  *
64  * MISSING:
65  *  - relative links, can be implemented by prefixing an absolute path, ask me
66  *    if you need it...
67  *  - ...
68  *
69  * Dont be a fool:
70  *  - Dont alter data on a GET: '<img src="http://yourhost/mail?action=delete&uid=3267" />'
71  *  - ...
2337a8 72  *
A 73  * Roundcube Changes:
74  * - added $block_elements
75  * - changed $ignore_elements behaviour
0b7f3a 76  * - added RFC2397 support
b6f040 77  * - base URL support
9ebac6 78  * - invalid HTML comments removal before parsing
45f56c 79  */
T 80
81 class washtml
82 {
21e724 83   /* Allowed HTML elements (default) */
c1fcd1 84   static $html_elements = array('a', 'abbr', 'acronym', 'address', 'area', 'b',
A 85     'basefont', 'bdo', 'big', 'blockquote', 'br', 'caption', 'center',
86     'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl',
87     'dt', 'em', 'fieldset', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i',
88     'ins', 'label', 'legend', 'li', 'map', 'menu', 'nobr', 'ol', 'p', 'pre', 'q',
89     's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
90     'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', 'img',
91     // form elements
92     'button', 'input', 'textarea', 'select', 'option', 'optgroup'
93   );
94
2337a8 95   /* Ignore these HTML tags and their content */
A 96   static $ignore_elements = array('script', 'applet', 'embed', 'object', 'style');
c1fcd1 97
21e724 98   /* Allowed HTML attributes */
c1fcd1 99   static $html_attribs = array('name', 'class', 'title', 'alt', 'width', 'height',
A 100     'align', 'nowrap', 'col', 'row', 'id', 'rowspan', 'colspan', 'cellspacing',
101     'cellpadding', 'valign', 'bgcolor', 'color', 'border', 'bordercolorlight',
102     'bordercolordark', 'face', 'marginwidth', 'marginheight', 'axis', 'border',
103     'abbr', 'char', 'charoff', 'clear', 'compact', 'coords', 'vspace', 'hspace',
104     'cellborder', 'size', 'lang', 'dir',
105     // attributes of form elements
106     'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value'
107   );
503e01 108
A 109   /* Block elements which could be empty but cannot be returned in short form (<tag />) */
c1fcd1 110   static $block_elements = array('div', 'p', 'pre', 'blockquote', 'a', 'font', 'center',
af4b3b 111     'table', 'ul', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ol', 'dl', 'strong', 'i', 'b', 'u');
c1fcd1 112
21e724 113   /* State for linked objects in HTML */
T 114   public $extlinks = false;
45f56c 115
21e724 116   /* Current settings */
T 117   private $config = array();
118
119   /* Registered callback functions for tags */
120   private $handlers = array();
c1fcd1 121
45f56c 122   /* Allowed HTML elements */
21e724 123   private $_html_elements = array();
T 124
125   /* Ignore these HTML tags but process their content */
126   private $_ignore_elements = array();
45f56c 127
503e01 128   /* Block elements which could be empty but cannot be returned in short form (<tag />) */
A 129   private $_block_elements = array();
130
45f56c 131   /* Allowed HTML attributes */
21e724 132   private $_html_attribs = array();
c1fcd1 133
45f56c 134
21e724 135   /* Constructor */
T 136   public function __construct($p = array()) {
137     $this->_html_elements = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements) ;
138     $this->_html_attribs = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
139     $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
503e01 140     $this->_block_elements = array_flip((array)$p['block_elements']) + array_flip(self::$block_elements);
A 141     unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['block_elements']);
21e724 142     $this->config = $p + array('show_washed'=>true, 'allow_remote'=>false, 'cid_map'=>array());
T 143   }
2eeb12 144
21e724 145   /* Register a callback function for a certain tag */
T 146   public function add_callback($tagName, $callback)
147   {
148     $this->handlers[$tagName] = $callback;
149   }
2eeb12 150
45f56c 151   /* Check CSS style */
21e724 152   private function wash_style($style) {
45f56c 153     $s = '';
T 154
0b7f3a 155     foreach (explode(';', $style) as $declaration) {
A 156       if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
45f56c 157         $cssid = $match[1];
T 158         $str = $match[2];
159         $value = '';
0b7f3a 160         while (sizeof($str) > 0 &&
45f56c 161           preg_match('/^(url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)'./*1,2*/
T 162                  '|rgb\(\s*[0-9]+\s*,\s*[0-9]+\s*,\s*[0-9]+\s*\)'.
163                  '|-?[0-9.]+\s*(em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)?'.
2eeb12 164                  '|#[0-9a-f]{3,6}|[a-z0-9", -]+'.
45f56c 165                  ')\s*/i', $str, $match)) {
0b7f3a 166           if ($match[2]) {
b6f040 167             if (($src = $this->config['cid_map'][$match[2]])
A 168                 || ($src = $this->config['cid_map'][$this->config['base_url'].$match[2]])) {
0b7f3a 169               $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
b6f040 170             }
98c2d6 171             else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $match[2], $url)) {
0b7f3a 172               if ($this->config['allow_remote'])
A 173                 $value .= ' url('.htmlspecialchars($url[0], ENT_QUOTES).')';
45f56c 174               else
21e724 175                 $this->extlinks = true;
c505e5 176             }
a0d29e 177             else if (preg_match('/^data:.+/i', $match[2])) { // RFC2397
A 178               $value .= ' url('.htmlspecialchars($match[2], ENT_QUOTES).')';
0b7f3a 179             }
a0d29e 180           }
2eeb12 181           else if ($match[0] != 'url' && $match[0] != 'rgb') //whitelist ?
45f56c 182             $value .= ' ' . $match[0];
2eeb12 183
45f56c 184           $str = substr($str, strlen($match[0]));
T 185         }
0b7f3a 186         if ($value)
45f56c 187           $s .= ($s?' ':'') . $cssid . ':' . $value . ';';
T 188       }
189     }
190     return $s;
191   }
192
193   /* Take a node and return allowed attributes and check values */
21e724 194   private function wash_attribs($node) {
45f56c 195     $t = '';
T 196     $washed;
197
0b7f3a 198     foreach ($node->attributes as $key => $plop) {
45f56c 199       $key = strtolower($key);
T 200       $value = $node->getAttribute($key);
0b7f3a 201       if (isset($this->_html_attribs[$key]) ||
f38dfc 202          ($key == 'href' && preg_match('!^(http:|https:|ftp:|mailto:|//|#).+!i', $value)))
45f56c 203         $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
2eeb12 204       else if ($key == 'style' && ($style = $this->wash_style($value))) {
A 205         $quot = strpos($style, '"') !== false ? "'" : '"';
206         $t .= ' style=' . $quot . $style . $quot;
207       }
0b7f3a 208       else if ($key == 'background' || ($key == 'src' && strtolower($node->tagName) == 'img')) { //check tagName anyway
b6f040 209         if (($src = $this->config['cid_map'][$value])
A 210             || ($src = $this->config['cid_map'][$this->config['base_url'].$value])) {
c505e5 211           $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
T 212         }
0b7f3a 213         else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
A 214           if ($this->config['allow_remote'])
45f56c 215             $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
T 216           else {
21e724 217             $this->extlinks = true;
T 218             if ($this->config['blocked_src'])
4cc74f 219               $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
45f56c 220           }
c505e5 221         }
0b7f3a 222         else if (preg_match('/^data:.+/i', $value)) { // RFC2397
A 223           $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
224         }
45f56c 225       } else
T 226         $washed .= ($washed?' ':'') . $key;
227     }
21e724 228     return $t . ($washed && $this->config['show_washed']?' x-washed="'.$washed.'"':'');
45f56c 229   }
T 230
231   /* The main loop that recurse on a node tree.
232    * It output only allowed tags with allowed attributes
233    * and allowed inline styles */
21e724 234   private function dumpHtml($node) {
45f56c 235     if(!$node->hasChildNodes())
T 236       return '';
237
238     $node = $node->firstChild;
239     $dump = '';
240
241     do {
242       switch($node->nodeType) {
243       case XML_ELEMENT_NODE: //Check element
244         $tagName = strtolower($node->tagName);
b6f040 245         if ($callback = $this->handlers[$tagName]) {
2b017e 246           $dump .= call_user_func($callback, $tagName, $this->wash_attribs($node), $this->dumpHtml($node), $this);
b6f040 247         }
A 248         else if (isset($this->_html_elements[$tagName])) {
21e724 249           $content = $this->dumpHtml($node);
T 250           $dump .= '<' . $tagName . $this->wash_attribs($node) .
be6f3a 251             // create closing tag for block elements, but also for elements
A 252             // with content or with some attributes (eg. style, class) (#1486812)
253             ($content != '' || $node->hasAttributes() || isset($this->_block_elements[$tagName]) ? ">$content</$tagName>" : ' />');
b6f040 254         }
A 255         else if (isset($this->_ignore_elements[$tagName])) {
45f56c 256           $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
b6f040 257         }
A 258         else {
2337a8 259           $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
A 260           $dump .= $this->dumpHtml($node); // ignore tags not its content
b6f040 261         }
21e724 262         break;
T 263       case XML_CDATA_SECTION_NODE:
264         $dump .= $node->nodeValue;
45f56c 265         break;
T 266       case XML_TEXT_NODE:
267         $dump .= htmlspecialchars($node->nodeValue);
268         break;
269       case XML_HTML_DOCUMENT_NODE:
21e724 270         $dump .= $this->dumpHtml($node);
45f56c 271         break;
21e724 272       case XML_DOCUMENT_TYPE_NODE:
T 273         break;
45f56c 274       default:
21e724 275         $dump . '<!-- node type ' . $node->nodeType . ' -->';
45f56c 276       }
T 277     } while($node = $node->nextSibling);
278
279     return $dump;
280   }
281
282   /* Main function, give it untrusted HTML, tell it if you allow loading
283    * remote images and give it a map to convert "cid:" urls. */
b6f040 284   public function wash($html)
A 285   {
286     // Charset seems to be ignored (probably if defined in the HTML document)
21e724 287     $node = new DOMDocument('1.0', $this->config['charset']);
T 288     $this->extlinks = false;
b6f040 289
A 290     // Find base URL for images
291     if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches))
292       $this->config['base_url'] = $matches[1];
293     else
294       $this->config['base_url'] = '';
295
9ebac6 296     // Remove invalid HTML comments (#1487759)
968754 297     // Don't remove valid conditional comments
e4d094 298     $html = preg_replace('/<!--[^->[\n]*>/', '', $html);
9ebac6 299
45f56c 300     @$node->loadHTML($html);
21e724 301     return $this->dumpHtml($node);
45f56c 302   }
T 303
2b017e 304   /**
T 305    * Getter for config parameters
306    */
307   public function get_config($prop)
308   {
309       return $this->config[$prop];
310   }
311
45f56c 312 }
T 313
2337a8 314 ?>