Repository: gawel/pyquery

Issue:
.html() fails to escape initial html entities
The html() method returns incorrect results in some cases because it fails to escape HTML entities prior to the first tag in the inner HTML:

```
>>> PyQuery("<foo>&lt;script&gt;// uh oh&lt;/script&gt;bar<boo/></foo>").html()
'<script>// uh oh</script>bar<boo/>'
```

This has potential security implications for downstream users if processing sanitized user-controlled content.

The fix would be to html-encode `tag.text` in [the html method](https://github.com/gawel/pyquery/blob/8f2a788647c7260357e4f76fd1a19a35aa26cdee/pyquery/pyquery.py#L1068).

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 971d377..3e0901e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -3,6 +3,8 @@
 
 - Add nextUntil method
 
+- Fix escaping of top-level element text in ``.html()`` output
+
 
 1.4.3 (2020-11-21)
 ------------------
diff --git a/pyquery/pyquery.py b/pyquery/pyquery.py
index e549f0e..976a158 100644
--- a/pyquery/pyquery.py
+++ b/pyquery/pyquery.py
@@ -8,6 +8,7 @@ from urllib.parse import urljoin
 from .openers import url_opener
 from .text import extract_text
 from copy import deepcopy
+from html import escape
 from lxml import etree
 import lxml.html
 import inspect
@@ -1085,9 +1086,9 @@ class PyQuery(list):
                 return None
             tag = self[0]
             children = tag.getchildren()
+            html = escape(tag.text or '', quote=False)
             if not children:
-                return tag.text or ''
-            html = tag.text or ''
+                return html
             if 'encoding' not in kwargs:
                 kwargs['encoding'] = str
             html += u''.join([etree.tostring(e, **kwargs)


Test patch:
diff --git a/tests/test_pyquery.py b/tests/test_pyquery.py
index d82a556..340e11e 100644
--- a/tests/test_pyquery.py
+++ b/tests/test_pyquery.py
@@ -534,9 +534,10 @@ Bacon</textarea>
         self.assertEqual(d('#textarea-multi').val(), multi_expected)
         self.assertEqual(d('#textarea-multi').text(), multi_expected)
         multi_new = '''Bacon\n<b>Eggs</b>\nSpam'''
+        multi_new_expected = '''Bacon\n&lt;b&gt;Eggs&lt;/b&gt;\nSpam'''
         d('#textarea-multi').val(multi_new)
-        self.assertEqual(d('#textarea-multi').val(), multi_new)
-        self.assertEqual(d('#textarea-multi').text(), multi_new)
+        self.assertEqual(d('#textarea-multi').val(), multi_new_expected)
+        self.assertEqual(d('#textarea-multi').text(), multi_new_expected)
 
     def test_val_for_select(self):
         d = pq(self.html4)
@@ -622,6 +623,13 @@ Bacon</textarea>
         self.assertEqual(new_html, expected)
         self.assertIn(replacement, new_html)
 
+    def test_html_escape(self):
+        inner_html = 'encoded &lt;script&gt; tag with "quotes".' \
+                     '<span>nested &lt;tag&gt;</span>'
+        html = '<div>' + inner_html + '</div>'
+        d = pq(html)
+        self.assertEqual(d.html(), inner_html)
+
 
 class TestAjax(TestCase):