summaryrefslogtreecommitdiff
path: root/program/lib/washtml.php
blob: 0f8dc7ee614863ec35cf0fd5f638d8b19510c973 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
<?php
/*                Washtml, a HTML sanityzer.
 *
 * Copyright (c) 2007 Frederic Motte <fmotte@ubixis.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
 * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
 * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/* Please send me your comments about this code if you have some, thanks, Fred. */

/* OVERVIEW:
 *
 * Wahstml take an untrusted HTML and return a safe html string.
 *
 * SYNOPSIS:
 *
 * $washer = new washtml($config);
 * $washer->wash($html);
 * It return a sanityzed string of the $html parameter without html and head tags.
 * $html is a string containing the html code to wash.
 * $config is an array containing options:
 *   $config['allow_remote'] is a boolean to allow link to remote images.
 *   $config['blocked_src'] string with image-src to be used for blocked remote images
 *   $config['show_washed'] is a boolean to include washed out attributes as x-washed
 *   $config['cid_map'] is an array where cid urls index urls to replace them.
 *   $config['charset'] is a string containing the charset of the HTML document if it is not defined in it.
 * $washer->extlinks is a reference to a boolean that is set to true if remote images were removed. (FE: show remote images link)
 *
 * INTERNALS:
 *
 * Only tags and attributes in the static lists $html_elements and $html_attributes
 * are kept, inline styles are also filtered: all style identifiers matching
 * /[a-z\-]/i are allowed. Values matching colors, sizes, /[a-z\-]/i and safe
 * urls if allowed and cid urls if mapped are kept.
 *
 * BUGS: It MUST be safe !
 *  - Check regexp
 *  - urlencode URLs instead of htmlspecials
 *  - Check is a 3 bytes utf8 first char can eat '">'
 *  - Update PCRE: CVE-2007-1659 - CVE-2007-1660 - CVE-2007-1661 - CVE-2007-1662 
 *                 CVE-2007-4766 - CVE-2007-4767 - CVE-2007-4768  
 *    http://lists.debian.org/debian-security-announce/debian-security-announce-2007/msg00177.html 
 *  - ...
 *
 * MISSING:
 *  - relative links, can be implemented by prefixing an absolute path, ask me
 *    if you need it...
 *  - ...
 *
 * Dont be a fool:
 *  - Dont alter data on a GET: '<img src="http://yourhost/mail?action=delete&uid=3267" />'
 *  - ...
 *
 * Roundcube Changes:
 * - added $block_elements
 * - changed $ignore_elements behaviour
 * - added RFC2397 support
 * - base URL support
 * - invalid HTML comments removal before parsing
 */

class washtml
{
  /* Allowed HTML elements (default) */
  static $html_elements = array('a', 'abbr', 'acronym', 'address', 'area', 'b', 'basefont', 'bdo', 'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt', 'em', 'fieldset', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'ins', 'label', 'legend', 'li', 'map', 'menu', 'nobr', 'ol', 'p', 'pre', 'q', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', 'img');
  
  /* Ignore these HTML tags and their content */
  static $ignore_elements = array('script', 'applet', 'embed', 'object', 'style');
  
  /* Allowed HTML attributes */
  static $html_attribs = array('name', 'class', 'title', 'alt', 'width', 'height', 'align', 'nowrap', 'col', 'row', 'id', 'rowspan', 'colspan', 'cellspacing', 'cellpadding', 'valign', 'bgcolor', 'color', 'border', 'bordercolorlight', 'bordercolordark', 'face', 'marginwidth', 'marginheight', 'axis', 'border', 'abbr', 'char', 'charoff', 'clear', 'compact', 'coords', 'vspace', 'hspace', 'cellborder', 'size', 'lang', 'dir');  

  /* Block elements which could be empty but cannot be returned in short form (<tag />) */
  static $block_elements = array('div', 'p', 'pre', 'blockquote', 'a', 'font', 'center', 'table', 'ul', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ol', 'dl', 'strong', 'i', 'b');
  
  /* State for linked objects in HTML */
  public $extlinks = false;

  /* Current settings */
  private $config = array();

  /* Registered callback functions for tags */
  private $handlers = array();
  
  /* Allowed HTML elements */
  private $_html_elements = array();

  /* Ignore these HTML tags but process their content */
  private $_ignore_elements = array();

  /* Block elements which could be empty but cannot be returned in short form (<tag />) */
  private $_block_elements = array();

  /* Allowed HTML attributes */
  private $_html_attribs = array();
  

  /* Constructor */
  public function __construct($p = array()) {
    $this->_html_elements = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements) ;
    $this->_html_attribs = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
    $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
    $this->_block_elements = array_flip((array)$p['block_elements']) + array_flip(self::$block_elements);
    unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['block_elements']);
    $this->config = $p + array('show_washed'=>true, 'allow_remote'=>false, 'cid_map'=>array());
  }
  
  /* Register a callback function for a certain tag */
  public function add_callback($tagName, $callback)
  {
    $this->handlers[$tagName] = $callback;
  }
  
  /* Check CSS style */
  private function wash_style($style) {
    $s = '';

    foreach (explode(';', $style) as $declaration) {
      if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
        $cssid = $match[1];
        $str = $match[2];
        $value = '';
        while (sizeof($str) > 0 &&
          preg_match('/^(url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)'./*1,2*/
                 '|rgb\(\s*[0-9]+\s*,\s*[0-9]+\s*,\s*[0-9]+\s*\)'.
                 '|-?[0-9.]+\s*(em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)?'.
                 '|#[0-9a-f]{3,6}|[a-z0-9\-]+'.
                 ')\s*/i', $str, $match)) {
          if ($match[2]) {
            if (($src = $this->config['cid_map'][$match[2]])
                || ($src = $this->config['cid_map'][$this->config['base_url'].$match[2]])) {
              $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
            }
            else if (preg_match('/^(http|https|ftp):.*$/i', $match[2], $url)) {
              if ($this->config['allow_remote'])
                $value .= ' url('.htmlspecialchars($url[0], ENT_QUOTES).')';
              else
                $this->extlinks = true;
            }
            else if (preg_match('/^data:.+/i', $match[2])) { // RFC2397
              $value .= ' url('.htmlspecialchars($match[2], ENT_QUOTES).')';
            }
          }
          else if ($match[0] != 'url' && $match[0] != 'rbg') //whitelist ?
            $value .= ' ' . $match[0];
          $str = substr($str, strlen($match[0]));
        }
        if ($value)
          $s .= ($s?' ':'') . $cssid . ':' . $value . ';';
      }
    }
    return $s;
  }

  /* Take a node and return allowed attributes and check values */
  private function wash_attribs($node) {
    $t = '';
    $washed;

    foreach ($node->attributes as $key => $plop) {
      $key = strtolower($key);
      $value = $node->getAttribute($key);
      if (isset($this->_html_attribs[$key]) ||
         ($key == 'href' && preg_match('/^(http:|https:|ftp:|mailto:|#).+/i', $value)))
        $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
      else if ($key == 'style' && ($style = $this->wash_style($value)))
        $t .= ' style="' . $style . '"';
      else if ($key == 'background' || ($key == 'src' && strtolower($node->tagName) == 'img')) { //check tagName anyway
        if (($src = $this->config['cid_map'][$value])
            || ($src = $this->config['cid_map'][$this->config['base_url'].$value])) {
          $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
        }
        else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
          if ($this->config['allow_remote'])
            $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
          else {
            $this->extlinks = true;
            if ($this->config['blocked_src'])
              $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
          }
        }
        else if (preg_match('/^data:.+/i', $value)) { // RFC2397
          $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
        }
      } else
        $washed .= ($washed?' ':'') . $key;
    }
    return $t . ($washed && $this->config['show_washed']?' x-washed="'.$washed.'"':'');
  }

  /* The main loop that recurse on a node tree.
   * It output only allowed tags with allowed attributes
   * and allowed inline styles */
  private function dumpHtml($node) {
    if(!$node->hasChildNodes())
      return '';

    $node = $node->firstChild;
    $dump = '';

    do {
      switch($node->nodeType) {
      case XML_ELEMENT_NODE: //Check element
        $tagName = strtolower($node->tagName);
        if ($callback = $this->handlers[$tagName]) {
          $dump .= call_user_func($callback, $tagName, $this->wash_attribs($node), $this->dumpHtml($node));
        }
        else if (isset($this->_html_elements[$tagName])) {
          $content = $this->dumpHtml($node);
          $dump .= '<' . $tagName . $this->wash_attribs($node) .
            // create closing tag for block elements, but also for elements
            // with content or with some attributes (eg. style, class) (#1486812)
            ($content != '' || $node->hasAttributes() || isset($this->_block_elements[$tagName]) ? ">$content</$tagName>" : ' />');
        }
        else if (isset($this->_ignore_elements[$tagName])) {
          $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
        }
        else {
          $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
          $dump .= $this->dumpHtml($node); // ignore tags not its content
        }
        break;
      case XML_CDATA_SECTION_NODE:
        $dump .= $node->nodeValue;
        break;
      case XML_TEXT_NODE:
        $dump .= htmlspecialchars($node->nodeValue);
        break;
      case XML_HTML_DOCUMENT_NODE:
        $dump .= $this->dumpHtml($node);
        break;
      case XML_DOCUMENT_TYPE_NODE:
        break;
      default:
        $dump . '<!-- node type ' . $node->nodeType . ' -->';
      }
    } while($node = $node->nextSibling);

    return $dump;
  }

  /* Main function, give it untrusted HTML, tell it if you allow loading
   * remote images and give it a map to convert "cid:" urls. */
  public function wash($html)
  {
    // Charset seems to be ignored (probably if defined in the HTML document)
    $node = new DOMDocument('1.0', $this->config['charset']);
    $this->extlinks = false;

    // Find base URL for images
    if (preg_match('/<base\s+href=[\'"]*([^\'"]+)/is', $html, $matches))
      $this->config['base_url'] = $matches[1];
    else
      $this->config['base_url'] = '';

    // Remove invalid HTML comments (#1487759)
    $html = preg_replace('/<!--[^->]*>/', '', $html);

    @$node->loadHTML($html);
    return $this->dumpHtml($node);
  }

}

?>