CVE-2021-43818: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: lxml is a library for processing XML and HTML in the Python language. Prior to version 4.6.5, the HTML Cleaner in lxml.html lets certain crafted script content pass through, as well as script content in SVG files embedded using data URIs. Users that employ the HTML cleaner in a security relevant context should upgrade to lxml 4.6.5 to receive a patch. There are no known workarounds available.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 7.1
Published: 2021-12-13T18:15Z
Repository: https://github.com/lxml/lxml
Language: Python
Files changed: src/lxml/html/clean.py, src/lxml/html/tests/test_clean.py
Commit date: 2021-11-11 15:39:27 +0100
Commit message: Cleaner: Remove SVG image data URLs since they can embed script content.

Reported as GHSL-2021-1038

Diff: diff --git a/src/lxml/html/clean.py b/src/lxml/html/clean.py
index 25844e873..dd3a28ad1 100644
--- a/src/lxml/html/clean.py
+++ b/src/lxml/html/clean.py
@@ -73,22 +73,29 @@ _looks_like_tag_content = re.compile(
     r'</?[a-zA-Z]+|\son[a-zA-Z]+\s*=',
     *((re.ASCII,) if sys.version_info[0] >= 3 else ())).search
 
 # All kinds of schemes besides just javascript: that can cause
 # execution:
-_is_image_dataurl = re.compile(
-    r'^data:image/.+;base64', re.I).search
+_find_image_dataurls = re.compile(
+    r'^data:image/(.+);base64,', re.I).findall
 _is_possibly_malicious_scheme = re.compile(
-    r'(?:javascript|jscript|livescript|vbscript|data|about|mocha):',
-    re.I).search
+    r'(javascript|jscript|livescript|vbscript|data|about|mocha):',
+    re.I).findall
+# SVG images can contain script content
+_is_unsafe_image_type = re.compile(r"(xml|svg)", re.I).findall
+
 def _is_javascript_scheme(s):
-    if _is_image_dataurl(s):
-        return None
-    return _is_possibly_malicious_scheme(s)
+    is_image_url = False
+    for image_type in _find_image_dataurls(s):
+        is_image_url = True
+        if _is_unsafe_image_type(image_type):
+            return True
+    if is_image_url:
+        return False
+    return bool(_is_possibly_malicious_scheme(s))
 
 _substitute_whitespace = re.compile(r'[\s\x00-\x08\x0B\x0C\x0E-\x19]+').sub
-# FIXME: should data: be blocked?
 
 # FIXME: check against: http://msdn2.microsoft.com/en-us/library/ms537512.aspx
 _conditional_comment_re = re.compile(
     r'\[if[\s\n\r]+.*?][\s\n\r]*>', re.I|re.S)
 
diff --git a/src/lxml/html/tests/test_clean.py b/src/lxml/html/tests/test_clean.py
index d395d5141..a05d9673d 100644
--- a/src/lxml/html/tests/test_clean.py
+++ b/src/lxml/html/tests/test_clean.py
@@ -1,5 +1,7 @@
+import base64
+import gzip
 import unittest
 from lxml.tests.common_imports import make_doctest
 
 import lxml.html
 from lxml.html.clean import Cleaner, clean_html
@@ -141,10 +143,53 @@ class CleanerTest(unittest.TestCase):
             self.assertEqual(
                 b'<style>/* deleted */</style>',
                 cleaned,
                 "%s  ->  %s" % (style_code, cleaned))
 
+    def test_svg_data_links(self):
+        # Remove SVG images with potentially insecure content.
+        svg = b'<svg onload="alert(123)" />'
+        svgz = gzip.compress(svg)
+        svg_b64 = base64.b64encode(svg).decode('ASCII')
+        svgz_b64 = base64.b64encode(svgz).decode('ASCII')
+        urls = [
+            "data:image/svg+xml;base64," + svg_b64,
+            "data:image/svg+xml-compressed;base64," + svgz_b64,
+        ]
+        for url in urls:
+            html = '<img src="%s">' % url
+            s = lxml.html.fragment_fromstring(html)
+
+            cleaned = lxml.html.tostring(clean_html(s))
+            self.assertEqual(
+                b'<img src="">',
+                cleaned,
+                "%s  ->  %s" % (url, cleaned))
+
+    def test_image_data_links(self):
+        data = b'123'
+        data_b64 = base64.b64encode(data).decode('ASCII')
+        urls = [
+            "data:image/jpeg;base64," + data_b64,
+            "data:image/apng;base64," + data_b64,
+            "data:image/png;base64," + data_b64,
+            "data:image/gif;base64," + data_b64,
+            "data:image/webp;base64," + data_b64,
+            "data:image/bmp;base64," + data_b64,
+            "data:image/tiff;base64," + data_b64,
+            "data:image/x-icon;base64," + data_b64,
+        ]
+        for url in urls:
+            html = '<img src="%s">' % url
+            s = lxml.html.fragment_fromstring(html)
+
+            cleaned = lxml.html.tostring(clean_html(s))
+            self.assertEqual(
+                html.encode("UTF-8"),
+                cleaned,
+                "%s  ->  %s" % (url, cleaned))
+
     def test_formaction_attribute_in_button_input(self):
         # The formaction attribute overrides the form's action and should be
         # treated as a malicious link attribute
         html = ('<form id="test"><input type="submit" formaction="javascript:alert(1)"></form>'
         '<button form="test" formaction="javascript:alert(1)">X</button>')

Vulnerable code: _is_image_dataurl = re.compile(
    r'^data:image/.+;base64', re.I).search
    r'(?:javascript|jscript|livescript|vbscript|data|about|mocha):',
    re.I).search
    if _is_image_dataurl(s):
        return None
    return _is_possibly_malicious_scheme(s)
# FIXME: should data: be blocked?
Fixed code: _find_image_dataurls = re.compile(
    r'^data:image/(.+);base64,', re.I).findall
    r'(javascript|jscript|livescript|vbscript|data|about|mocha):',
    re.I).findall
# SVG images can contain script content
_is_unsafe_image_type = re.compile(r"(xml|svg)", re.I).findall

    is_image_url = False
    for image_type in _find_image_dataurls(s):
        is_image_url = True
        if _is_unsafe_image_type(image_type):
            return True
    if is_image_url:
        return False
    return bool(_is_possibly_malicious_scheme(s))
import base64
import gzip
    def test_svg_data_links(self):
        # Remove SVG images with potentially insecure content.
        svg = b'<svg onload="alert(123)" />'
        svgz = gzip.compress(svg)
        svg_b64 = base64.b64encode(svg).decode('ASCII')
        svgz_b64 = base64.b64encode(svgz).decode('ASCII')
        urls = [
            "data:image/svg+xml;base64," + svg_b64,
            "data:image/svg+xml-compressed;base64," + svgz_b64,
        ]
        for url in urls:
            html = '<img src="%s">' % url
            s = lxml.html.fragment_fromstring(html)

            cleaned = lxml.html.tostring(clean_html(s))
            self.assertEqual(
                b'<img src="">',
                cleaned,
                "%s  ->  %s" % (url, cleaned))

    def test_image_data_links(self):
        data = b'123'
        data_b64 = base64.b64encode(data).decode('ASCII')
        urls = [
            "data:image/jpeg;base64," + data_b64,
            "data:image/apng;base64," + data_b64,
            "data:image/png;base64," + data_b64,
            "data:image/gif;base64," + data_b64,
            "data:image/webp;base64," + data_b64,
            "data:image/bmp;base64," + data_b64,
            "data:image/tiff;base64," + data_b64,
            "data:image/x-icon;base64," + data_b64,
        ]
        for url in urls:
            html = '<img src="%s">' % url
            s = lxml.html.fragment_fromstring(html)

            cleaned = lxml.html.tostring(clean_html(s))
            self.assertEqual(
                html.encode("UTF-8"),
                cleaned,
                "%s  ->  %s" % (url, cleaned))