Edgewall Software

source: branches/stable/0.6.x/genshi/filters/html.py

Last change on this file was 1176, checked in by hodgestar, 12 years ago

Merge r1174 and r1175 from trunk (improve sanitizing of CSS in style attributes -- see #455).

  • Property svn:eol-style set to native
File size: 22.1 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006-2009 Edgewall Software
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution. The terms
8# are also available at http://genshi.edgewall.org/wiki/License.
9#
10# This software consists of voluntary contributions made by many
11# individuals. For the exact contribution history, see the revision
12# history and logs, available at http://genshi.edgewall.org/log/.
13
14"""Implementation of a number of stream filters."""
15
16try:
17    any
18except NameError:
19    from genshi.util import any
20import re
21
22from genshi.core import Attrs, QName, stripentities
23from genshi.core import END, START, TEXT, COMMENT
24
25__all__ = ['HTMLFormFiller', 'HTMLSanitizer']
26__docformat__ = 'restructuredtext en'
27
28
29class HTMLFormFiller(object):
30    """A stream filter that can populate HTML forms from a dictionary of values.
31   
32    >>> from genshi.input import HTML
33    >>> html = HTML('''<form>
34    ...   <p><input type="text" name="foo" /></p>
35    ... </form>''')
36    >>> filler = HTMLFormFiller(data={'foo': 'bar'})
37    >>> print(html | filler)
38    <form>
39      <p><input type="text" name="foo" value="bar"/></p>
40    </form>
41    """
42    # TODO: only select the first radio button, and the first select option
43    #       (if not in a multiple-select)
44    # TODO: only apply to elements in the XHTML namespace (or no namespace)?
45
46    def __init__(self, name=None, id=None, data=None, passwords=False):
47        """Create the filter.
48       
49        :param name: The name of the form that should be populated. If this
50                     parameter is given, only forms where the ``name`` attribute
51                     value matches the parameter are processed.
52        :param id: The ID of the form that should be populated. If this
53                   parameter is given, only forms where the ``id`` attribute
54                   value matches the parameter are processed.
55        :param data: The dictionary of form values, where the keys are the names
56                     of the form fields, and the values are the values to fill
57                     in.
58        :param passwords: Whether password input fields should be populated.
59                          This is off by default for security reasons (for
60                          example, a password may end up in the browser cache)
61        :note: Changed in 0.5.2: added the `passwords` option
62        """
63        self.name = name
64        self.id = id
65        if data is None:
66            data = {}
67        self.data = data
68        self.passwords = passwords
69
70    def __call__(self, stream):
71        """Apply the filter to the given stream.
72       
73        :param stream: the markup event stream to filter
74        """
75        in_form = in_select = in_option = in_textarea = False
76        select_value = option_value = textarea_value = None
77        option_start = None
78        option_text = []
79        no_option_value = False
80
81        for kind, data, pos in stream:
82
83            if kind is START:
84                tag, attrs = data
85                tagname = tag.localname
86
87                if tagname == 'form' and (
88                        self.name and attrs.get('name') == self.name or
89                        self.id and attrs.get('id') == self.id or
90                        not (self.id or self.name)):
91                    in_form = True
92
93                elif in_form:
94                    if tagname == 'input':
95                        type = attrs.get('type', '').lower()
96                        if type in ('checkbox', 'radio'):
97                            name = attrs.get('name')
98                            if name and name in self.data:
99                                value = self.data[name]
100                                declval = attrs.get('value')
101                                checked = False
102                                if isinstance(value, (list, tuple)):
103                                    if declval:
104                                        checked = declval in [unicode(v) for v
105                                                              in value]
106                                    else:
107                                        checked = any(value)
108                                else:
109                                    if declval:
110                                        checked = declval == unicode(value)
111                                    elif type == 'checkbox':
112                                        checked = bool(value)
113                                if checked:
114                                    attrs |= [(QName('checked'), 'checked')]
115                                elif 'checked' in attrs:
116                                    attrs -= 'checked'
117                        elif type in ('', 'hidden', 'text') \
118                                or type == 'password' and self.passwords:
119                            name = attrs.get('name')
120                            if name and name in self.data:
121                                value = self.data[name]
122                                if isinstance(value, (list, tuple)):
123                                    value = value[0]
124                                if value is not None:
125                                    attrs |= [
126                                        (QName('value'), unicode(value))
127                                    ]
128                    elif tagname == 'select':
129                        name = attrs.get('name')
130                        if name in self.data:
131                            select_value = self.data[name]
132                            in_select = True
133                    elif tagname == 'textarea':
134                        name = attrs.get('name')
135                        if name in self.data:
136                            textarea_value = self.data.get(name)
137                            if isinstance(textarea_value, (list, tuple)):
138                                textarea_value = textarea_value[0]
139                            in_textarea = True
140                    elif in_select and tagname == 'option':
141                        option_start = kind, data, pos
142                        option_value = attrs.get('value')
143                        if option_value is None:
144                            no_option_value = True
145                            option_value = ''
146                        in_option = True
147                        continue
148                yield kind, (tag, attrs), pos
149
150            elif in_form and kind is TEXT:
151                if in_select and in_option:
152                    if no_option_value:
153                        option_value += data
154                    option_text.append((kind, data, pos))
155                    continue
156                elif in_textarea:
157                    continue
158                yield kind, data, pos
159
160            elif in_form and kind is END:
161                tagname = data.localname
162                if tagname == 'form':
163                    in_form = False
164                elif tagname == 'select':
165                    in_select = False
166                    select_value = None
167                elif in_select and tagname == 'option':
168                    if isinstance(select_value, (tuple, list)):
169                        selected = option_value in [unicode(v) for v
170                                                    in select_value]
171                    else:
172                        selected = option_value == unicode(select_value)
173                    okind, (tag, attrs), opos = option_start
174                    if selected:
175                        attrs |= [(QName('selected'), 'selected')]
176                    elif 'selected' in attrs:
177                        attrs -= 'selected'
178                    yield okind, (tag, attrs), opos
179                    if option_text:
180                        for event in option_text:
181                            yield event
182                    in_option = False
183                    no_option_value = False
184                    option_start = option_value = None
185                    option_text = []
186                elif tagname == 'textarea':
187                    if textarea_value:
188                        yield TEXT, unicode(textarea_value), pos
189                    in_textarea = False
190                yield kind, data, pos
191
192            else:
193                yield kind, data, pos
194
195
196class HTMLSanitizer(object):
197    """A filter that removes potentially dangerous HTML tags and attributes
198    from the stream.
199   
200    >>> from genshi import HTML
201    >>> html = HTML('<div><script>alert(document.cookie)</script></div>')
202    >>> print(html | HTMLSanitizer())
203    <div/>
204   
205    The default set of safe tags and attributes can be modified when the filter
206    is instantiated. For example, to allow inline ``style`` attributes, the
207    following instantation would work:
208   
209    >>> html = HTML('<div style="background: #000"></div>')
210    >>> sanitizer = HTMLSanitizer(safe_attrs=HTMLSanitizer.SAFE_ATTRS | set(['style']))
211    >>> print(html | sanitizer)
212    <div style="background: #000"/>
213   
214    Note that even in this case, the filter *does* attempt to remove dangerous
215    constructs from style attributes:
216
217    >>> html = HTML('<div style="background: url(javascript:void); color: #000"></div>')
218    >>> print(html | sanitizer)
219    <div style="color: #000"/>
220   
221    This handles HTML entities, unicode escapes in CSS and Javascript text, as
222    well as a lot of other things. However, the style tag is still excluded by
223    default because it is very hard for such sanitizing to be completely safe,
224    especially considering how much error recovery current web browsers perform.
225   
226    It also does some basic filtering of CSS properties that may be used for
227    typical phishing attacks. For more sophisticated filtering, this class
228    provides a couple of hooks that can be overridden in sub-classes.
229   
230    :warn: Note that this special processing of CSS is currently only applied to
231           style attributes, **not** style elements.
232    """
233
234    SAFE_TAGS = frozenset(['a', 'abbr', 'acronym', 'address', 'area', 'b',
235        'big', 'blockquote', 'br', 'button', 'caption', 'center', 'cite',
236        'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt',
237        'em', 'fieldset', 'font', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
238        'hr', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'map',
239        'menu', 'ol', 'optgroup', 'option', 'p', 'pre', 'q', 's', 'samp',
240        'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
241        'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u',
242        'ul', 'var'])
243
244    SAFE_ATTRS = frozenset(['abbr', 'accept', 'accept-charset', 'accesskey',
245        'action', 'align', 'alt', 'axis', 'bgcolor', 'border', 'cellpadding',
246        'cellspacing', 'char', 'charoff', 'charset', 'checked', 'cite', 'class',
247        'clear', 'cols', 'colspan', 'color', 'compact', 'coords', 'datetime',
248        'dir', 'disabled', 'enctype', 'for', 'frame', 'headers', 'height',
249        'href', 'hreflang', 'hspace', 'id', 'ismap', 'label', 'lang',
250        'longdesc', 'maxlength', 'media', 'method', 'multiple', 'name',
251        'nohref', 'noshade', 'nowrap', 'prompt', 'readonly', 'rel', 'rev',
252        'rows', 'rowspan', 'rules', 'scope', 'selected', 'shape', 'size',
253        'span', 'src', 'start', 'summary', 'tabindex', 'target', 'title',
254        'type', 'usemap', 'valign', 'value', 'vspace', 'width'])
255
256    SAFE_CSS = frozenset([
257        # CSS 3 properties <http://www.w3.org/TR/CSS/#properties>
258        'background', 'background-attachment', 'background-color',
259        'background-image', 'background-position', 'background-repeat',
260        'border', 'border-bottom', 'border-bottom-color',
261        'border-bottom-style', 'border-bottom-width', 'border-collapse',
262        'border-color', 'border-left', 'border-left-color',
263        'border-left-style', 'border-left-width', 'border-right',
264        'border-right-color', 'border-right-style', 'border-right-width',
265        'border-spacing', 'border-style', 'border-top', 'border-top-color',
266        'border-top-style', 'border-top-width', 'border-width', 'bottom',
267        'caption-side', 'clear', 'clip', 'color', 'content',
268        'counter-increment', 'counter-reset', 'cursor', 'direction', 'display',
269        'empty-cells', 'float', 'font', 'font-family', 'font-size',
270        'font-style', 'font-variant', 'font-weight', 'height', 'left',
271        'letter-spacing', 'line-height', 'list-style', 'list-style-image',
272        'list-style-position', 'list-style-type', 'margin', 'margin-bottom',
273        'margin-left', 'margin-right', 'margin-top', 'max-height', 'max-width',
274        'min-height', 'min-width', 'opacity', 'orphans', 'outline',
275        'outline-color', 'outline-style', 'outline-width', 'overflow',
276        'padding', 'padding-bottom', 'padding-left', 'padding-right',
277        'padding-top', 'page-break-after', 'page-break-before',
278        'page-break-inside', 'quotes', 'right', 'table-layout',
279        'text-align', 'text-decoration', 'text-indent', 'text-transform',
280        'top', 'unicode-bidi', 'vertical-align', 'visibility', 'white-space',
281        'widows', 'width', 'word-spacing', 'z-index',
282     ])
283
284    SAFE_SCHEMES = frozenset(['file', 'ftp', 'http', 'https', 'mailto', None])
285
286    URI_ATTRS = frozenset(['action', 'background', 'dynsrc', 'href', 'lowsrc',
287        'src'])
288
289    def __init__(self, safe_tags=SAFE_TAGS, safe_attrs=SAFE_ATTRS,
290                 safe_schemes=SAFE_SCHEMES, uri_attrs=URI_ATTRS,
291                 safe_css=SAFE_CSS):
292        """Create the sanitizer.
293       
294        The exact set of allowed elements and attributes can be configured.
295       
296        :param safe_tags: a set of tag names that are considered safe
297        :param safe_attrs: a set of attribute names that are considered safe
298        :param safe_schemes: a set of URI schemes that are considered safe
299        :param uri_attrs: a set of names of attributes that contain URIs
300        """
301        self.safe_tags = safe_tags
302        # The set of tag names that are considered safe.
303        self.safe_attrs = safe_attrs
304        # The set of attribute names that are considered safe.
305        self.safe_css = safe_css
306        # The set of CSS properties that are considered safe.
307        self.uri_attrs = uri_attrs
308        # The set of names of attributes that may contain URIs.
309        self.safe_schemes = safe_schemes
310        # The set of URI schemes that are considered safe.
311
312    # IE6 <http://heideri.ch/jso/#80>
313    _EXPRESSION_SEARCH = re.compile(u"""
314        [eE
315         \uFF25 # FULLWIDTH LATIN CAPITAL LETTER E
316         \uFF45 # FULLWIDTH LATIN SMALL LETTER E
317        ]
318        [xX
319         \uFF38 # FULLWIDTH LATIN CAPITAL LETTER X
320         \uFF58 # FULLWIDTH LATIN SMALL LETTER X
321        ]
322        [pP
323         \uFF30 # FULLWIDTH LATIN CAPITAL LETTER P
324         \uFF50 # FULLWIDTH LATIN SMALL LETTER P
325        ]
326        [rR
327         \u0280 # LATIN LETTER SMALL CAPITAL R
328         \uFF32 # FULLWIDTH LATIN CAPITAL LETTER R
329         \uFF52 # FULLWIDTH LATIN SMALL LETTER R
330        ]
331        [eE
332         \uFF25 # FULLWIDTH LATIN CAPITAL LETTER E
333         \uFF45 # FULLWIDTH LATIN SMALL LETTER E
334        ]
335        [sS
336         \uFF33 # FULLWIDTH LATIN CAPITAL LETTER S
337         \uFF53 # FULLWIDTH LATIN SMALL LETTER S
338        ]{2}
339        [iI
340         \u026A # LATIN LETTER SMALL CAPITAL I
341         \uFF29 # FULLWIDTH LATIN CAPITAL LETTER I
342         \uFF49 # FULLWIDTH LATIN SMALL LETTER I
343        ]
344        [oO
345         \uFF2F # FULLWIDTH LATIN CAPITAL LETTER O
346         \uFF4F # FULLWIDTH LATIN SMALL LETTER O
347        ]
348        [nN
349         \u0274 # LATIN LETTER SMALL CAPITAL N
350         \uFF2E # FULLWIDTH LATIN CAPITAL LETTER N
351         \uFF4E # FULLWIDTH LATIN SMALL LETTER N
352        ]
353        """, re.VERBOSE).search
354
355    # IE6 <http://openmya.hacker.jp/hasegawa/security/expression.txt>
356    #     7) Particular bit of Unicode characters
357    _URL_FINDITER = re.compile(
358        u'[Uu][Rr\u0280][Ll\u029F]\s*\(([^)]+)').finditer
359
360    def __call__(self, stream):
361        """Apply the filter to the given stream.
362       
363        :param stream: the markup event stream to filter
364        """
365        waiting_for = None
366
367        for kind, data, pos in stream:
368            if kind is START:
369                if waiting_for:
370                    continue
371                tag, attrs = data
372                if not self.is_safe_elem(tag, attrs):
373                    waiting_for = tag
374                    continue
375
376                new_attrs = []
377                for attr, value in attrs:
378                    value = stripentities(value)
379                    if attr not in self.safe_attrs:
380                        continue
381                    elif attr in self.uri_attrs:
382                        # Don't allow URI schemes such as "javascript:"
383                        if not self.is_safe_uri(value):
384                            continue
385                    elif attr == 'style':
386                        # Remove dangerous CSS declarations from inline styles
387                        decls = self.sanitize_css(value)
388                        if not decls:
389                            continue
390                        value = '; '.join(decls)
391                    new_attrs.append((attr, value))
392
393                yield kind, (tag, Attrs(new_attrs)), pos
394
395            elif kind is END:
396                tag = data
397                if waiting_for:
398                    if waiting_for == tag:
399                        waiting_for = None
400                else:
401                    yield kind, data, pos
402
403            elif kind is not COMMENT:
404                if not waiting_for:
405                    yield kind, data, pos
406
407    def is_safe_css(self, propname, value):
408        """Determine whether the given css property declaration is to be
409        considered safe for inclusion in the output.
410       
411        :param propname: the CSS property name
412        :param value: the value of the property
413        :return: whether the property value should be considered safe
414        :rtype: bool
415        :since: version 0.6
416        """
417        if propname not in self.safe_css:
418            return False
419        if propname.startswith('margin') and '-' in value:
420            # Negative margins can be used for phishing
421            return False
422        return True
423
424    def is_safe_elem(self, tag, attrs):
425        """Determine whether the given element should be considered safe for
426        inclusion in the output.
427       
428        :param tag: the tag name of the element
429        :type tag: QName
430        :param attrs: the element attributes
431        :type attrs: Attrs
432        :return: whether the element should be considered safe
433        :rtype: bool
434        :since: version 0.6
435        """
436        if tag not in self.safe_tags:
437            return False
438        if tag.localname == 'input':
439            input_type = attrs.get('type', '').lower()
440            if input_type == 'password':
441                return False
442        return True
443
444    def is_safe_uri(self, uri):
445        """Determine whether the given URI is to be considered safe for
446        inclusion in the output.
447       
448        The default implementation checks whether the scheme of the URI is in
449        the set of allowed URIs (`safe_schemes`).
450       
451        >>> sanitizer = HTMLSanitizer()
452        >>> sanitizer.is_safe_uri('http://example.org/')
453        True
454        >>> sanitizer.is_safe_uri('javascript:alert(document.cookie)')
455        False
456       
457        :param uri: the URI to check
458        :return: `True` if the URI can be considered safe, `False` otherwise
459        :rtype: `bool`
460        :since: version 0.4.3
461        """
462        if '#' in uri:
463            uri = uri.split('#', 1)[0] # Strip out the fragment identifier
464        if ':' not in uri:
465            return True # This is a relative URI
466        chars = [char for char in uri.split(':', 1)[0] if char.isalnum()]
467        return ''.join(chars).lower() in self.safe_schemes
468
469    def sanitize_css(self, text):
470        """Remove potentially dangerous property declarations from CSS code.
471       
472        In particular, properties using the CSS ``url()`` function with a scheme
473        that is not considered safe are removed:
474       
475        >>> sanitizer = HTMLSanitizer()
476        >>> sanitizer.sanitize_css(u'''
477        ...   background: url(javascript:alert("foo"));
478        ...   color: #000;
479        ... ''')
480        [u'color: #000']
481       
482        Also, the proprietary Internet Explorer function ``expression()`` is
483        always stripped:
484       
485        >>> sanitizer.sanitize_css(u'''
486        ...   background: #fff;
487        ...   color: #000;
488        ...   width: e/**/xpression(alert("foo"));
489        ... ''')
490        [u'background: #fff', u'color: #000']
491       
492        :param text: the CSS text; this is expected to be `unicode` and to not
493                     contain any character or numeric references
494        :return: a list of declarations that are considered safe
495        :rtype: `list`
496        :since: version 0.4.3
497        """
498        decls = []
499        text = self._strip_css_comments(self._replace_unicode_escapes(text))
500        for decl in text.split(';'):
501            decl = decl.strip()
502            if not decl:
503                continue
504            try:
505                propname, value = decl.split(':', 1)
506            except ValueError:
507                continue
508            if not self.is_safe_css(propname.strip().lower(), value.strip()):
509                continue
510            is_evil = False
511            if self._EXPRESSION_SEARCH(value):
512                is_evil = True
513            for match in self._URL_FINDITER(value):
514                if not self.is_safe_uri(match.group(1)):
515                    is_evil = True
516                    break
517            if not is_evil:
518                decls.append(decl.strip())
519        return decls
520
521    _NORMALIZE_NEWLINES = re.compile(r'\r\n').sub
522    _UNICODE_ESCAPE = re.compile(
523        r"""\\([0-9a-fA-F]{1,6})\s?|\\([^\r\n\f0-9a-fA-F'"{};:()#*])""",
524        re.UNICODE).sub
525
526    def _replace_unicode_escapes(self, text):
527        def _repl(match):
528            t = match.group(1)
529            if t:
530                return unichr(int(t, 16))
531            t = match.group(2)
532            if t == '\\':
533                return r'\\'
534            else:
535                return t
536        return self._UNICODE_ESCAPE(_repl, self._NORMALIZE_NEWLINES('\n', text))
537
538    _CSS_COMMENTS = re.compile(r'/\*.*?\*/').sub
539
540    def _strip_css_comments(self, text):
541        return self._CSS_COMMENTS('', text)
Note: See TracBrowser for help on using the repository browser.