Edgewall Software

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

Last change on this file was 1051, checked in by cmlenz, 15 years ago

Ported [1050] to 0.5.x branch.

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