From e272e977c8785315f77613481d378d3e9e906c7b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sun, 21 Nov 2021 15:52:46 +0100
Subject: [PATCH] misc: switch html cleanup to bleach (#58808)

---
 debian/control        |   2 +-
 setup.py              |   1 +
 tests/test_widgets.py |  52 +++----
 tox.ini               |   1 +
 wcs/qommon/form.py    | 310 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 336 insertions(+), 30 deletions(-)

diff --git a/debian/control b/debian/control
index a6e54d924..4b3879515 100644
--- a/debian/control
+++ b/debian/control
@@ -17,10 +17,10 @@ Homepage: https://dev.entrouvert.org/projects/wcs/
 Package: wcs
 Architecture: all
 Depends: graphviz,
+         python3-bleach,
          python3-django (>= 1:1.11),
          python3-django-ckeditor,
          python3-django-ratelimit,
-         python3-feedparser,
          python3-hobo,
          python3-lasso,
          python3-pil,
diff --git a/setup.py b/setup.py
index bdfc79fa2..bd364e880 100644
--- a/setup.py
+++ b/setup.py
@@ -168,6 +168,7 @@ setup(
     maintainer_email="fpeters@entrouvert.com",
     url="http://wcs.labs.libre-entreprise.org",
     install_requires=[
+        'bleach',
         'gadjo>=0.53',
         'django-ckeditor<4.5.4',
         'django-ratelimit<3',
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 8e60672dd..d973337d7 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -420,24 +420,38 @@ def test_wysiwygwidget():
     assert not widget.has_error()
     assert widget.parse() == 'bla bla bla'
 
-    import wcs.qommon.form
+    widget = WysiwygTextWidget('test')
+    mock_form_submission(req, widget, {'test': '<p>bla bla bla</p>'})
+    assert not widget.has_error()
+    assert widget.parse() == '<p>bla bla bla</p>'
 
-    sanitize_html = wcs.qommon.form._sanitizeHTML
-    if sanitize_html:
-        widget = WysiwygTextWidget('test')
-        mock_form_submission(req, widget, {'test': '<p>bla bla bla</p>'})
-        assert not widget.has_error()
-        assert widget.parse() == '<p>bla bla bla</p>'
+    widget = WysiwygTextWidget('test')
+    mock_form_submission(req, widget, {'test': '<a href="#">a</a>'})
+    assert not widget.has_error()
+    assert widget.parse() == '<a href="#">a</a>'
 
-        widget = WysiwygTextWidget('test')
-        mock_form_submission(req, widget, {'test': '<a href="#">a</a>'})
-        assert not widget.has_error()
-        assert widget.parse() == '<a href="#">a</a>'
+    widget = WysiwygTextWidget('test')
+    mock_form_submission(req, widget, {'test': '<a href="javascript:alert()">a</a>'})
+    assert not widget.has_error()
+    assert widget.parse() == '<a>a</a>'  # javascript: got filtered
 
-        widget = WysiwygTextWidget('test')
-        mock_form_submission(req, widget, {'test': '<a href="javascript:alert()">a</a>'})
-        assert not widget.has_error()
-        assert widget.parse() == '<a href="">a</a>'  # javascript: got filtered
+    # check comments are kept
+    widget = WysiwygTextWidget('test')
+    mock_form_submission(req, widget, {'test': '<p>hello</p><!-- world --><p>.</p>'})
+    assert not widget.has_error()
+    assert widget.parse() == '<p>hello</p><!-- world --><p>.</p>'
+
+    # check <script> are kept
+    widget = WysiwygTextWidget('test')
+    mock_form_submission(req, widget, {'test': '<p>hello</p><script>alert("test")</script>'})
+    assert not widget.has_error()
+    assert widget.parse() == '<p>hello</p><script>alert("test")</script>'
+
+    # check <style> are kept
+    widget = WysiwygTextWidget('test')
+    mock_form_submission(req, widget, {'test': '<p>hello</p><style>p { color: blue; }</style>'})
+    assert not widget.has_error()
+    assert widget.parse() == '<p>hello</p><style>p { color: blue; }</style>'
 
     # check django syntax is kept intact
     widget = WysiwygTextWidget('test')
@@ -467,14 +481,6 @@ def test_wysiwygwidget():
         == '<a href="{% if 1 > 2 %}héllo{% endif %}">{% if 2 > 1 %}{{plop|date:"Y"}}{% endif %}</a>'
     )
 
-    # check we don't escape HTML if feedparser _sanitizeHTML is missing
-    wcs.qommon.form._sanitizeHTML = None
-    widget = WysiwygTextWidget('test')
-    mock_form_submission(req, widget, {'test': '<p>bla bla bla</p>'})
-    assert not widget.has_error()
-    assert widget.parse() == '<p>bla bla bla</p>'
-    wcs.qommon.form._sanitizeHTML = sanitize_html
-
 
 def test_select_hint_widget():
     widget = SingleSelectHintWidget(
diff --git a/tox.ini b/tox.ini
index d1fe9eeba..fbf971510 100644
--- a/tox.ini
+++ b/tox.ini
@@ -44,6 +44,7 @@ deps =
     Quixote>=3.0,<3.2
     pre-commit
     pyzbar
+    bleach
 commands =
     py.test -v {env:COVERAGE:} --junitxml=junit-{envname}.xml {posargs:tests/}
     pylint: ./pylint.sh wcs/ tests/
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index 82aa5c17e..719150e0b 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -36,10 +36,7 @@ try:
 except ImportError:
     Image = None
 
-try:
-    from feedparser import _sanitizeHTML
-except ImportError:
-    _sanitizeHTML = None
+import bleach
 
 try:
     import DNS
@@ -1904,11 +1901,312 @@ class WidgetDict(quixote.form.widget.WidgetDict):
 
 
 class WysiwygTextWidget(TextWidget):
+    ALL_TAGS = [
+        'a',
+        'abbr',
+        'acronym',
+        'address',
+        'area',
+        'article',
+        'aside',
+        'audio',
+        'b',
+        'big',
+        'blockquote',
+        'br',
+        'button',
+        'canvas',
+        'caption',
+        'center',
+        'cite',
+        'code',
+        'col',
+        'colgroup',
+        'command',
+        'datagrid',
+        'datalist',
+        'dd',
+        'del',
+        'details',
+        'dfn',
+        'dialog',
+        'dir',
+        'div',
+        'dl',
+        'dt',
+        'em',
+        'event-source',
+        'fieldset',
+        'figcaption',
+        'figure',
+        'font',
+        'footer',
+        'form',
+        'h1',
+        'h2',
+        'h3',
+        'h4',
+        'h5',
+        'h6',
+        'header',
+        'hr',
+        'i',
+        'img',
+        'input',
+        'ins',
+        'kbd',
+        'keygen',
+        'label',
+        'legend',
+        'li',
+        'm',
+        'map',
+        'menu',
+        'meter',
+        'multicol',
+        'nav',
+        'nextid',
+        'noscript',
+        'ol',
+        'optgroup',
+        'option',
+        'output',
+        'p',
+        'pre',
+        'progress',
+        'q',
+        's',
+        'samp',
+        'script',
+        'section',
+        'select',
+        'small',
+        'sound',
+        'source',
+        'spacer',
+        'span',
+        'strike',
+        'strong',
+        'style',
+        'sub',
+        'sup',
+        'table',
+        'tbody',
+        'td',
+        'textarea',
+        'tfoot',
+        'th',
+        'thead',
+        'time',
+        'tr',
+        'tt',
+        'u',
+        'ul',
+        'var',
+        'video',
+    ]
+    ALL_ATTRS = [
+        'abbr',
+        'accept',
+        'accept-charset',
+        'accesskey',
+        'action',
+        'align',
+        'alt',
+        'autocomplete',
+        'autofocus',
+        'axis',
+        'background',
+        'balance',
+        'bgcolor',
+        'bgproperties',
+        'border',
+        'bordercolor',
+        'bordercolordark',
+        'bordercolorlight',
+        'bottompadding',
+        'cellpadding',
+        'cellspacing',
+        'ch',
+        'challenge',
+        'char',
+        'charoff',
+        'charset',
+        'checked',
+        'choff',
+        'cite',
+        'class',
+        'clear',
+        'color',
+        'cols',
+        'colspan',
+        'compact',
+        'contenteditable',
+        'controls',
+        'coords',
+        'data',
+        'datafld',
+        'datapagesize',
+        'datasrc',
+        'datetime',
+        'default',
+        'delay',
+        'dir',
+        'disabled',
+        'draggable',
+        'dynsrc',
+        'enctype',
+        'end',
+        'face',
+        'for',
+        'form',
+        'frame',
+        'galleryimg',
+        'gutter',
+        'headers',
+        'height',
+        'hidden',
+        'hidefocus',
+        'high',
+        'href',
+        'hreflang',
+        'hspace',
+        'icon',
+        'id',
+        'inputmode',
+        'ismap',
+        'keytype',
+        'label',
+        'lang',
+        'leftspacing',
+        'list',
+        'longdesc',
+        'loop',
+        'loopcount',
+        'loopend',
+        'loopstart',
+        'low',
+        'lowsrc',
+        'max',
+        'maxlength',
+        'media',
+        'method',
+        'min',
+        'multiple',
+        'name',
+        'nohref',
+        'noshade',
+        'nowrap',
+        'open',
+        'optimum',
+        'pattern',
+        'ping',
+        'point-size',
+        'poster',
+        'pqg',
+        'preload',
+        'prompt',
+        'radiogroup',
+        'readonly',
+        'rel',
+        'repeat-max',
+        'repeat-min',
+        'replace',
+        'required',
+        'rev',
+        'rightspacing',
+        'rows',
+        'rowspan',
+        'rules',
+        'scope',
+        'selected',
+        'shape',
+        'size',
+        'span',
+        'src',
+        'start',
+        'step',
+        'style',
+        'summary',
+        'suppress',
+        'tabindex',
+        'target',
+        'template',
+        'title',
+        'toppadding',
+        'type',
+        'unselectable',
+        'urn',
+        'usemap',
+        'valign',
+        'value',
+        'variable',
+        'volume',
+        'vrml',
+        'vspace',
+        'width',
+        'wrap',
+        'xml:lang',
+    ]
+    ALL_STYLES = [
+        'azimuth',
+        'background-color',
+        'border-bottom-color',
+        'border-collapse',
+        'border-color',
+        'border-left-color',
+        'border-right-color',
+        'border-top-color',
+        'clear',
+        'color',
+        'cursor',
+        'direction',
+        'display',
+        'elevation',
+        'float',
+        'font',
+        'font-family',
+        'font-size',
+        'font-style',
+        'font-variant',
+        'font-weight',
+        'height',
+        'letter-spacing',
+        'line-height',
+        'overflow',
+        'pause',
+        'pause-after',
+        'pause-before',
+        'pitch',
+        'pitch-range',
+        'richness',
+        'speak',
+        'speak-header',
+        'speak-numeral',
+        'speak-punctuation',
+        'speech-rate',
+        'stress',
+        'text-align',
+        'text-decoration',
+        'text-indent',
+        'unicode-bidi',
+        'vertical-align',
+        'voice-family',
+        'volume',
+        'white-space',
+        'width',
+    ]
+
     def _parse(self, request):
         TextWidget._parse(self, request, use_validation_function=False)
         if self.value:
-            if _sanitizeHTML:
-                self.value = _sanitizeHTML(self.value, get_request().charset, 'text/html')
+            self.value = bleach.clean(
+                self.value,
+                tags=self.ALL_TAGS,
+                attributes=self.ALL_ATTRS,
+                styles=self.ALL_STYLES,
+                strip_comments=False,
+            )
             if self.value.startswith('<br />'):
                 self.value = self.value[6:]
             if self.value.endswith('<br />'):
-- 
2.34.0

