Skip to content

Commit b40cfc6

Browse files
nessitajacobtylerwalls
authored andcommitted
[4.2.x] Fixed CVE-2026-1285 -- Mitigated potential DoS in django.utils.text.Truncator for HTML input.
The `TruncateHTMLParser` used `deque.remove()` to remove tags from the stack when processing end tags. With crafted input containing many unmatched end tags, this caused repeated full scans of the tag stack, leading to quadratic time complexity. The fix uses LIFO semantics, only removing a tag from the stack when it matches the most recently opened tag. This avoids linear scans for unmatched end tags and reduces complexity to linear time. Refs #30686 and 6ee37ad. Thanks Seokchan Yoon for the report. Backport of a33540b from main.
1 parent a143631 commit b40cfc6

3 files changed

Lines changed: 29 additions & 9 deletions

File tree

django/utils/text.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -272,15 +272,11 @@ def _truncate_html(self, length, truncate, text, truncate_len, words):
272272
if self_closing or tagname in html4_singlets:
273273
pass
274274
elif closing_tag:
275-
# Check for match in open tags list
276-
try:
277-
i = open_tags.index(tagname)
278-
except ValueError:
279-
pass
280-
else:
281-
# SGML: An end tag closes, back to the matching start tag,
282-
# all unclosed intervening start tags with omitted end tags
283-
open_tags = open_tags[i + 1 :]
275+
# Remove from the list only if the tag matches the most
276+
# recently opened tag (LIFO). This avoids O(n) linear scans
277+
# for unmatched end tags if `list.index()` would be called.
278+
if open_tags and open_tags[0] == tagname:
279+
open_tags = open_tags[1:]
284280
else:
285281
# Add it to the start of the open tags list
286282
open_tags.insert(0, tagname)

docs/releases/4.2.28.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,17 @@ As a reminder, all untrusted user input should be validated before use.
4141

4242
This issue has severity "high" according to the :ref:`Django security policy
4343
<security-disclosure>`.
44+
Django 4.2.28 fixes two security issues with severity "moderate", three
45+
security issues with severity "moderate", and one security issue with severity
46+
"low" in 4.2.27.
47+
48+
CVE-2026-1285: Potential denial-of-service vulnerability in ``django.utils.text.Truncator`` HTML methods
49+
========================================================================================================
50+
51+
``django.utils.text.Truncator.chars()`` and ``Truncator.words()`` methods (with
52+
``html=True``) and the :tfilter:`truncatechars_html` and
53+
:tfilter:`truncatewords_html` template filters were subject to a potential
54+
denial-of-service attack via certain inputs with a large number of unmatched
55+
HTML end tags, which could cause quadratic time complexity during HTML parsing.
56+
57+
This issue has severity "moderate" according to the Django security policy.

tests/utils_tests/test_text.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ def test_truncate_chars(self):
9595
text.Truncator(lazystr("The quick brown fox")).chars(10), "The quick…"
9696
)
9797

98+
def test_truncate_chars_html_with_misnested_tags(self):
99+
# LIFO removal keeps all tags when a middle tag is closed out of order.
100+
# With <a><b><c></b>, the </b> doesn't match <c>, so all tags remain
101+
# in the stack and are properly closed at truncation.
102+
truncator = text.Truncator("<a><b><c></b>XXXX")
103+
self.assertEqual(
104+
truncator.chars(2, html=True, truncate=""),
105+
"<a><b><c></b>XX</c></b></a>",
106+
)
107+
98108
@patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
99109
def test_truncate_chars_html_size_limit(self):
100110
max_len = text.Truncator.MAX_LENGTH_HTML

0 commit comments

Comments
 (0)