c98b14de079d088f6cc0a83aa2c29f26b5489d04
[WebKit-https.git] / sanitize.py
1 """
2 sanitize: bringing sanitiy to world of messed-up data
3 """
4
5 __author__ = ["Mark Pilgrim <http://diveintomark.org/>", 
6               "Aaron Swartz <http://www.aaronsw.com/>"]
7 __contributors__ = ["Sam Ruby <http://intertwingly.net/>"]
8 __license__ = "BSD"
9 __version__ = "0.25"
10
11 _debug = 0
12
13 # If you want sanitize to automatically run HTML markup through HTML Tidy, set
14 # this to 1.  Requires mxTidy <http://www.egenix.com/files/python/mxTidy.html>
15 # or utidylib <http://utidylib.berlios.de/>.
16 TIDY_MARKUP = 0
17
18 # List of Python interfaces for HTML Tidy, in order of preference.  Only useful
19 # if TIDY_MARKUP = 1
20 PREFERRED_TIDY_INTERFACES = ["uTidy", "mxTidy"]
21
22 import sgmllib, re
23
24 # chardet library auto-detects character encodings
25 # Download from http://chardet.feedparser.org/
26 try:
27     import chardet
28     if _debug:
29         import chardet.constants
30         chardet.constants._debug = 1
31
32     _chardet = lambda data: chardet.detect(data)['encoding']
33 except:
34     chardet = None
35     _chardet = lambda data: None
36
37 class _BaseHTMLProcessor(sgmllib.SGMLParser):
38     elements_no_end_tag = ['area', 'base', 'basefont', 'br', 'col', 'frame', 'hr',
39       'img', 'input', 'isindex', 'link', 'meta', 'param']
40     
41     _r_barebang = re.compile(r'<!((?!DOCTYPE|--|\[))', re.IGNORECASE)
42     _r_bareamp = re.compile("&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)")
43     _r_shorttag = re.compile(r'<([^<\s]+?)\s*/>')
44     
45     def __init__(self, encoding):
46         self.encoding = encoding
47         if _debug: sys.stderr.write('entering BaseHTMLProcessor, encoding=%s\n' % self.encoding)
48         sgmllib.SGMLParser.__init__(self)
49         
50     def reset(self):
51         self.pieces = []
52         sgmllib.SGMLParser.reset(self)
53
54     def _shorttag_replace(self, match):
55         tag = match.group(1)
56         if tag in self.elements_no_end_tag:
57             return '<' + tag + ' />'
58         else:
59             return '<' + tag + '></' + tag + '>'
60         
61     def feed(self, data):
62         data = self._r_barebang.sub(r'&lt;!\1', data)
63         data = self._r_bareamp.sub("&amp;", data)
64         data = self._r_shorttag.sub(self._shorttag_replace, data) 
65         if self.encoding and type(data) == type(u''):
66             data = data.encode(self.encoding)
67         sgmllib.SGMLParser.feed(self, data)
68
69     def normalize_attrs(self, attrs):
70         # utility method to be called by descendants
71         attrs = [(k.lower(), v) for k, v in attrs]
72         attrs = [(k, k in ('rel', 'type') and v.lower() or v) for k, v in attrs]
73         return attrs
74
75     def unknown_starttag(self, tag, attrs):
76         # called for each start tag
77         # attrs is a list of (attr, value) tuples
78         # e.g. for <pre class='screen'>, tag='pre', attrs=[('class', 'screen')]
79         if _debug: sys.stderr.write('_BaseHTMLProcessor, unknown_starttag, tag=%s\n' % tag)
80         uattrs = []
81         # thanks to Kevin Marks for this breathtaking hack to deal with (valid) high-bit attribute values in UTF-8 feeds
82         for key, value in attrs:
83             if type(value) != type(u''):
84                 value = unicode(value, self.encoding)
85             uattrs.append((unicode(key, self.encoding), value))
86         strattrs = u''.join([u' %s="%s"' % (key, value) for key, value in uattrs]).encode(self.encoding)
87         if tag in self.elements_no_end_tag:
88             self.pieces.append('<%(tag)s%(strattrs)s />' % locals())
89         else:
90             self.pieces.append('<%(tag)s%(strattrs)s>' % locals())
91
92     def unknown_endtag(self, tag):
93         # called for each end tag, e.g. for </pre>, tag will be 'pre'
94         # Reconstruct the original end tag.
95         if tag not in self.elements_no_end_tag:
96             self.pieces.append("</%(tag)s>" % locals())
97
98     def handle_charref(self, ref):
99         # called for each character reference, e.g. for '&#160;', ref will be '160'
100         # Reconstruct the original character reference.
101         self.pieces.append('&#%(ref)s;' % locals())
102         
103     def handle_entityref(self, ref):
104         # called for each entity reference, e.g. for '&copy;', ref will be 'copy'
105         # Reconstruct the original entity reference.
106         self.pieces.append('&%(ref)s;' % locals())
107
108     def handle_data(self, text):
109         # called for each block of plain text, i.e. outside of any tag and
110         # not containing any character or entity references
111         # Store the original text verbatim.
112         if _debug: sys.stderr.write('_BaseHTMLProcessor, handle_text, text=%s\n' % text)
113         self.pieces.append(text)
114         
115     def handle_comment(self, text):
116         # called for each HTML comment, e.g. <!-- insert Javascript code here -->
117         # Reconstruct the original comment.
118         self.pieces.append('<!--%(text)s-->' % locals())
119         
120     def handle_pi(self, text):
121         # called for each processing instruction, e.g. <?instruction>
122         # Reconstruct original processing instruction.
123         self.pieces.append('<?%(text)s>' % locals())
124
125     def handle_decl(self, text):
126         # called for the DOCTYPE, if present, e.g.
127         # <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
128         #     "http://www.w3.org/TR/html4/loose.dtd">
129         # Reconstruct original DOCTYPE
130         self.pieces.append('<!%(text)s>' % locals())
131         
132     _new_declname_match = re.compile(r'[a-zA-Z][-_.a-zA-Z0-9:]*\s*').match
133     def _scan_name(self, i, declstartpos):
134         rawdata = self.rawdata
135         n = len(rawdata)
136         if i == n:
137             return None, -1
138         m = self._new_declname_match(rawdata, i)
139         if m:
140             s = m.group()
141             name = s.strip()
142             if (i + len(s)) == n:
143                 return None, -1  # end of buffer
144             return name.lower(), m.end()
145         else:
146             self.handle_data(rawdata)
147 #            self.updatepos(declstartpos, i)
148             return None, -1
149
150     def output(self):
151         '''Return processed HTML as a single string'''
152         return ''.join([str(p) for p in self.pieces])
153
154 class _HTMLSanitizer(_BaseHTMLProcessor):
155     acceptable_elements = ['a', 'abbr', 'acronym', 'address', 'area', 'b', 'big',
156       'blockquote', 'br', 'button', 'caption', 'center', 'cite', 'code', 'col', 
157       'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt', 'em', 'fieldset',
158       'font', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input',
159       'ins', 'kbd', 'label', 'legend', 'li', 'map', 'menu', 'ol', 'optgroup', 
160       'option', 'p', 'pre', 'q', 's', 'samp', 'select', 'small', 'span', 'strike',
161       'strong', 'sub', 'sup', 'table', 'textarea', 'tbody', 'td', 'tfoot', 'th', 
162       'thead', 'tr', 'tt', 'u', 'ul', 'var']
163     
164     acceptable_attributes = ['abbr', 'accept', 'accept-charset', 'accesskey',
165       'action', 'align', 'alt', 'axis', 'border', 'cellpadding', 'cellspacing',
166       'char', 'charoff', 'charset', 'checked', 'cite', 'class', 'clear', 'cols',
167       'colspan', 'color', 'compact', 'coords', 'datetime', 'dir', 'disabled',
168       'enctype', 'for', 'frame', 'headers', 'height', 'href', 'hreflang', 'hspace',
169       'id', 'ismap', 'label', 'lang', 'longdesc', 'maxlength', 'media', 'method',
170       'multiple', 'name', 'nohref', 'noshade', 'nowrap', 'prompt', 'readonly',
171       'rel', 'rev', 'rows', 'rowspan', 'rules', 'scope', 'selected', 'shape', 'size',
172       'span', 'src', 'start', 'summary', 'tabindex', 'target', 'title', 'type',
173       'usemap', 'valign', 'value', 'vspace', 'width']
174
175     ignorable_elements = ['script', 'applet', 'style']
176             
177     def reset(self):
178         _BaseHTMLProcessor.reset(self)
179         self.tag_stack = []
180         self.ignore_level = 0
181
182     def feed(self, data):
183         _BaseHTMLProcessor.feed(self, data)
184         while self.tag_stack:
185             _BaseHTMLProcessor.unknown_endtag(self, self.tag_stack.pop())
186         
187     def unknown_starttag(self, tag, attrs):
188         if tag in self.ignorable_elements:
189             self.ignore_level += 1
190             return
191         
192         if self.ignore_level:
193             return
194         
195         if tag in self.acceptable_elements:
196             attrs = self.normalize_attrs(attrs)
197             attrs = [(key, value) for key, value in attrs if key in self.acceptable_attributes]
198             if tag not in self.elements_no_end_tag:
199                 self.tag_stack.append(tag)
200             _BaseHTMLProcessor.unknown_starttag(self, tag, attrs)
201         
202     def unknown_endtag(self, tag):
203         if tag in self.ignorable_elements:
204             self.ignore_level -= 1
205             return
206         
207         if self.ignore_level:
208             return
209         
210         if tag in self.acceptable_elements and tag not in self.elements_no_end_tag:
211             match = False
212             while self.tag_stack:
213                 top = self.tag_stack.pop()
214                 if top == tag:
215                     match = True
216                     break
217                 _BaseHTMLProcessor.unknown_endtag(self, top)
218
219             if match:
220                 _BaseHTMLProcessor.unknown_endtag(self, tag)
221
222     def handle_pi(self, text):
223         pass
224
225     def handle_decl(self, text):
226         pass
227
228     def handle_data(self, text):
229         if not self.ignore_level:
230             text = text.replace('<', '')
231             _BaseHTMLProcessor.handle_data(self, text)
232
233 def HTML(htmlSource, encoding='utf8'):
234     p = _HTMLSanitizer(encoding)
235     p.feed(htmlSource)
236     data = p.output()
237     if TIDY_MARKUP:
238         # loop through list of preferred Tidy interfaces looking for one that's installed,
239         # then set up a common _tidy function to wrap the interface-specific API.
240         _tidy = None
241         for tidy_interface in PREFERRED_TIDY_INTERFACES:
242             try:
243                 if tidy_interface == "uTidy":
244                     from tidy import parseString as _utidy
245                     def _tidy(data, **kwargs):
246                         return str(_utidy(data, **kwargs))
247                     break
248                 elif tidy_interface == "mxTidy":
249                     from mx.Tidy import Tidy as _mxtidy
250                     def _tidy(data, **kwargs):
251                         nerrors, nwarnings, data, errordata = _mxtidy.tidy(data, **kwargs)
252                         return data
253                     break
254             except:
255                 pass
256         if _tidy:
257             utf8 = type(data) == type(u'')
258             if utf8:
259                 data = data.encode('utf-8')
260             data = _tidy(data, output_xhtml=1, numeric_entities=1, wrap=0, char_encoding="utf8")
261             if utf8:
262                 data = unicode(data, 'utf-8')
263             if data.count('<body'):
264                 data = data.split('<body', 1)[1]
265                 if data.count('>'):
266                     data = data.split('>', 1)[1]
267             if data.count('</body'):
268                 data = data.split('</body', 1)[0]
269     data = data.strip().replace('\r\n', '\n')
270     return data
271
272 unicode_bom_map = {
273   '\x00\x00\xfe\xff': 'utf-32be',
274   '\xff\xfe\x00\x00': 'utf-32le',
275   '\xfe\xff##': 'utf-16be',
276   '\xff\xfe##': 'utf-16le',
277   '\xef\bb\bf': 'utf-8'
278 }
279 xml_bom_map = {
280   '\x00\x00\x00\x3c': 'utf-32be',
281   '\x3c\x00\x00\x00': 'utf-32le',
282   '\x00\x3c\x00\x3f': 'utf-16be',
283   '\x3c\x00\x3f\x00': 'utf-16le',
284   '\x3c\x3f\x78\x6d': 'utf-8', # or equivalent
285   '\x4c\x6f\xa7\x94': 'ebcdic'
286 }
287
288 _ebcdic_to_ascii_map = None
289 def _ebcdic_to_ascii(s):
290     global _ebcdic_to_ascii_map
291     if not _ebcdic_to_ascii_map:
292         emap = (
293             0,1,2,3,156,9,134,127,151,141,142,11,12,13,14,15,
294             16,17,18,19,157,133,8,135,24,25,146,143,28,29,30,31,
295             128,129,130,131,132,10,23,27,136,137,138,139,140,5,6,7,
296             144,145,22,147,148,149,150,4,152,153,154,155,20,21,158,26,
297             32,160,161,162,163,164,165,166,167,168,91,46,60,40,43,33,
298             38,169,170,171,172,173,174,175,176,177,93,36,42,41,59,94,
299             45,47,178,179,180,181,182,183,184,185,124,44,37,95,62,63,
300             186,187,188,189,190,191,192,193,194,96,58,35,64,39,61,34,
301             195,97,98,99,100,101,102,103,104,105,196,197,198,199,200,201,
302             202,106,107,108,109,110,111,112,113,114,203,204,205,206,207,208,
303             209,126,115,116,117,118,119,120,121,122,210,211,212,213,214,215,
304             216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,
305             123,65,66,67,68,69,70,71,72,73,232,233,234,235,236,237,
306             125,74,75,76,77,78,79,80,81,82,238,239,240,241,242,243,
307             92,159,83,84,85,86,87,88,89,90,244,245,246,247,248,249,
308             48,49,50,51,52,53,54,55,56,57,250,251,252,253,254,255
309             )
310         import string
311         _ebcdic_to_ascii_map = string.maketrans( \
312             ''.join(map(chr, range(256))), ''.join(map(chr, emap)))
313     return s.translate(_ebcdic_to_ascii_map)
314
315 def _startswithbom(text, bom):
316     for i, c in enumerate(bom):
317         if c == '#':
318             if text[i] == '\x00':
319                 return False
320         else:
321             if text[i] != c:
322                 return False
323     return True
324
325 def _detectbom(text, bom_map=unicode_bom_map):
326     for bom, encoding in bom_map.iteritems():
327         if _startswithbom(text, bom):
328             return encoding
329     return None
330
331 def characters(text, isXML=False, guess=None):
332     """
333     Takes a string text of unknown encoding and tries to 
334     provide a Unicode string for it.
335     """
336     _triedEncodings = []
337     def tryEncoding(encoding):
338         if encoding and encoding not in _triedEncodings:
339             if encoding == 'ebcdic':
340                 return _ebcdic_to_ascii(text)
341             try:
342                 return unicode(text, encoding)
343             except UnicodeDecodeError:
344                 pass
345             _triedEncodings.append(encoding)
346     
347     return (
348       tryEncoding(guess) or 
349       tryEncoding(_detectbom(text)) or 
350       isXML and tryEncoding(_detectbom(text, xml_bom_map)) or
351       tryEncoding(_chardet(text)) or
352       tryEncoding('utf8') or
353       tryEncoding('windows-1252') or
354       tryEncoding('iso-8859-1'))