Skip to content

Commit 479619b

Browse files
committed
Merge branch '2.11-redos' into 2.11
2 parents 809bfac + 5e5a920 commit 479619b

9 files changed

Lines changed: 219 additions & 41 deletions

File tree

docs/faq.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,13 @@ build the DOM of the entire feed in memory, and this can be quite slow and
297297
consume a lot of memory.
298298

299299
In order to avoid parsing all the entire feed at once in memory, you can use
300-
the functions ``xmliter`` and ``csviter`` from ``scrapy.utils.iterators``
301-
module. In fact, this is what the feed spiders (see :ref:`topics-spiders`) use
302-
under the cover.
300+
the :func:`~scrapy.utils.iterators.xmliter_lxml` and
301+
:func:`~scrapy.utils.iterators.csviter` functions. In fact, this is what
302+
:class:`~scrapy.spiders.XMLFeedSpider` uses.
303+
304+
.. autofunction:: scrapy.utils.iterators.xmliter_lxml
305+
306+
.. autofunction:: scrapy.utils.iterators.csviter
303307

304308
Does Scrapy manage cookies automatically?
305309
-----------------------------------------

docs/news.rst

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ Highlights:
1919
Security bug fixes
2020
~~~~~~~~~~~~~~~~~~
2121

22+
- Addressed `ReDoS vulnerabilities`_:
23+
24+
- ``scrapy.utils.iterators.xmliter`` is now deprecated in favor of
25+
:func:`~scrapy.utils.iterators.xmliter_lxml`, which
26+
:class:`~scrapy.spiders.XMLFeedSpider` now uses.
27+
28+
To minimize the impact of this change on existing code,
29+
:func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating
30+
the node namespace with a prefix in the node name, and big files with
31+
highly nested trees when using libxml2 2.7+.
32+
33+
- Fixed regular expressions in the implementation of the
34+
:func:`~scrapy.utils.response.open_in_browser` function.
35+
36+
Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information.
37+
38+
.. _ReDoS vulnerabilities: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
39+
.. _cc65-xxvf-f7r9 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9
40+
2241
- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply
2342
to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security
2443
advisory`_ for more information.
@@ -2951,14 +2970,24 @@ affect subclasses:
29512970

29522971
(:issue:`3884`)
29532972

2954-
29552973
.. _release-1.8.4:
29562974

29572975
Scrapy 1.8.4 (unreleased)
29582976
-------------------------
29592977

29602978
**Security bug fixes:**
29612979

2980+
- Due to its `ReDoS vulnerabilities`_, ``scrapy.utils.iterators.xmliter`` is
2981+
now deprecated in favor of :func:`~scrapy.utils.iterators.xmliter_lxml`,
2982+
which :class:`~scrapy.spiders.XMLFeedSpider` now uses.
2983+
2984+
To minimize the impact of this change on existing code,
2985+
:func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating
2986+
the node namespace as a prefix in the node name, and big files with highly
2987+
nested trees when using libxml2 2.7+.
2988+
2989+
Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information.
2990+
29622991
- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply
29632992
to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security
29642993
advisory`_ for more information.

docs/topics/debug.rst

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -125,26 +125,16 @@ Fortunately, the :command:`shell` is your bread and butter in this case (see
125125
126126
See also: :ref:`topics-shell-inspect-response`.
127127

128+
128129
Open in browser
129130
===============
130131

131132
Sometimes you just want to see how a certain response looks in a browser, you
132-
can use the ``open_in_browser`` function for that. Here is an example of how
133-
you would use it:
134-
135-
.. code-block:: python
133+
can use the :func:`~scrapy.utils.response.open_in_browser` function for that:
136134

137-
from scrapy.utils.response import open_in_browser
135+
.. autofunction:: scrapy.utils.response.open_in_browser
138136

139137

140-
def parse_details(self, response):
141-
if "item name" not in response.body:
142-
open_in_browser(response)
143-
144-
``open_in_browser`` will open a browser with the response received by Scrapy at
145-
that point, adjusting the `base tag`_ so that images and styles are displayed
146-
properly.
147-
148138
Logging
149139
=======
150140

@@ -163,8 +153,6 @@ available in all future runs should they be necessary again:
163153
164154
For more information, check the :ref:`topics-logging` section.
165155

166-
.. _base tag: https://www.w3schools.com/tags/tag_base.asp
167-
168156
.. _debug-vscode:
169157

170158
Visual Studio Code

scrapy/spiders/feed.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from scrapy.exceptions import NotConfigured, NotSupported
88
from scrapy.selector import Selector
99
from scrapy.spiders import Spider
10-
from scrapy.utils.iterators import csviter, xmliter
10+
from scrapy.utils.iterators import csviter, xmliter_lxml
1111
from scrapy.utils.spider import iterate_spider_output
1212

1313

@@ -84,7 +84,7 @@ def _parse(self, response, **kwargs):
8484
return self.parse_nodes(response, nodes)
8585

8686
def _iternodes(self, response):
87-
for node in xmliter(response, self.itertag):
87+
for node in xmliter_lxml(response, self.itertag):
8888
self._register_namespaces(node)
8989
yield node
9090

scrapy/utils/iterators.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
cast,
1717
overload,
1818
)
19+
from warnings import warn
1920

21+
from lxml import etree
22+
23+
from scrapy.exceptions import ScrapyDeprecationWarning
2024
from scrapy.http import Response, TextResponse
2125
from scrapy.selector import Selector
2226
from scrapy.utils.python import re_rsearch, to_unicode
@@ -38,6 +42,16 @@ def xmliter(
3842
- a unicode string
3943
- a string encoded as utf-8
4044
"""
45+
warn(
46+
(
47+
"xmliter is deprecated and its use strongly discouraged because "
48+
"it is vulnerable to ReDoS attacks. Use xmliter_lxml instead. See "
49+
"https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9"
50+
),
51+
ScrapyDeprecationWarning,
52+
stacklevel=2,
53+
)
54+
4155
nodename_patt = re.escape(nodename)
4256

4357
DOCUMENT_HEADER_RE = re.compile(r"<\?xml[^>]+>\s*", re.S)
@@ -81,15 +95,34 @@ def xmliter_lxml(
8195
namespace: Optional[str] = None,
8296
prefix: str = "x",
8397
) -> Generator[Selector, Any, None]:
84-
from lxml import etree
85-
8698
reader = _StreamReader(obj)
8799
tag = f"{{{namespace}}}{nodename}" if namespace else nodename
88100
iterable = etree.iterparse(
89-
cast("SupportsReadClose[bytes]", reader), tag=tag, encoding=reader.encoding
101+
cast("SupportsReadClose[bytes]", reader),
102+
encoding=reader.encoding,
103+
events=("end", "start-ns"),
104+
huge_tree=True,
90105
)
91106
selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename)
92-
for _, node in iterable:
107+
needs_namespace_resolution = not namespace and ":" in nodename
108+
if needs_namespace_resolution:
109+
prefix, nodename = nodename.split(":", maxsplit=1)
110+
for event, data in iterable:
111+
if event == "start-ns":
112+
assert isinstance(data, tuple)
113+
if needs_namespace_resolution:
114+
_prefix, _namespace = data
115+
if _prefix != prefix:
116+
continue
117+
namespace = _namespace
118+
needs_namespace_resolution = False
119+
selxpath = f"//{prefix}:{nodename}"
120+
tag = f"{{{namespace}}}{nodename}"
121+
continue
122+
assert isinstance(data, etree._Element)
123+
node = data
124+
if node.tag != tag:
125+
continue
93126
nodetext = etree.tostring(node, encoding="unicode")
94127
node.clear()
95128
xs = Selector(text=nodetext, type="xml")

scrapy/utils/response.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,25 +74,50 @@ def response_httprepr(response: Response) -> bytes:
7474
return b"".join(values)
7575

7676

77+
def _remove_html_comments(body):
78+
start = body.find(b"<!--")
79+
while start != -1:
80+
end = body.find(b"-->", start + 1)
81+
if end == -1:
82+
return body[:start]
83+
else:
84+
body = body[:start] + body[end + 3 :]
85+
start = body.find(b"<!--")
86+
return body
87+
88+
7789
def open_in_browser(
7890
response: Union[
7991
"scrapy.http.response.html.HtmlResponse",
8092
"scrapy.http.response.text.TextResponse",
8193
],
8294
_openfunc: Callable[[str], Any] = webbrowser.open,
8395
) -> Any:
84-
"""Open the given response in a local web browser, populating the <base>
85-
tag for external links to work
96+
"""Open *response* in a local web browser, adjusting the `base tag`_ for
97+
external links to work, e.g. so that images and styles are displayed.
98+
99+
.. _base tag: https://www.w3schools.com/tags/tag_base.asp
100+
101+
For example:
102+
103+
.. code-block:: python
104+
105+
from scrapy.utils.response import open_in_browser
106+
107+
108+
def parse_details(self, response):
109+
if "item name" not in response.body:
110+
open_in_browser(response)
86111
"""
87112
from scrapy.http import HtmlResponse, TextResponse
88113

89114
# XXX: this implementation is a bit dirty and could be improved
90115
body = response.body
91116
if isinstance(response, HtmlResponse):
92117
if b"<base" not in body:
93-
repl = rf'\1<base href="{response.url}">'
94-
body = re.sub(b"<!--.*?-->", b"", body, flags=re.DOTALL)
95-
body = re.sub(rb"(<head(?:>|\s.*?>))", to_bytes(repl), body)
118+
_remove_html_comments(body)
119+
repl = rf'\0<base href="{response.url}">'
120+
body = re.sub(rb"<head(?:[^<>]*?>)", to_bytes(repl), body, count=1)
96121
ext = ".html"
97122
elif isinstance(response, TextResponse):
98123
ext = ".txt"

tests/test_spider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,10 @@ def test_register_namespace(self):
151151
body = b"""<?xml version="1.0" encoding="UTF-8"?>
152152
<urlset xmlns:x="http://www.google.com/schemas/sitemap/0.84"
153153
xmlns:y="http://www.example.com/schemas/extras/1.0">
154-
<url><x:loc>http://www.example.com/Special-Offers.html</loc><y:updated>2009-08-16</updated>
154+
<url><x:loc>http://www.example.com/Special-Offers.html</x:loc><y:updated>2009-08-16</y:updated>
155155
<other value="bar" y:custom="fuu"/>
156156
</url>
157-
<url><loc>http://www.example.com/</loc><y:updated>2009-08-16</updated><other value="foo"/></url>
157+
<url><loc>http://www.example.com/</loc><y:updated>2009-08-16</y:updated><other value="foo"/></url>
158158
</urlset>"""
159159
response = XmlResponse(url="http://example.com/sitemap.xml", body=body)
160160

tests/test_utils_iterators.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
from pytest import mark
1+
import pytest
22
from twisted.trial import unittest
33

4+
from scrapy.exceptions import ScrapyDeprecationWarning
45
from scrapy.http import Response, TextResponse, XmlResponse
56
from scrapy.utils.iterators import _body_or_str, csviter, xmliter, xmliter_lxml
67
from tests import get_testdata
78

89

9-
class XmliterTestCase(unittest.TestCase):
10-
xmliter = staticmethod(xmliter)
11-
10+
class XmliterBaseTestCase:
11+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
1212
def test_xmliter(self):
1313
body = b"""
1414
<?xml version="1.0" encoding="UTF-8"?>
@@ -40,6 +40,7 @@ def test_xmliter(self):
4040
attrs, [("001", ["Name 1"], ["Type 1"]), ("002", ["Name 2"], ["Type 2"])]
4141
)
4242

43+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
4344
def test_xmliter_unusual_node(self):
4445
body = b"""<?xml version="1.0" encoding="UTF-8"?>
4546
<root>
@@ -53,6 +54,7 @@ def test_xmliter_unusual_node(self):
5354
]
5455
self.assertEqual(nodenames, [["matchme..."]])
5556

57+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
5658
def test_xmliter_unicode(self):
5759
# example taken from https://github.com/scrapy/scrapy/issues/1665
5860
body = """<?xml version="1.0" encoding="UTF-8"?>
@@ -112,6 +114,7 @@ def test_xmliter_unicode(self):
112114
[("26", ["-"], ["80"]), ("21", ["Ab"], ["76"]), ("27", ["A"], ["27"])],
113115
)
114116

117+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
115118
def test_xmliter_text(self):
116119
body = (
117120
'<?xml version="1.0" encoding="UTF-8"?>'
@@ -123,6 +126,7 @@ def test_xmliter_text(self):
123126
[["one"], ["two"]],
124127
)
125128

129+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
126130
def test_xmliter_namespaces(self):
127131
body = b"""
128132
<?xml version="1.0" encoding="UTF-8"?>
@@ -162,6 +166,7 @@ def test_xmliter_namespaces(self):
162166
self.assertEqual(node.xpath("id/text()").getall(), [])
163167
self.assertEqual(node.xpath("price/text()").getall(), [])
164168

169+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
165170
def test_xmliter_namespaced_nodename(self):
166171
body = b"""
167172
<?xml version="1.0" encoding="UTF-8"?>
@@ -190,6 +195,7 @@ def test_xmliter_namespaced_nodename(self):
190195
["http://www.mydummycompany.com/images/item1.jpg"],
191196
)
192197

198+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
193199
def test_xmliter_namespaced_nodename_missing(self):
194200
body = b"""
195201
<?xml version="1.0" encoding="UTF-8"?>
@@ -214,6 +220,7 @@ def test_xmliter_namespaced_nodename_missing(self):
214220
with self.assertRaises(StopIteration):
215221
next(my_iter)
216222

223+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
217224
def test_xmliter_exception(self):
218225
body = (
219226
'<?xml version="1.0" encoding="UTF-8"?>'
@@ -226,10 +233,12 @@ def test_xmliter_exception(self):
226233

227234
self.assertRaises(StopIteration, next, iter)
228235

236+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
229237
def test_xmliter_objtype_exception(self):
230238
i = self.xmliter(42, "product")
231239
self.assertRaises(TypeError, next, i)
232240

241+
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
233242
def test_xmliter_encoding(self):
234243
body = (
235244
b'<?xml version="1.0" encoding="ISO-8859-9"?>\n'
@@ -244,12 +253,25 @@ def test_xmliter_encoding(self):
244253
)
245254

246255

247-
class LxmlXmliterTestCase(XmliterTestCase):
248-
xmliter = staticmethod(xmliter_lxml)
256+
class XmliterTestCase(XmliterBaseTestCase, unittest.TestCase):
257+
xmliter = staticmethod(xmliter)
249258

250-
@mark.xfail(reason="known bug of the current implementation")
251-
def test_xmliter_namespaced_nodename(self):
252-
super().test_xmliter_namespaced_nodename()
259+
def test_deprecation(self):
260+
body = b"""
261+
<?xml version="1.0" encoding="UTF-8"?>
262+
<products>
263+
<product></product>
264+
</products>
265+
"""
266+
with pytest.warns(
267+
ScrapyDeprecationWarning,
268+
match="xmliter",
269+
):
270+
next(self.xmliter(body, "product"))
271+
272+
273+
class LxmlXmliterTestCase(XmliterBaseTestCase, unittest.TestCase):
274+
xmliter = staticmethod(xmliter_lxml)
253275

254276
def test_xmliter_iterate_namespace(self):
255277
body = b"""

0 commit comments

Comments
 (0)