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

HEADcourier-fixdev-browser-capabilitiespdorelease-0.8
Last change on this file since f38dfc29 was f38dfc29, checked in by thomascube <thomas@…>, 17 months ago

Accept absolute urls without protocol

  • Property mode set to 100644
File size: 12.6 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');
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    $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);
140    $this->_block_elements = array_flip((array)$p['block_elements']) + array_flip(self::$block_elements);
141    unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['block_elements']);
142    $this->config = $p + array('show_washed'=>true, 'allow_remote'=>false, 'cid_map'=>array());
143  }
144
145  /* Register a callback function for a certain tag */
146  public function add_callback($tagName, $callback)
147  {
148    $this->handlers[$tagName] = $callback;
149  }
150
151  /* Check CSS style */
152  private function wash_style($style) {
153    $s = '';
154
155    foreach (explode(';', $style) as $declaration) {
156      if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
157        $cssid = $match[1];
158        $str = $match[2];
159        $value = '';
160        while (sizeof($str) > 0 &&
161          preg_match('/^(url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)'./*1,2*/
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|%)?'.
164                 '|#[0-9a-f]{3,6}|[a-z0-9", -]+'.
165                 ')\s*/i', $str, $match)) {
166          if ($match[2]) {
167            if (($src = $this->config['cid_map'][$match[2]])
168                || ($src = $this->config['cid_map'][$this->config['base_url'].$match[2]])) {
169              $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
170            }
171            else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $match[2], $url)) {
172              if ($this->config['allow_remote'])
173                $value .= ' url('.htmlspecialchars($url[0], ENT_QUOTES).')';
174              else
175                $this->extlinks = true;
176            }
177            else if (preg_match('/^data:.+/i', $match[2])) { // RFC2397
178              $value .= ' url('.htmlspecialchars($match[2], ENT_QUOTES).')';
179            }
180          }
181          else if ($match[0] != 'url' && $match[0] != 'rgb') //whitelist ?
182            $value .= ' ' . $match[0];
183
184          $str = substr($str, strlen($match[0]));
185        }
186        if ($value)
187          $s .= ($s?' ':'') . $cssid . ':' . $value . ';';
188      }
189    }
190    return $s;
191  }
192
193  /* Take a node and return allowed attributes and check values */
194  private function wash_attribs($node) {
195    $t = '';
196    $washed;
197
198    foreach ($node->attributes as $key => $plop) {
199      $key = strtolower($key);
200      $value = $node->getAttribute($key);
201      if (isset($this->_html_attribs[$key]) ||
202         ($key == 'href' && preg_match('!^(http:|https:|ftp:|mailto:|//|#).+!i', $value)))
203        $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
204      else if ($key == 'style' && ($style = $this->wash_style($value))) {
205        $quot = strpos($style, '"') !== false ? "'" : '"';
206        $t .= ' style=' . $quot . $style . $quot;
207      }
208      else if ($key == 'background' || ($key == 'src' && strtolower($node->tagName) == 'img')) { //check tagName anyway
209        if (($src = $this->config['cid_map'][$value])
210            || ($src = $this->config['cid_map'][$this->config['base_url'].$value])) {
211          $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
212        }
213        else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
214          if ($this->config['allow_remote'])
215            $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
216          else {
217            $this->extlinks = true;
218            if ($this->config['blocked_src'])
219              $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
220          }
221        }
222        else if (preg_match('/^data:.+/i', $value)) { // RFC2397
223          $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
224        }
225      } else
226        $washed .= ($washed?' ':'') . $key;
227    }
228    return $t . ($washed && $this->config['show_washed']?' x-washed="'.$washed.'"':'');
229  }
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 */
234  private function dumpHtml($node) {
235    if(!$node->hasChildNodes())
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);
245        if ($callback = $this->handlers[$tagName]) {
246          $dump .= call_user_func($callback, $tagName, $this->wash_attribs($node), $this->dumpHtml($node), $this);
247        }
248        else if (isset($this->_html_elements[$tagName])) {
249          $content = $this->dumpHtml($node);
250          $dump .= '<' . $tagName . $this->wash_attribs($node) .
251            // create closing tag for block elements, but also for elements
252            // with content or with some attributes (eg. style, class) (#1486812)
253            ($content != '' || $node->hasAttributes() || isset($this->_block_elements[$tagName]) ? ">$content</$tagName>" : ' />');
254        }
255        else if (isset($this->_ignore_elements[$tagName])) {
256          $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
257        }
258        else {
259          $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
260          $dump .= $this->dumpHtml($node); // ignore tags not its content
261        }
262        break;
263      case XML_CDATA_SECTION_NODE:
264        $dump .= $node->nodeValue;
265        break;
266      case XML_TEXT_NODE:
267        $dump .= htmlspecialchars($node->nodeValue);
268        break;
269      case XML_HTML_DOCUMENT_NODE:
270        $dump .= $this->dumpHtml($node);
271        break;
272      case XML_DOCUMENT_TYPE_NODE:
273        break;
274      default:
275        $dump . '<!-- node type ' . $node->nodeType . ' -->';
276      }
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. */
284  public function wash($html)
285  {
286    // Charset seems to be ignored (probably if defined in the HTML document)
287    $node = new DOMDocument('1.0', $this->config['charset']);
288    $this->extlinks = false;
289
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
296    // Remove invalid HTML comments (#1487759)
297    // Don't remove valid conditional comments
298    $html = preg_replace('/<!--[^->[\n]*>/', '', $html);
299
300    @$node->loadHTML($html);
301    return $this->dumpHtml($node);
302  }
303
304  /**
305   * Getter for config parameters
306   */
307  public function get_config($prop)
308  {
309      return $this->config[$prop];
310  }
311
312}
313
314?>
Note: See TracBrowser for help on using the repository browser.