source: github/program/lib/washtml.php @ 6236838

release-0.8
Last change on this file since 6236838 was 6236838, checked in by Aleksander Machniak <alec@…>, 12 months ago

Fix handling of some HTML tags e.g. IMG (#1488471) - reworked fix for #1486812

  • Property mode set to 100644
File size: 12.4 KB
Line 
1<?php
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 *
36 * $washer = new washtml($config);
37 * $washer->wash($html);
38 * It return a sanityzed string of the $html parameter without html and head tags.
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.
46 * $washer->extlinks is a reference to a boolean that is set to true if remote images were removed. (FE: show remote images link)
47 *
48 * INTERNALS:
49 *
50 * Only tags and attributes in the static lists $html_elements and $html_attributes
51 * are kept, inline styles are also filtered: all style identifiers matching
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 *  - ...
72 *
73 * Roundcube Changes:
74 * - added $block_elements
75 * - changed $ignore_elements behaviour
76 * - added RFC2397 support
77 * - base URL support
78 * - invalid HTML comments removal before parsing
79 */
80
81class washtml
82{
83  /* Allowed HTML elements (default) */
84  static $html_elements = array('a', 'abbr', 'acronym', 'address', 'area', 'b',
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
95  /* Ignore these HTML tags and their content */
96  static $ignore_elements = array('script', 'applet', 'embed', 'object', 'style');
97
98  /* Allowed HTML attributes */
99  static $html_attribs = array('name', 'class', 'title', 'alt', 'width', 'height',
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  );
108
109  /* Block elements which could be empty but cannot be returned in short form (<tag />) */
110  static $block_elements = array('div', 'p', 'pre', 'blockquote', 'a', 'font', 'center',
111    'table', 'ul', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ol', 'dl', 'strong', 'i', 'b', 'u', 'span');
112
113  /* State for linked objects in HTML */
114  public $extlinks = false;
115
116  /* Current settings */
117  private $config = array();
118
119  /* Registered callback functions for tags */
120  private $handlers = array();
121
122  /* Allowed HTML elements */
123  private $_html_elements = array();
124
125  /* Ignore these HTML tags but process their content */
126  private $_ignore_elements = array();
127
128  /* Block elements which could be empty but cannot be returned in short form (<tag />) */
129  private $_block_elements = array();
130
131  /* Allowed HTML attributes */
132  private $_html_attribs = array();
133
134
135  /* Constructor */
136  public function __construct($p = array())
137  {
138    $this->_html_elements = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements) ;
139    $this->_html_attribs = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
140    $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
141    $this->_block_elements = array_flip((array)$p['block_elements']) + array_flip(self::$block_elements);
142    unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['block_elements']);
143    $this->config = $p + array('show_washed'=>true, 'allow_remote'=>false, 'cid_map'=>array());
144  }
145
146  /* Register a callback function for a certain tag */
147  public function add_callback($tagName, $callback)
148  {
149    $this->handlers[$tagName] = $callback;
150  }
151
152  /* Check CSS style */
153  private function wash_style($style)
154  {
155    $s = '';
156
157    foreach (explode(';', $style) as $declaration) {
158      if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
159        $cssid = $match[1];
160        $str = $match[2];
161        $value = '';
162        while (sizeof($str) > 0 &&
163          preg_match('/^(url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)'./*1,2*/
164                 '|rgb\(\s*[0-9]+\s*,\s*[0-9]+\s*,\s*[0-9]+\s*\)'.
165                 '|-?[0-9.]+\s*(em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)?'.
166                 '|#[0-9a-f]{3,6}|[a-z0-9", -]+'.
167                 ')\s*/i', $str, $match)) {
168          if ($match[2]) {
169            if (($src = $this->config['cid_map'][$match[2]])
170                || ($src = $this->config['cid_map'][$this->config['base_url'].$match[2]])) {
171              $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
172            }
173            else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $match[2], $url)) {
174              if ($this->config['allow_remote'])
175                $value .= ' url('.htmlspecialchars($url[0], ENT_QUOTES).')';
176              else
177                $this->extlinks = true;
178            }
179            else if (preg_match('/^data:.+/i', $match[2])) { // RFC2397
180              $value .= ' url('.htmlspecialchars($match[2], ENT_QUOTES).')';
181            }
182          }
183          else if ($match[0] != 'url' && $match[0] != 'rgb') //whitelist ?
184            $value .= ' ' . $match[0];
185
186          $str = substr($str, strlen($match[0]));
187        }
188        if ($value)
189          $s .= ($s?' ':'') . $cssid . ':' . $value . ';';
190      }
191    }
192    return $s;
193  }
194
195  /* Take a node and return allowed attributes and check values */
196  private function wash_attribs($node)
197  {
198    $t = '';
199    $washed;
200
201    foreach ($node->attributes as $key => $plop) {
202      $key = strtolower($key);
203      $value = $node->getAttribute($key);
204      if (isset($this->_html_attribs[$key]) ||
205         ($key == 'href' && preg_match('!^(http:|https:|ftp:|mailto:|//|#).+!i', $value)))
206        $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
207      else if ($key == 'style' && ($style = $this->wash_style($value))) {
208        $quot = strpos($style, '"') !== false ? "'" : '"';
209        $t .= ' style=' . $quot . $style . $quot;
210      }
211      else if ($key == 'background' || ($key == 'src' && strtolower($node->tagName) == 'img')) { //check tagName anyway
212        if (($src = $this->config['cid_map'][$value])
213            || ($src = $this->config['cid_map'][$this->config['base_url'].$value])) {
214          $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
215        }
216        else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
217          if ($this->config['allow_remote'])
218            $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
219          else {
220            $this->extlinks = true;
221            if ($this->config['blocked_src'])
222              $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
223          }
224        }
225        else if (preg_match('/^data:.+/i', $value)) { // RFC2397
226          $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
227        }
228      } else
229        $washed .= ($washed?' ':'') . $key;
230    }
231    return $t . ($washed && $this->config['show_washed']?' x-washed="'.$washed.'"':'');
232  }
233
234  /* The main loop that recurse on a node tree.
235   * It output only allowed tags with allowed attributes
236   * and allowed inline styles */
237  private function dumpHtml($node)
238  {
239    if(!$node->hasChildNodes())
240      return '';
241
242    $node = $node->firstChild;
243    $dump = '';
244
245    do {
246      switch($node->nodeType) {
247      case XML_ELEMENT_NODE: //Check element
248        $tagName = strtolower($node->tagName);
249        if ($callback = $this->handlers[$tagName]) {
250          $dump .= call_user_func($callback, $tagName, $this->wash_attribs($node), $this->dumpHtml($node), $this);
251        }
252        else if (isset($this->_html_elements[$tagName])) {
253          $content = $this->dumpHtml($node);
254          $dump .= '<' . $tagName . $this->wash_attribs($node) .
255            ($content != '' || isset($this->_block_elements[$tagName]) ? ">$content</$tagName>" : ' />');
256        }
257        else if (isset($this->_ignore_elements[$tagName])) {
258          $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
259        }
260        else {
261          $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
262          $dump .= $this->dumpHtml($node); // ignore tags not its content
263        }
264        break;
265      case XML_CDATA_SECTION_NODE:
266        $dump .= $node->nodeValue;
267        break;
268      case XML_TEXT_NODE:
269        $dump .= htmlspecialchars($node->nodeValue);
270        break;
271      case XML_HTML_DOCUMENT_NODE:
272        $dump .= $this->dumpHtml($node);
273        break;
274      case XML_DOCUMENT_TYPE_NODE:
275        break;
276      default:
277        $dump . '<!-- node type ' . $node->nodeType . ' -->';
278      }
279    } while($node = $node->nextSibling);
280
281    return $dump;
282  }
283
284  /* Main function, give it untrusted HTML, tell it if you allow loading
285   * remote images and give it a map to convert "cid:" urls. */
286  public function wash($html)
287  {
288    // Charset seems to be ignored (probably if defined in the HTML document)
289    $node = new DOMDocument('1.0', $this->config['charset']);
290    $this->extlinks = false;
291
292    // Find base URL for images
293    if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches))
294      $this->config['base_url'] = $matches[1];
295    else
296      $this->config['base_url'] = '';
297
298    // Remove invalid HTML comments (#1487759)
299    // Don't remove valid conditional comments
300    $html = preg_replace('/<!--[^->[\n]*>/', '', $html);
301
302    @$node->loadHTML($html);
303    return $this->dumpHtml($node);
304  }
305
306  /**
307   * Getter for config parameters
308   */
309  public function get_config($prop)
310  {
311      return $this->config[$prop];
312  }
313
314}
Note: See TracBrowser for help on using the repository browser.