Skip to content

Commit 0765ae4

Browse files
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. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> (cherry picked from commit bcf98dd)
1 parent 938ec03 commit 0765ae4

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
@@ -137,6 +137,9 @@ def reset(self):
137137
self.cdata_elem = None
138138
self._support_cdata = True
139139
self._escapable = True
140+
self._pending = []
141+
self._pending_len = 0
142+
self._parse_threshold = 1
140143
_markupbase.ParserBase.reset(self)
141144

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

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

155183
__starttag_text = None

Lib/test/test_htmlparser.py

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

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

933953
class AttributesTestCase(TestCaseBase):
934954

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)