From d4525f0d7c87af1247e8696334600674711267c9 Mon Sep 17 00:00:00 2001
From: Serhiy Storchaka <storchaka@gmail.com>
Date: Sat, 4 Jul 2026 20:40:22 +0300
Subject: [PATCH] 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 bcf98ddbc40ec9b3ee87da0124a5660b19b7e606)
Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
Lib/html/parser.py | 32 +++++++++++++++++--
Lib/test/test_htmlparser.py | 20 ++++++++++++
...-07-04-17-00-00.gh-issue-153030.RovkP6.rst | 3 ++
3 files changed, 53 insertions(+), 2 deletions(-)
create mode 100644 Misc/NEWS.d/next/Security/2026-07-04-17-00-00.gh-issue-153030.RovkP6.rst
diff --git a/Lib/html/parser.py b/Lib/html/parser.py
index 38ddf9ef442d36..fbe0d3665e073c 100644
|
a
|
b
|
def reset(self):
|
| 157 | 157 | self.cdata_elem = None |
| 158 | 158 | self._support_cdata = True |
| 159 | 159 | self._escapable = True |
| | 160 | self._pending = [] |
| | 161 | self._pending_len = 0 |
| | 162 | self._parse_threshold = 1 |
| 160 | 163 | super().reset() |
| 161 | 164 | |
| 162 | 165 | def feed(self, data): |
| … |
… |
def feed(self, data):
|
| 165 | 168 | Call this as often as you want, with as little or as much text |
| 166 | 169 | as you want (may include '\n'). |
| 167 | 170 | """ |
| 168 | | self.rawdata = self.rawdata + data |
| 169 | | self.goahead(0) |
| | 171 | # Accumulate new data in a list and only join and parse it once |
| | 172 | # enough has piled up. Rescanning an unparsed buffer (e.g. an |
| | 173 | # unterminated tag) and concatenating onto it on every call would |
| | 174 | # both be quadratic in the input size. |
| | 175 | self._pending_len += len(data) |
| | 176 | if self._pending_len < self._parse_threshold: |
| | 177 | self._pending.append(data) |
| | 178 | else: |
| | 179 | if not self._pending: |
| | 180 | self.rawdata += data |
| | 181 | else: |
| | 182 | self._pending.append(data) |
| | 183 | self.rawdata += ''.join(self._pending) |
| | 184 | self._pending.clear() |
| | 185 | self._pending_len = 0 |
| | 186 | n = len(self.rawdata) |
| | 187 | self.goahead(0) |
| | 188 | if len(self.rawdata) < n: |
| | 189 | # Some data was parsed; resume on the next call. |
| | 190 | self._parse_threshold = 1 |
| | 191 | else: |
| | 192 | # Nothing was parsed; wait until the buffer doubles. |
| | 193 | self._parse_threshold = len(self.rawdata) |
| 170 | 194 | |
| 171 | 195 | def close(self): |
| 172 | 196 | """Handle any buffered data.""" |
| | 197 | if self._pending: |
| | 198 | self.rawdata += ''.join(self._pending) |
| | 199 | self._pending.clear() |
| | 200 | self._pending_len = 0 |
| 173 | 201 | self.goahead(1) |
| 174 | 202 | |
| 175 | 203 | __starttag_text = None |
diff --git a/Lib/test/test_htmlparser.py b/Lib/test/test_htmlparser.py
index 6b7624f11505d9..3fdaed4ff46b9d 100644
|
a
|
b
|
def check(source):
|
| 1041 | 1041 | check("<![CDATA[" * 9 * n) |
| 1042 | 1042 | check("<!doctype" * 35 * n) |
| 1043 | 1043 | |
| | 1044 | @support.requires_resource('cpu') |
| | 1045 | def test_incremental_no_quadratic_complexity(self): |
| | 1046 | # An unterminated construct fed in many small chunks used to take |
| | 1047 | # quadratic time, both to rescan and to concatenate the buffer. |
| | 1048 | # Now it takes a fraction of a second. |
| | 1049 | def check(prefix, chunk, suffix): |
| | 1050 | parser = html.parser.HTMLParser() |
| | 1051 | parser.feed(prefix) |
| | 1052 | for _ in range(200_000): |
| | 1053 | parser.feed(chunk) |
| | 1054 | parser.feed(suffix) |
| | 1055 | parser.close() |
| | 1056 | chunk = "a" * 64 |
| | 1057 | check("<!--", chunk, "-->") # comment |
| | 1058 | check("<?", chunk, ">") # processing instruction |
| | 1059 | check("<!doctype ", chunk, ">") # doctype |
| | 1060 | check("<![CDATA[", chunk, "]]>") # CDATA section |
| | 1061 | check("<a href='", chunk, "'>") # start tag |
| | 1062 | check("<script>", chunk, "</script>") # RAWTEXT element |
| | 1063 | |
| 1044 | 1064 | |
| 1045 | 1065 | class AttributesTestCase(TestCaseBase): |
| 1046 | 1066 | |
diff --git a/Misc/NEWS.d/next/Security/2026-07-04-17-00-00.gh-issue-153030.RovkP6.rst b/Misc/NEWS.d/next/Security/2026-07-04-17-00-00.gh-issue-153030.RovkP6.rst
new file mode 100644
index 00000000000000..d1d60593f4ba7d
|
-
|
+
|
|
| | 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. |