[IMP] tools: sanitizer: add and clean sanitize options

Now having

 * sanitize: run the sanitizer to clean the html (removing javascripts,
   unwanted tags, ...)
 * sanitize_tags: only a subset of tags is allowed in html content.
   Unwelcomed tags are remove dand their content stripped.
 * sanitize_attributes: only a subset of attributes is allowed.
 * sanitize_style: only a subset of style attributes is allowed. Style
   attributes are parsed to keep only a white list.
 * strip_style: all style is removed. It bypasses sanitize_style as there
   is no need to sanitize something that is removed.
 * strip_classes: remove class attributes

Fields parameters have also been updated to match the sanitize options. Html
fields by default are sanitized with sanitize_tags activated but without any
further options. All addons have been updated to match the new options
according to their previous behavior.
This commit is contained in:
Thibault Delavallée
2016-08-30 15:54:56 +02:00
parent 97425bfe3f
commit 02c748be59
21 changed files with 110 additions and 56 deletions
+1 -1
View File
@@ -184,7 +184,7 @@ class EventEvent(models.Model):
readonly=False, states={'done': [('readonly', True)]})
country_id = fields.Many2one('res.country', 'Country', related='address_id.country_id', store=True)
description = fields.Html(
string='Description', oldname='note', translate=html_translate, sanitize=False,
string='Description', oldname='note', translate=html_translate, sanitize_attributes=False,
readonly=False, states={'done': [('readonly', True)]})
# badge fields
badge_front = fields.Html(string='Badge Front')
+1 -1
View File
@@ -50,7 +50,7 @@ class Message(models.Model):
# content
subject = fields.Char('Subject')
date = fields.Datetime('Date', default=fields.Datetime.now)
body = fields.Html('Contents', default='', strip_classes=True)
body = fields.Html('Contents', default='', sanitize_style=True, strip_classes=True)
attachment_ids = fields.Many2many(
'ir.attachment', 'message_attachment_rel',
'message_id', 'attachment_id',
+1 -1
View File
@@ -139,7 +139,7 @@ class MailTemplate(models.Model):
mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing Mail Server', readonly=False,
help="Optional preferred server for outgoing mails. If not set, the highest "
"priority one will be used.")
body_html = fields.Html('Body', translate=True, sanitize=False)
body_html = fields.Html('Body', translate=True, sanitize_attributes=False)
report_name = fields.Char('Report Filename', translate=True,
help="Name to use for the generated report file (may contain placeholders)\n"
"The extension can be omitted and will then come from the report type.")
+1 -1
View File
@@ -300,7 +300,7 @@ class MassMailing(models.Model):
create_date = fields.Datetime(string='Creation Date')
sent_date = fields.Datetime(string='Sent Date', oldname='date', copy=False)
schedule_date = fields.Datetime(string='Schedule in the Future')
body_html = fields.Html(string='Body', translate=html_translate, sanitize=False)
body_html = fields.Html(string='Body', translate=html_translate, sanitize_attributes=False)
attachment_ids = fields.Many2many('ir.attachment', 'mass_mailing_ir_attachments_rel',
'mass_mailing_id', 'attachment_id', string='Attachments')
keep_archives = fields.Boolean(string='Keep Archives')
+1 -1
View File
@@ -110,7 +110,7 @@ class BlogPost(models.Model):
default='{"background-image": "none", "background-color": "oe_black", "opacity": "0.2", "resize_class": ""}')
blog_id = fields.Many2one('blog.blog', 'Blog', required=True, ondelete='cascade')
tag_ids = fields.Many2many('blog.tag', string='Tags')
content = fields.Html('Content', default=_default_content, translate=html_translate, sanitize=False)
content = fields.Html('Content', default=_default_content, translate=html_translate, sanitize_attributes=False)
teaser = fields.Text('Teaser', compute='_compute_teaser', inverse='_set_teaser')
teaser_manual = fields.Text(string='Teaser Content')
@@ -49,7 +49,7 @@ class Track(models.Model):
state = fields.Selection([
('draft', 'Proposal'), ('confirmed', 'Confirmed'), ('announced', 'Announced'), ('published', 'Published'), ('refused', 'Refused'), ('cancel', 'Cancelled')],
'Status', default='draft', required=True, copy=False, track_visibility='onchange')
description = fields.Html('Track Description', translate=html_translate, sanitize=False)
description = fields.Html('Track Description', translate=html_translate, sanitize_attributes=False)
date = fields.Datetime('Track Date')
duration = fields.Float('Duration', default=1.5)
location_id = fields.Many2one('event.track.location', 'Room')
+1 -1
View File
@@ -711,7 +711,7 @@ class Post(models.Model):
question = self.parent_id
values = {
'author_id': self.sudo().create_uid.partner_id.id, # use sudo here because of access to res.users model
'body': tools.html_sanitize(self.content, strict=True, strip_style=True, strip_classes=True),
'body': tools.html_sanitize(self.content, sanitize_attributes=True, strip_style=True, strip_classes=True),
'message_type': 'comment',
'subtype': 'mail.mt_comment',
'date': self.create_date,
@@ -47,7 +47,7 @@ class Job(models.Model):
default_description = self.env["ir.model.data"].xmlid_to_object("website_hr_recruitment.default_website_description")
return (default_description.render() if default_description else "")
website_description = fields.Html('Website description', translate=html_translate, sanitize=False, default=_get_default_website_description)
website_description = fields.Html('Website description', translate=html_translate, sanitize_attributes=False, default=_get_default_website_description)
@api.multi
def _compute_website_url(self):
@@ -17,4 +17,4 @@ class ImLivechatChannel(models.Model):
for channel in self:
channel.website_url = "/livechat/channel/%s" % (slug(channel),)
website_description = fields.Html("Website description", default=False, help="Description of the channel displayed on the website page", sanitize=False, translate=html_translate)
website_description = fields.Html("Website description", default=False, help="Description of the channel displayed on the website page", sanitize_attributes=False, translate=html_translate)
@@ -18,6 +18,6 @@ class MassMailingList(models.Model):
</div>
<p class="o_message_paragraph">Join our Marketing newsletter and get <strong>this white paper instantly</strong></p>"""
popup_content = fields.Html(string="Website Popup Content", translate=True, sanitize=False,
popup_content = fields.Html(string="Website Popup Content", translate=True, sanitize_attributes=False,
default=_default_popup_content)
popup_redirect_url = fields.Char(string="Website Popup Redirect URL", default='/')
@@ -7,5 +7,5 @@ from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
website_description = fields.Html('Description for the website', sanitize=False) # hack, if website_sale is not installed
quote_description = fields.Html('Description for the quote', sanitize=False)
website_description = fields.Html('Description for the website', sanitize_attributes=False) # hack, if website_sale is not installed
quote_description = fields.Html('Description for the quote', sanitize_attributes=False)
+2 -2
View File
@@ -56,7 +56,7 @@ class SaleOrder(models.Model):
'sale.quote.template', 'Quotation Template',
default=_get_default_template_id, readonly=True,
states={'draft': [('readonly', False)], 'sent': [('readonly', False)]})
website_description = fields.Html('Description', sanitize=False, translate=html_translate)
website_description = fields.Html('Description', sanitize_attributes=False, translate=html_translate)
options = fields.One2many(
'sale.order.option', 'order_id', 'Optional Products Lines',
copy=True, readonly=True,
@@ -199,7 +199,7 @@ class SaleOrderOption(models.Model):
name = fields.Text('Description', required=True)
product_id = fields.Many2one('product.product', 'Product', domain=[('sale_ok', '=', True)])
layout_category_id = fields.Many2one('sale.layout_category', string='Section')
website_description = fields.Html('Line Description', sanitize=False, translate=html_translate)
website_description = fields.Html('Line Description', sanitize_attributes=False, translate=html_translate)
price_unit = fields.Float('Unit Price', required=True, digits=dp.get_precision('Product Price'))
discount = fields.Float('Discount (%)', digits=dp.get_precision('Discount'))
uom_id = fields.Many2one('product.uom', 'Unit of Measure ', required=True)
+2 -2
View File
@@ -11,7 +11,7 @@ class SaleQuoteTemplate(models.Model):
_description = "Sale Quotation Template"
name = fields.Char('Quotation Template', required=True)
website_description = fields.Html('Description', translate=html_translate, sanitize=False)
website_description = fields.Html('Description', translate=html_translate, sanitize_attributes=False)
quote_line = fields.One2many('sale.quote.line', 'quote_id', 'Quotation Template Lines', copy=True)
note = fields.Text('Terms and conditions')
options = fields.One2many('sale.quote.option', 'template_id', 'Optional Products Lines', copy=True)
@@ -100,7 +100,7 @@ class SaleQuoteOption(models.Model):
name = fields.Text('Description', required=True, translate=True)
product_id = fields.Many2one('product.product', 'Product', domain=[('sale_ok', '=', True)], required=True)
layout_category_id = fields.Many2one('sale.layout_category', string='Section')
website_description = fields.Html('Option Description', translate=html_translate, sanitize=False)
website_description = fields.Html('Option Description', translate=html_translate, sanitize_attributes=False)
price_unit = fields.Float('Unit Price', required=True, digits=dp.get_precision('Product Price'))
discount = fields.Float('Discount (%)', digits=dp.get_precision('Discount'))
uom_id = fields.Many2one('product.uom', 'Unit of Measure ', required=True)
+1 -1
View File
@@ -81,7 +81,7 @@ class ProductTemplate(models.Model):
domain=lambda self: ['&', ('model', '=', self._name), ('message_type', '=', 'comment')],
string='Website Comments',
)
website_description = fields.Html('Description for the website', sanitize=False, translate=True)
website_description = fields.Html('Description for the website', sanitize_attributes=False, translate=True)
alternative_product_ids = fields.Many2many('product.template', 'product_alternative_rel', 'src_id', 'dest_id',
string='Suggested Products', help='Appear on the product page')
accessory_product_ids = fields.Many2many('product.product', 'product_accessory_rel', 'src_id', 'dest_id',
+2 -2
View File
@@ -34,7 +34,7 @@ class Channel(models.Model):
name = fields.Char('Name', translate=True, required=True)
active = fields.Boolean(default=True)
description = fields.Html('Description', translate=html_translate, sanitize=False)
description = fields.Html('Description', translate=html_translate, sanitize_attributes=False)
sequence = fields.Integer(default=10, help='Display order')
category_ids = fields.One2many('slide.category', 'channel_id', string="Categories")
slide_ids = fields.One2many('slide.slide', 'channel_id', string="Slides")
@@ -102,7 +102,7 @@ class Channel(models.Model):
string='Channel Groups', help="Groups allowed to see presentations in this channel")
access_error_msg = fields.Html(
'Error Message', help="Message to display when not accessible due to access rights",
default="<p>This channel is private and its content is restricted to some users.</p>", translate=html_translate, sanitize=False)
default="<p>This channel is private and its content is restricted to some users.</p>", translate=html_translate, sanitize_attributes=False)
upload_group_ids = fields.Many2many(
'res.groups', 'rel_upload_groups', 'channel_id', 'group_id',
string='Upload Groups', help="Groups allowed to upload presentations in this channel. If void, every user can upload.")
+10 -3
View File
@@ -133,18 +133,25 @@ class TestSanitizer(unittest.TestCase):
]
for test, in_lst, out_lst in test_data:
new_html = html_sanitize(test, strict=False, strip_style=False, strip_classes=False)
new_html = html_sanitize(test, sanitize_attributes=False, sanitize_style=True, strip_style=False, strip_classes=False)
for text in in_lst:
self.assertIn(text, new_html)
for text in out_lst:
self.assertNotIn(text, new_html)
# style should not be sanitized if removed
new_html = html_sanitize(test_data[0][0], strict=False, strip_style=True, strip_classes=False)
new_html = html_sanitize(test_data[0][0], sanitize_attributes=False, strip_style=True, strip_classes=False)
self.assertEqual(new_html, u'<span>Coin coin </span>')
def test_style_class(self):
html = html_sanitize(test_mail_examples.REMOVE_CLASS, strict=True, strip_style=False, strip_classes=True)
html = html_sanitize(test_mail_examples.REMOVE_CLASS, sanitize_attributes=True, sanitize_style=True, strip_classes=True)
for ext in test_mail_examples.REMOVE_CLASS_IN:
self.assertIn(ext, html)
for ext in test_mail_examples.REMOVE_CLASS_OUT:
self.assertNotIn(ext, html,)
def test_style_class_only(self):
html = html_sanitize(test_mail_examples.REMOVE_CLASS, sanitize_attributes=False, sanitize_style=True, strip_classes=True)
for ext in test_mail_examples.REMOVE_CLASS_IN:
self.assertIn(ext, html)
for ext in test_mail_examples.REMOVE_CLASS_OUT:
+3 -3
View File
@@ -193,9 +193,9 @@ class MixedModel(models.Model):
reference = fields.Reference(string='Related Document',
selection='_reference_models')
comment1 = fields.Html(sanitize=False)
comment2 = fields.Html(sanitize=True, strip_classes=False)
comment3 = fields.Html(sanitize=True, strip_classes=True)
comment4 = fields.Html(sanitize=True, strip_style=True)
comment2 = fields.Html(sanitize_attributes=True, strip_classes=False)
comment3 = fields.Html(sanitize_attributes=True, strip_classes=True)
comment4 = fields.Html(sanitize_attributes=True, strip_style=True)
currency_id = fields.Many2one('res.currency', default=lambda self: self.env.ref('base.EUR'))
amount = fields.Monetary()
@@ -605,9 +605,9 @@ class TestHtmlField(common.TransactionCase):
def test_00_sanitize(self):
self.assertEqual(self.model._fields['comment1'].sanitize, False)
self.assertEqual(self.model._fields['comment2'].sanitize, True)
self.assertEqual(self.model._fields['comment2'].sanitize_attributes, True)
self.assertEqual(self.model._fields['comment2'].strip_classes, False)
self.assertEqual(self.model._fields['comment3'].sanitize, True)
self.assertEqual(self.model._fields['comment3'].sanitize_attributes, True)
self.assertEqual(self.model._fields['comment3'].strip_classes, True)
some_ugly_html = """<p>Oops this should maybe be sanitized
+23 -2
View File
@@ -1304,7 +1304,10 @@ class Html(_String):
type = 'html'
_slots = {
'sanitize': True, # whether value must be sanitized
'strip_style': False, # whether to strip style attributes (otherwise: sanitized)
'sanitize_tags': True, # whether to sanitize tags (only a white list of attributes is accepted)
'sanitize_attributes': True, # whether to sanitize attributes (only a white list of attributes is accepted)
'sanitize_style': False, # whether to sanitize style attributes
'strip_style': False, # whether to strip style attributes (removed and therefore not sanitized)
'strip_classes': False, # whether to strip classes attributes
}
@@ -1318,6 +1321,18 @@ class Html(_String):
_related_sanitize = property(attrgetter('sanitize'))
_description_sanitize = property(attrgetter('sanitize'))
_column_sanitize_tags = property(attrgetter('sanitize_tags'))
_related_sanitize_tags = property(attrgetter('sanitize_tags'))
_description_sanitize_tags = property(attrgetter('sanitize_tags'))
_column_sanitize_attributes = property(attrgetter('sanitize_attributes'))
_related_sanitize_attributes = property(attrgetter('sanitize_attributes'))
_description_sanitize_attributes = property(attrgetter('sanitize_attributes'))
_column_sanitize_style = property(attrgetter('sanitize_style'))
_related_sanitize_style = property(attrgetter('sanitize_style'))
_description_sanitize_style = property(attrgetter('sanitize_style'))
_column_strip_style = property(attrgetter('strip_style'))
_related_strip_style = property(attrgetter('strip_style'))
_description_strip_style = property(attrgetter('strip_style'))
@@ -1330,7 +1345,13 @@ class Html(_String):
if value is None or value is False:
return False
if validate and self.sanitize:
return html_sanitize(value, silent=True, strict=True, strip_style=self.strip_style, strip_classes=self.strip_classes)
return html_sanitize(
value, silent=True,
sanitize_tags=self.sanitize_tags,
sanitize_attributes=self.sanitize_attributes,
sanitize_style=self.sanitize_style,
strip_style=self.strip_style,
strip_classes=self.strip_classes)
return value
+14 -3
View File
@@ -339,18 +339,26 @@ class text(_column):
class html(text):
_type = 'html'
_symbol_c = '%s'
__slots__ = ['_sanitize', '_strip_style', '_strip_classes', '_symbol_f', '_symbol_set']
__slots__ = ['_sanitize', '_sanitize_tags', '_sanitize_attributes', '_sanitize_style', '_strip_style', '_strip_classes', '_symbol_f', '_symbol_set']
def _symbol_set_html(self, value):
if value is None or value is False:
return None
if not self._sanitize:
return value
return html_sanitize(value, silent=True, strict=True, strip_style=self._strip_style, strip_classes=self._strip_classes)
return html_sanitize(
value, silent=True,
sanitize_attributes=self._sanitize_attributes,
sanitize_style=self._sanitize_style,
strip_style=self._strip_style,
strip_classes=self._strip_classes)
def __init__(self, string='unknown', sanitize=True, strip_style=False, strip_classes=False, **args):
def __init__(self, string='unknown', sanitize=True, sanitize_tags=True, sanitize_attributes=True, sanitize_style=False, strip_style=False, strip_classes=False, **args):
super(html, self).__init__(string=string, **args)
self._sanitize = sanitize
self._sanitize_tags = sanitize_tags
self._sanitize_attributes = sanitize_attributes
self._sanitize_style = sanitize_style
self._strip_style = strip_style
self._strip_classes = strip_classes
# symbol_set redefinition because of sanitize specific behavior
@@ -360,6 +368,9 @@ class html(text):
def to_field_args(self):
args = super(html, self).to_field_args()
args['sanitize'] = self._sanitize
args['sanitize_tags'] = self._sanitize_tags
args['sanitize_attributes'] = self._sanitize_attributes
args['sanitize_style'] = self._sanitize_style
args['strip_style'] = self._strip_style
args['strip_classes'] = self._strip_classes
return args
+39 -24
View File
@@ -50,6 +50,9 @@ class _Cleaner(clean.Cleaner):
# tables
'border-collapse', 'border-spacing', 'caption-side', 'empty-cells', 'table-layout']
strip_classes = False
sanitize_style = False
def __call__(self, doc):
# perform quote detection before cleaning and class removal
for el in doc.iter():
@@ -57,8 +60,13 @@ class _Cleaner(clean.Cleaner):
super(_Cleaner, self).__call__(doc)
# if we keep attributes but still remove classes
if not getattr(self, 'safe_attrs_only', False) and self.strip_classes:
for el in doc.iter():
self.strip_class(el)
# if we keep style attribute, sanitize them
if not self.style:
if not self.style and self.sanitize_style:
for el in doc.iter():
self.parse_style(el)
@@ -123,6 +131,10 @@ class _Cleaner(clean.Cleaner):
if el.getparent() is not None and (el.getparent().get('data-o-mail-quote') or el.getparent().get('data-o-mail-quote-container')) and not el.getparent().get('data-o-mail-quote-node'):
el.set('data-o-mail-quote', '1')
def strip_class(self, el):
if el.attrib.get('class'):
del el.attrib['class']
def parse_style(self, el):
attributes = el.attrib
styling = attributes.get('style')
@@ -143,7 +155,7 @@ class _Cleaner(clean.Cleaner):
return super(_Cleaner, self).allow_element(el)
def html_sanitize(src, silent=True, strict=False, strip_style=False, strip_classes=False):
def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=False, sanitize_style=False, strip_style=False, strip_classes=False):
if not src:
return src
src = ustr(src, errors='replace')
@@ -164,35 +176,38 @@ def html_sanitize(src, silent=True, strict=False, strip_style=False, strip_class
kwargs = {
'page_structure': True,
'style': strip_style, # True = remove style tags/attrs
'forms': True, # remove form tags
'style': strip_style, # True = remove style tags/attrs
'sanitize_style': sanitize_style, # True = sanitize styling
'forms': True, # True = remove form tags
'remove_unknown_tags': False,
'allow_tags': allowed_tags,
'comments': False,
'processing_instructions': False
}
if etree.LXML_VERSION >= (2, 3, 1):
# kill_tags attribute has been added in version 2.3.1
if sanitize_tags:
kwargs['allow_tags'] = allowed_tags
if etree.LXML_VERSION >= (2, 3, 1):
# kill_tags attribute has been added in version 2.3.1
kwargs.update({
'kill_tags': tags_to_kill,
'remove_tags': tags_to_remove,
})
else:
kwargs['remove_tags'] = tags_to_kill + tags_to_remove
if sanitize_attributes and etree.LXML_VERSION >= (3, 1, 0): # lxml < 3.1.0 does not allow to specify safe_attrs. We keep all attributes in order to keep "style"
if strip_classes:
current_safe_attrs = safe_attrs - frozenset(['class'])
else:
current_safe_attrs = safe_attrs
kwargs.update({
'kill_tags': tags_to_kill,
'remove_tags': tags_to_remove,
'safe_attrs_only': True,
'safe_attrs': current_safe_attrs,
})
else:
kwargs['remove_tags'] = tags_to_kill + tags_to_remove
if strict:
if etree.LXML_VERSION >= (3, 1, 0):
# lxml < 3.1.0 does not allow to specify safe_attrs. We keep all attributes in order to keep "style"
if strip_classes:
current_safe_attrs = safe_attrs - frozenset(['class'])
else:
current_safe_attrs = safe_attrs
kwargs.update({
'safe_attrs_only': True,
'safe_attrs': current_safe_attrs,
})
else:
kwargs['safe_attrs_only'] = False # keep oe-data attributes + style
kwargs.update({
'safe_attrs_only': False, # keep oe-data attributes + style
'strip_classes': strip_classes, # remove classes, even when keeping other attributes
})
try:
# some corner cases make the parser crash (such as <SCRIPT/XSS SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT> in test_mail)