8000 [3.2.x] Fixed CVE-2024-27351 -- Prevented potential ReDoS in Truncato… · django/django@072963e · GitHub
Skip to content

Commit 072963e

Browse files
shaibfelixxm
andcommitted
[3.2.x] Fixed CVE-2024-27351 -- Prevented potential ReDoS in Truncator.words().
Thanks Seokchan Yoon for the report. Co-Authored-By: Mariusz Felisiak <felisiak.mariusz@gmail.com>
1 parent 2ad2676 commit 072963e

File tree

3 files changed

+89
-2
lines changed

3 files changed

+89
-2
lines changed

django/utils/text.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,61 @@ def capfirst(x):
1818
return x and str(x)[0].upper() + str(x)[1:]
1919

2020

21-
# Set up regular expressions
22-
re_words = _lazy_re_compile(r'<[^>]+?>|([^<>\s]+)', re.S)
21+
# ----- Begin security-related performance workaround -----
22+
23+
# We used to have, below
24+
#
25+
# re_words = _lazy_re_compile(r"<[^>]+?>|([^<>\s]+)", re.S)
26+
#
27+
# But it was shown that this regex, in the way we use it here, has some
28+
# catastrophic edge-case performance features. Namely, when it is applied to
29+
# text with only open brackets "<<<...". The class below provides the services
30+
# and correct answers for the use cases, but in these edge cases does it much
31+
# faster.
32+
re_notag = _lazy_re_compile(r"([^<>\s]+)", re.S)
33+
re_prt = _lazy_re_compile(r"<|([^<>\s]+)", re.S)
34+
35+
36+
class WordsRegex:
37+
@staticmethod
38+
def search(text, pos):
39+
# Look for "<" or a non-tag word.
40+
partial = re_prt.search(text, pos)
41+
if partial is None or partial[1] is not None:
42+
return partial
43+
44+
# "<" was found, look for a closing ">".
45+
end = text.find(">", partial.end(0))
46+
if end < 0:
47+
# ">" cannot be found, look for a word.
48+
return re_notag.search(text, pos + 1)
49+
else:
50+
# "<" followed by a ">" was found -- fake a match.
51+
end += 1
52+
return FakeMatch(text[partial.start(0): end], end)
53+
54+
55+
class FakeMatch:
56+
__slots__ = ["_text", "_end"]
57+
58+
def end(self, group=0):
59+
assert group == 0, "This specific object takes only group=0"
60+
return self._end
61+
62+
def __getitem__(self, group):
63+
if group == 1:
64+
return None
65+
assert group == 0, "This specific object takes only group in {0,1}"
66+
return self._text
67+
68+
def __init__(self, text, end):
69+
self._text, self._end = text, end
70+
71+
72+
# ----- End security-related performance workaround -----
73+
74+
# Set up regular expressions.
75+
re_words = WordsRegex
2376
re_chars = _lazy_re_compile(r'<[^>]+?>|(.)', re.S)
2477
re_tag = _lazy_re_compile(r'<(/)?(\S+?)(?:(\s*/)|\s.*?)?>', re.S)
2578
re_newlines = _lazy_re_compile(r'\r\n|\r') # Used in normalize_newlines

docs/releases/3.2.25.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ Django 3.2.25 release notes
77
Django 3.2.25 fixes a security issue with severity "moderate" and a regression
88
in 3.2.24.
99

10+
CVE-2024-27351: Potential regular expression denial-of-service in ``django.utils.text.Truncator.words()``
11+
=========================================================================================================
12+
13+
``django.utils.text.Truncator.words()`` method (with ``html=True``) and
14+
:tfilter:`truncatewords_html` template filter were subject to a potential
15+
regular expression denial-of-service attack using a suitably crafted string
16+
(follow up to :cve:`2019-14232` and :cve:`2023-43665`).
17+
1018
Bugfixes
1119
========
1220

tests/utils_tests/test_text.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,32 @@ def test_truncate_html_words(self):
159159
truncator = text.Truncator('<p>I &lt;3 python, what about you?</p>')
160160
self.assertEqual('<p>I &lt;3 python,…</p>', truncator.words(3, html=True))
161161

162+
# Only open brackets.
163+
test = "<" * 60_000
164+
truncator = text.Truncator(test)
165+
self.assertEqual(truncator.words(1, html=True), test)
166+
167+
# Tags with special chars in attrs.
168+
truncator = text.Truncator(
169+
"""<i style="margin: 5%; font: *;">Hello, my dear lady!</i>"""
170+
)
171+
self.assertEqual(
172+
"""<i style="margin: 5%; font: *;">Hello, my dear…</i>""",
173+
truncator.words(3, html=True),
174+
)
175+
176+
# Tags with special non-latin chars in attrs.
177+
truncator = text.Truncator("""<p data-x="א">Hello, my dear lady!</p>""")
178+
self.assertEqual(
179+
"""<p data-x="א">Hello, my dear…</p>""",
180+
truncator.words(3, html=True),
181+
)
182+
183+
# Misplaced brackets.
184+
truncator = text.Truncator("hello >< world")
185+
self.assertEqual(truncator.words(1, html=True), "hello…")
186+
self.assertEqual(truncator.words(2, html=True), "hello >< world")
187+
162188
@patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
163189
def test_truncate_words_html_size_limit(self):
164190
max_len = text.Truncator.MAX_LENGTH_HTML

0 commit comments

Comments
 (0)
0