Edgewall Software

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

Last change on this file was 683, checked in by cmlenz, 16 years ago

Ported [682] to 0.4.x branch.

  • Property svn:eol-style set to native
File size: 15.8 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006-2007 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    frozenset
18except NameError:
19    from sets import ImmutableSet as frozenset
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):
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        """
59        self.name = name
60        self.id = id
61        if data is None:
62            data = {}
63        self.data = data
64
65    def __call__(self, stream):
66        """Apply the filter to the given stream.
67       
68        :param stream: the markup event stream to filter
69        """
70        in_form = in_select = in_option = in_textarea = False
71        select_value = option_value = textarea_value = None
72        option_start = option_text = None
73
74        for kind, data, pos in stream:
75
76            if kind is START:
77                tag, attrs = data
78                tagname = tag.localname
79
80                if tagname == 'form' and (
81                        self.name and attrs.get('name') == self.name or
82                        self.id and attrs.get('id') == self.id or
83                        not (self.id or self.name)):
84                    in_form = True
85
86                elif in_form:
87                    if tagname == 'input':
88                        type = attrs.get('type')
89                        if type in ('checkbox', 'radio'):
90                            name = attrs.get('name')
91                            if name and name in self.data:
92                                value = self.data[name]
93                                declval = attrs.get('value')
94                                checked = False
95                                if isinstance(value, (list, tuple)):
96                                    if declval:
97                                        checked = declval in [str(v) for v
98                                                              in value]
99                                    else:
100                                        checked = bool(filter(None, value))
101                                else:
102                                    if declval:
103                                        checked = declval == str(value)
104                                    elif type == 'checkbox':
105                                        checked = bool(value)
106                                if checked:
107                                    attrs |= [(QName('checked'), 'checked')]
108                                elif 'checked' in attrs:
109                                    attrs -= 'checked'
110                        elif type in (None, 'hidden', 'text'):
111                            name = attrs.get('name')
112                            if name and name in self.data:
113                                value = self.data[name]
114                                if isinstance(value, (list, tuple)):
115                                    value = value[0]
116                                if value is not None:
117                                    attrs |= [(QName('value'), unicode(value))]
118                    elif tagname == 'select':
119                        name = attrs.get('name')
120                        if name in self.data:
121                            select_value = self.data[name]
122                            in_select = True
123                    elif tagname == 'textarea':
124                        name = attrs.get('name')
125                        if name in self.data:
126                            textarea_value = self.data.get(name)
127                            if isinstance(textarea_value, (list, tuple)):
128                                textarea_value = textarea_value[0]
129                            in_textarea = True
130                    elif in_select and tagname == 'option':
131                        option_start = kind, data, pos
132                        option_value = attrs.get('value')
133                        in_option = True
134                        continue
135                yield kind, (tag, attrs), pos
136
137            elif in_form and kind is TEXT:
138                if in_select and in_option:
139                    if option_value is None:
140                        option_value = data
141                    option_text = kind, data, pos
142                    continue
143                elif in_textarea:
144                    continue
145                yield kind, data, pos
146
147            elif in_form and kind is END:
148                tagname = data.localname
149                if tagname == 'form':
150                    in_form = False
151                elif tagname == 'select':
152                    in_select = False
153                    select_value = None
154                elif in_select and tagname == 'option':
155                    if isinstance(select_value, (tuple, list)):
156                        selected = option_value in [str(v) for v
157                                                    in select_value]
158                    else:
159                        selected = option_value == str(select_value)
160                    okind, (tag, attrs), opos = option_start
161                    if selected:
162                        attrs |= [(QName('selected'), 'selected')]
163                    elif 'selected' in attrs:
164                        attrs -= 'selected'
165                    yield okind, (tag, attrs), opos
166                    if option_text:
167                        yield option_text
168                    in_option = False
169                    option_start = option_text = option_value = None
170                elif tagname == 'textarea':
171                    if textarea_value:
172                        yield TEXT, unicode(textarea_value), pos
173                    in_textarea = False
174                yield kind, data, pos
175
176            else:
177                yield kind, data, pos
178
179
180class HTMLSanitizer(object):
181    """A filter that removes potentially dangerous HTML tags and attributes
182    from the stream.
183   
184    >>> from genshi import HTML
185    >>> html = HTML('<div><script>alert(document.cookie)</script></div>')
186    >>> print html | HTMLSanitizer()
187    <div/>
188   
189    The default set of safe tags and attributes can be modified when the filter
190    is instantiated. For example, to allow inline ``style`` attributes, the
191    following instantation would work:
192   
193    >>> html = HTML('<div style="background: #000"></div>')
194    >>> sanitizer = HTMLSanitizer(safe_attrs=HTMLSanitizer.SAFE_ATTRS | set(['style']))
195    >>> print html | sanitizer
196    <div style="background: #000"/>
197   
198    Note that even in this case, the filter *does* attempt to remove dangerous
199    constructs from style attributes:
200
201    >>> html = HTML('<div style="background: url(javascript:void); color: #000"></div>')
202    >>> print html | sanitizer
203    <div style="color: #000"/>
204   
205    This handles HTML entities, unicode escapes in CSS and Javascript text, as
206    well as a lot of other things. However, the style tag is still excluded by
207    default because it is very hard for such sanitizing to be completely safe,
208    especially considering how much error recovery current web browsers perform.
209   
210    :warn: Note that this special processing of CSS is currently only applied to
211           style attributes, **not** style elements.
212    """
213
214    SAFE_TAGS = frozenset(['a', 'abbr', 'acronym', 'address', 'area', 'b',
215        'big', 'blockquote', 'br', 'button', 'caption', 'center', 'cite',
216        'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt',
217        'em', 'fieldset', 'font', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
218        'hr', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'map',
219        'menu', 'ol', 'optgroup', 'option', 'p', 'pre', 'q', 's', 'samp',
220        'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
221        'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u',
222        'ul', 'var'])
223
224    SAFE_ATTRS = frozenset(['abbr', 'accept', 'accept-charset', 'accesskey',
225        'action', 'align', 'alt', 'axis', 'bgcolor', 'border', 'cellpadding',
226        'cellspacing', 'char', 'charoff', 'charset', 'checked', 'cite', 'class',
227        'clear', 'cols', 'colspan', 'color', 'compact', 'coords', 'datetime',
228        'dir', 'disabled', 'enctype', 'for', 'frame', 'headers', 'height',
229        'href', 'hreflang', 'hspace', 'id', 'ismap', 'label', 'lang',
230        'longdesc', 'maxlength', 'media', 'method', 'multiple', 'name',
231        'nohref', 'noshade', 'nowrap', 'prompt', 'readonly', 'rel', 'rev',
232        'rows', 'rowspan', 'rules', 'scope', 'selected', 'shape', 'size',
233        'span', 'src', 'start', 'summary', 'tabindex', 'target', 'title',
234        'type', 'usemap', 'valign', 'value', 'vspace', 'width'])
235
236    SAFE_SCHEMES = frozenset(['file', 'ftp', 'http', 'https', 'mailto', None])
237
238    URI_ATTRS = frozenset(['action', 'background', 'dynsrc', 'href', 'lowsrc',
239        'src'])
240
241    def __init__(self, safe_tags=SAFE_TAGS, safe_attrs=SAFE_ATTRS,
242                 safe_schemes=SAFE_SCHEMES, uri_attrs=URI_ATTRS):
243        """Create the sanitizer.
244       
245        The exact set of allowed elements and attributes can be configured.
246       
247        :param safe_tags: a set of tag names that are considered safe
248        :param safe_attrs: a set of attribute names that are considered safe
249        :param safe_schemes: a set of URI schemes that are considered safe
250        :param uri_attrs: a set of names of attributes that contain URIs
251        """
252        self.safe_tags = safe_tags
253        "The set of tag names that are considered safe."
254        self.safe_attrs = safe_attrs
255        "The set of attribute names that are considered safe."
256        self.uri_attrs = uri_attrs
257        "The set of names of attributes that may contain URIs."
258        self.safe_schemes = safe_schemes
259        "The set of URI schemes that are considered safe."
260
261    def __call__(self, stream):
262        """Apply the filter to the given stream.
263       
264        :param stream: the markup event stream to filter
265        """
266        waiting_for = None
267
268        for kind, data, pos in stream:
269            if kind is START:
270                if waiting_for:
271                    continue
272                tag, attrs = data
273                if tag not in self.safe_tags:
274                    waiting_for = tag
275                    continue
276
277                new_attrs = []
278                for attr, value in attrs:
279                    value = stripentities(value)
280                    if attr not in self.safe_attrs:
281                        continue
282                    elif attr in self.uri_attrs:
283                        # Don't allow URI schemes such as "javascript:"
284                        if not self.is_safe_uri(value):
285                            continue
286                    elif attr == 'style':
287                        # Remove dangerous CSS declarations from inline styles
288                        decls = self.sanitize_css(value)
289                        if not decls:
290                            continue
291                        value = '; '.join(decls)
292                    new_attrs.append((attr, value))
293
294                yield kind, (tag, Attrs(new_attrs)), pos
295
296            elif kind is END:
297                tag = data
298                if waiting_for:
299                    if waiting_for == tag:
300                        waiting_for = None
301                else:
302                    yield kind, data, pos
303
304            elif kind is not COMMENT:
305                if not waiting_for:
306                    yield kind, data, pos
307
308    def is_safe_uri(self, uri):
309        """Determine whether the given URI is to be considered safe for
310        inclusion in the output.
311       
312        The default implementation checks whether the scheme of the URI is in
313        the set of allowed URIs (`safe_schemes`).
314       
315        >>> sanitizer = HTMLSanitizer()
316        >>> sanitizer.is_safe_uri('http://example.org/')
317        True
318        >>> sanitizer.is_safe_uri('javascript:alert(document.cookie)')
319        False
320       
321        :param uri: the URI to check
322        :return: `True` if the URI can be considered safe, `False` otherwise
323        :rtype: `bool`
324        """
325        if ':' not in uri:
326            return True # This is a relative URI
327        chars = [char for char in uri.split(':', 1)[0] if char.isalnum()]
328        return ''.join(chars).lower() in self.safe_schemes
329
330    def sanitize_css(self, text):
331        """Remove potentially dangerous property declarations from CSS code.
332       
333        In particular, properties using the CSS ``url()`` function with a scheme
334        that is not considered safe are removed:
335       
336        >>> sanitizer = HTMLSanitizer()
337        >>> sanitizer.sanitize_css(u'''
338        ...   background: url(javascript:alert("foo"));
339        ...   color: #000;
340        ... ''')
341        [u'color: #000']
342       
343        Also, the proprietary Internet Explorer function ``expression()`` is
344        always stripped:
345       
346        >>> sanitizer.sanitize_css(u'''
347        ...   background: #fff;
348        ...   color: #000;
349        ...   width: e/**/xpression(alert("foo"));
350        ... ''')
351        [u'background: #fff', u'color: #000']
352       
353        :param text: the CSS text; this is expected to be `unicode` and to not
354                     contain any character or numeric references
355        :return: a list of declarations that are considered safe
356        :rtype: `list`
357        """
358        decls = []
359        text = self._strip_css_comments(self._replace_unicode_escapes(text))
360        for decl in filter(None, text.split(';')):
361            decl = decl.strip()
362            if not decl:
363                continue
364            is_evil = False
365            if 'expression' in decl:
366                is_evil = True
367            for match in re.finditer(r'url\s*\(([^)]+)', decl):
368                if not self.is_safe_uri(match.group(1)):
369                    is_evil = True
370                    break
371            if not is_evil:
372                decls.append(decl.strip())
373        return decls
374
375    _NORMALIZE_NEWLINES = re.compile(r'\r\n').sub
376    _UNICODE_ESCAPE = re.compile(r'\\([0-9a-fA-F]{1,6})\s?').sub
377
378    def _replace_unicode_escapes(self, text):
379        def _repl(match):
380            return unichr(int(match.group(1), 16))
381        return self._UNICODE_ESCAPE(_repl, self._NORMALIZE_NEWLINES('\n', text))
382
383    _CSS_COMMENTS = re.compile(r'/\*.*?\*/').sub
384
385    def _strip_css_comments(self, text):
386        return self._CSS_COMMENTS('', text)
Note: See TracBrowser for help on using the repository browser.