Skip to content

Commit effae3f

Browse files
serhiy-storchakaclaude
authored andcommitted
gh-153030: Fix quadratic complexity in incremental parsing in HTMLParser (GH-153031)
When an unterminated construct (e.g. a tag or comment) spanned many feed() calls, rescanning the growing buffer and concatenating new data onto it were both quadratic. New data is now accumulated in a list and only joined and parsed once enough has piled up. (cherry picked from commit bcf98dd) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7c999be commit effae3f

3 files changed

Lines changed: 53 additions & 2 deletions

File tree

Lib/html/parser.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ def reset(self):
138138
self.cdata_elem = None
139139
self._support_cdata = True
140140
self._escapable = True
141+
self._pending = []
142+
self._pending_len = 0
143+
self._parse_threshold = 1
141144
super().reset()
142145

143146
def feed(self, data):
@@ -146,11 +149,36 @@ def feed(self, data):
146149
Call this as often as you want, with as little or as much text
147150
as you want (may include '\n').
148151
"""
149-
self.rawdata = self.rawdata + data
150-
self.goahead(0)
152+
# Accumulate new data in a list and only join and parse it once
153+
# enough has piled up. Rescanning an unparsed buffer (e.g. an
154+
# unterminated tag) and concatenating onto it on every call would
155+
# both be quadratic in the input size.
156+
self._pending_len += len(data)
157+
if self._pending_len < self._parse_threshold:
158+
self._pending.append(data)
159+
else:
160+
if not self._pending:
161+
self.rawdata += data
162+
else:
163+
self._pending.append(data)
164+
self.rawdata += ''.join(self._pending)
165+
self._pending.clear()
166+
self._pending_len = 0
167+
n = len(self.rawdata)
168+
self.goahead(0)
169+
if len(self.rawdata) < n:
170+
# Some data was parsed; resume on the next call.
171+
self._parse_threshold = 1
172+
else:
173+
# Nothing was parsed; wait until the buffer doubles.
174+
self._parse_threshold = len(self.rawdata)
151175

152176
def close(self):
153177
"""Handle any buffered data."""
178+
if self._pending:
179+
self.rawdata += ''.join(self._pending)
180+
self._pending.clear()
181+
self._pending_len = 0
154182
self.goahead(1)
155183

156184
__starttag_text = None

Lib/test/test_htmlparser.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,26 @@ def check(source):
930930
check("<![CDATA[" * 9 * n)
931931
check("<!doctype" * 35 * n)
932932

933+
@support.requires_resource('cpu')
934+
def test_incremental_no_quadratic_complexity(self):
935+
# An unterminated construct fed in many small chunks used to take
936+
# quadratic time, both to rescan and to concatenate the buffer.
937+
# Now it takes a fraction of a second.
938+
def check(prefix, chunk, suffix):
939+
parser = html.parser.HTMLParser()
940+
parser.feed(prefix)
941+
for _ in range(200_000):
942+
parser.feed(chunk)
943+
parser.feed(suffix)
944+
parser.close()
945+
chunk = "a" * 64
946+
check("<!--", chunk, "-->") # comment
947+
check("<?", chunk, ">") # processing instruction
948+
check("<!doctype ", chunk, ">") # doctype
949+
check("<![CDATA[", chunk, "]]>") # CDATA section
950+
check("<a href='", chunk, "'>") # start tag
951+
check("<script>", chunk, "</script>") # RAWTEXT element
952+
933953

934954
class AttributesTestCase(TestCaseBase):
935955

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed quadratic complexity in incremental parsing of long unterminated
2+
constructs (such as tags or comments) in :class:`html.parser.HTMLParser`,
3+
which could be exploited for a denial of service.

0 commit comments

Comments
 (0)