[FIX] core: extract normalize from HTML sanitizer
Since [1], it's possible to conditionnally bypass the HTML sanitizer in field definition with the `sanitize_overridable` attribute. In a nutshell, when someone is part of the required group(s), it won't go through the sanitizer, while the people not part of the group(s) will. A behavior was thus introcuded to prevent a "restricted" user to wipe the changes done previously by an "elevated" user (which bypassed the sanitizer). But that behavior was not correct as there was unforeseen cases which led to raise this error which are not due to the sanitizer but to normalization. Indeed, while named `html_sanitize()`, it also does some normalize stuff on top of the real sanitize part. For instance, there is also (not exhaustive): - some MAKO compatibility, replacing some chars - special case for quotes, related to mail clients, which will add data attributes, add nodes in dom etc. This happen when the following are found: - `<blockquote/>` tag - text-based quotes (>, >>) and signatures (-- Signature) - html signature (-- <br />blah) - some editor compatibility which removed the wrapping `<div/>` element - `nbsp` handling.. See commit list below for detail about how/when/why those normalize cases where introduced. At the end, the issue was that the normalize part should not prevent a "restricted" user to modify the content of an "elevated" user. Only the sanitize part should. For instance, the `Quotes` snippet dropped by an "elevated" user was preventing further edition by a "restricted" user because there was a "false positive" raised when checking if the save would wipe the existing changes. Indeed, when the "elevated" user droped the snippet, it was saved as: ```html <blockquote class=".." data-name="Blockquote"> ``` But when the "restricted" user then wanted to do some changes, it would become: ```html <blockquote class=".." data-name="Blockquote" data-o-mail-quote-node="1" data-o-mail-quote="1"> ``` Same for `Share` snippet: ```html <a href="https://www.facebook.com/sharer/sharer.php?u={url}"> <a href="https://www.facebook.com/sharer/sharer.php?u=%7Burl%7D"> ``` [1]: https://github.com/odoo/odoo/commit/cf844e34dd0ce4830eb99fd0fa5b6b9cb58c867c Normalize commit list: https://github.com/odoo/odoo/commit/5f1ec49ecdac6d72cd42755c41fbe75d6a1f3587 https://github.com/odoo/odoo/commit/69af79ff3d705d19a71ba3ba7851b981cb301077 https://github.com/odoo/odoo/commit/2bcf4cca79a57dfba84d1f3e3fa7b8908bfe66e8 https://github.com/odoo/odoo/commit/f5688cd8fd515d1b668e8eb1d74de68faa681a01 https://github.com/odoo/odoo/commit/cb8c2d2b7e15c7c16e02d078767e27a07e5012c6 https://github.com/odoo/odoo/commit/275ee5825d38841a3eb21bb195722f3ceed09005 https://github.com/odoo/odoo/commit/b51d21c5b83b88e8d56dbbbb7600bcbe554d1b07 closes odoo/odoo#110903 X-original-commit: 3a2e82cf40f3265650b4f79f9ad5fe309906311d Signed-off-by: Romain Derie (rde) <rde@odoo.com>
This commit is contained in:
@@ -3045,7 +3045,23 @@ class TestHtmlField(common.TransactionCase):
|
||||
})
|
||||
record = self.env['test_new_api.mixed'].create({})
|
||||
|
||||
# 1. Test main use case: prevent restricted user to wipe non restricted
|
||||
# 1. Test normalize case: diff due to normalize should not prevent the
|
||||
# changes
|
||||
val = '<blockquote>Something</blockquote>'
|
||||
normalized_val = '<blockquote data-o-mail-quote-node="1" data-o-mail-quote="1">Something</blockquote>'
|
||||
write_vals = {'comment5': val}
|
||||
|
||||
record.with_user(internal_user).write(write_vals)
|
||||
self.assertEqual(record.comment5, normalized_val,
|
||||
"should be normalized (not in groups)")
|
||||
record.with_user(bypass_user).write(write_vals)
|
||||
self.assertEqual(record.comment5, val,
|
||||
"should not be normalized (has group)")
|
||||
record.with_user(internal_user).write(write_vals)
|
||||
self.assertEqual(record.comment5, normalized_val,
|
||||
"should be normalized (not in groups) despite admin previous diff")
|
||||
|
||||
# 2. Test main use case: prevent restricted user to wipe non restricted
|
||||
# user previous change
|
||||
val = '<script></script>'
|
||||
write_vals = {'comment5': val}
|
||||
@@ -3061,7 +3077,7 @@ class TestHtmlField(common.TransactionCase):
|
||||
# other user that bypassed the sanitize)
|
||||
record.with_user(internal_user).write(write_vals)
|
||||
|
||||
# 2. Make sure field compare in `_convert` is working as expected with
|
||||
# 3. Make sure field compare in `_convert` is working as expected with
|
||||
# special content / format
|
||||
val = '<span attr1 ="att1" attr2=\'attr2\'>é@ </span><p><span/></p>'
|
||||
write_vals = {'comment5': val}
|
||||
@@ -3078,6 +3094,13 @@ class TestHtmlField(common.TransactionCase):
|
||||
# Next write shouldn't raise a sanitize right error
|
||||
record.with_user(internal_user).write(write_vals)
|
||||
|
||||
# 4. Ensure our exception handling is fine
|
||||
val = '<!-- I am a comment -->'
|
||||
write_vals = {'comment5': val}
|
||||
record.with_user(internal_user).write(write_vals)
|
||||
self.assertEqual(record.comment5, '',
|
||||
"should be sanitized (not in groups)")
|
||||
|
||||
|
||||
class TestMagicFields(common.TransactionCase):
|
||||
|
||||
|
||||
+5
-5
@@ -27,9 +27,10 @@ from difflib import get_close_matches
|
||||
from hashlib import sha256
|
||||
|
||||
from .tools import (
|
||||
float_repr, float_round, float_compare, float_is_zero, html_sanitize, human_size,
|
||||
float_repr, float_round, float_compare, float_is_zero, human_size,
|
||||
pg_varchar, ustr, OrderedSet, pycompat, sql, date_utils, unique,
|
||||
image_process, merge_sequences, SQL_ORDER_BY_TYPE, is_list_of, has_list_types,
|
||||
html_normalize, html_sanitize,
|
||||
)
|
||||
from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT
|
||||
from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT
|
||||
@@ -1988,14 +1989,13 @@ class Html(_String):
|
||||
|
||||
original_value = record[self.name]
|
||||
if original_value:
|
||||
# Note that sanitize also normalize
|
||||
original_value_sanitized = html_sanitize(original_value, **sanitize_vals)
|
||||
|
||||
def get_parsed(val):
|
||||
return etree.tostring(html.fromstring(val))
|
||||
original_value_normalized = html_normalize(original_value)
|
||||
|
||||
if (
|
||||
not original_value_sanitized # sanitizer could empty it
|
||||
or get_parsed(original_value) != get_parsed(original_value_sanitized)
|
||||
or original_value_normalized != original_value_sanitized
|
||||
):
|
||||
# The field contains element(s) that would be removed if
|
||||
# sanitized. It means that someone who was part of a group
|
||||
|
||||
+86
-48
@@ -14,7 +14,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import idna
|
||||
import markupsafe
|
||||
from lxml import etree
|
||||
from lxml import etree, html
|
||||
from lxml.html import clean
|
||||
from werkzeug import urls
|
||||
|
||||
@@ -74,10 +74,6 @@ class _Cleaner(clean.Cleaner):
|
||||
sanitize_style = False
|
||||
|
||||
def __call__(self, doc):
|
||||
# perform quote detection before cleaning and class removal
|
||||
for el in doc.iter(tag=etree.Element):
|
||||
tag_quote(el)
|
||||
|
||||
super(_Cleaner, self).__call__(doc)
|
||||
|
||||
# if we keep attributes but still remove classes
|
||||
@@ -178,68 +174,110 @@ def tag_quote(el):
|
||||
el.set('data-o-mail-quote', '1')
|
||||
|
||||
|
||||
def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=False, sanitize_style=False, sanitize_form=True, strip_style=False, strip_classes=False):
|
||||
def html_normalize(src, filter_callback=None):
|
||||
""" Normalize `src` for storage as an html field value.
|
||||
|
||||
The string is parsed as an html tag soup, made valid, then decorated for
|
||||
"email quote" detection, and prepared for an optional filtering.
|
||||
The filtering step (e.g. sanitization) should be performed by the
|
||||
`filter_callback` function (to avoid multiple parsing operations, and
|
||||
normalize the result).
|
||||
|
||||
:param src: the html string to normalize
|
||||
:param filter_callback: optional callable taking a single `etree._Element`
|
||||
document parameter, to be called during normalization in order to
|
||||
filter the output document
|
||||
"""
|
||||
|
||||
if not src:
|
||||
return src
|
||||
|
||||
src = ustr(src, errors='replace')
|
||||
# html: remove encoding attribute inside tags
|
||||
doctype = re.compile(r'(<[^>]*\s)(encoding=(["\'][^"\']*?["\']|[^\s\n\r>]+)(\s[^>]*|/)?>)', re.IGNORECASE | re.DOTALL)
|
||||
src = doctype.sub(u"", src)
|
||||
|
||||
logger = logging.getLogger(__name__ + '.html_sanitize')
|
||||
|
||||
kwargs = {
|
||||
'page_structure': True,
|
||||
'style': strip_style, # True = remove style tags/attrs
|
||||
'sanitize_style': sanitize_style, # True = sanitize styling
|
||||
'forms': sanitize_form, # True = remove form tags
|
||||
'remove_unknown_tags': False,
|
||||
'comments': False,
|
||||
'processing_instructions': False
|
||||
}
|
||||
if sanitize_tags:
|
||||
kwargs.update(SANITIZE_TAGS)
|
||||
|
||||
if sanitize_attributes: # 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.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)
|
||||
cleaner = _Cleaner(**kwargs)
|
||||
cleaned = cleaner.clean_html(src)
|
||||
assert isinstance(cleaned, str)
|
||||
# html considerations so real html content match database value
|
||||
cleaned = cleaned.replace(u'\xa0', u' ')
|
||||
doc = html.fromstring(src)
|
||||
except etree.ParserError as e:
|
||||
# HTML comment only string, whitespace only..
|
||||
if 'empty' in str(e):
|
||||
return u""
|
||||
raise
|
||||
|
||||
# perform quote detection before cleaning and class removal
|
||||
if doc is not None:
|
||||
for el in doc.iter(tag=etree.Element):
|
||||
tag_quote(el)
|
||||
|
||||
if filter_callback:
|
||||
doc = filter_callback(doc)
|
||||
|
||||
src = html.tostring(doc, encoding='unicode')
|
||||
|
||||
# this is ugly, but lxml/etree tostring want to put everything in a
|
||||
# 'div' that breaks the editor -> remove that
|
||||
if src.startswith('<div>') and src.endswith('</div>'):
|
||||
src = src[5:-6]
|
||||
|
||||
# html considerations so real html content match database value
|
||||
src = src.replace(u'\xa0', u' ')
|
||||
|
||||
return src
|
||||
|
||||
|
||||
def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=False, sanitize_style=False, sanitize_form=True, strip_style=False, strip_classes=False):
|
||||
if not src:
|
||||
return src
|
||||
|
||||
logger = logging.getLogger(__name__ + '.html_sanitize')
|
||||
|
||||
def sanitize_handler(doc):
|
||||
kwargs = {
|
||||
'page_structure': True,
|
||||
'style': strip_style, # True = remove style tags/attrs
|
||||
'sanitize_style': sanitize_style, # True = sanitize styling
|
||||
'forms': sanitize_form, # True = remove form tags
|
||||
'remove_unknown_tags': False,
|
||||
'comments': False,
|
||||
'processing_instructions': False
|
||||
}
|
||||
if sanitize_tags:
|
||||
kwargs.update(SANITIZE_TAGS)
|
||||
|
||||
if sanitize_attributes: # 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.update({
|
||||
'safe_attrs_only': False, # keep oe-data attributes + style
|
||||
'strip_classes': strip_classes, # remove classes, even when keeping other attributes
|
||||
})
|
||||
|
||||
cleaner = _Cleaner(**kwargs)
|
||||
cleaner(doc)
|
||||
return doc
|
||||
|
||||
try:
|
||||
sanitized = html_normalize(src, filter_callback=sanitize_handler)
|
||||
except etree.ParserError:
|
||||
if not silent:
|
||||
raise
|
||||
logger.warning(u'ParserError obtained when sanitizing %r', src, exc_info=True)
|
||||
cleaned = u'<p>ParserError when sanitizing</p>'
|
||||
sanitized = '<p>ParserError when sanitizing</p>'
|
||||
except Exception:
|
||||
if not silent:
|
||||
raise
|
||||
logger.warning(u'unknown error obtained when sanitizing %r', src, exc_info=True)
|
||||
cleaned = u'<p>Unknown error when sanitizing</p>'
|
||||
sanitized = '<p>Unknown error when sanitizing</p>'
|
||||
|
||||
# this is ugly, but lxml/etree tostring want to put everything in a 'div' that breaks the editor -> remove that
|
||||
if cleaned.startswith(u'<div>') and cleaned.endswith(u'</div>'):
|
||||
cleaned = cleaned[5:-6]
|
||||
|
||||
return markupsafe.Markup(cleaned)
|
||||
return markupsafe.Markup(sanitized)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# HTML/Text management
|
||||
|
||||
Reference in New Issue
Block a user