Chapter 23 · What changed

23.19 Security fixes: limits against denial of service

The security fixes from 5.0 to this commit that bound the work Django does on what a client sends: strip_tags, Truncator, wrap, intcomma, floatformat and the URL linker; URLs and redirects; IPv6 addresses and language codes; headers and bodies; the XML deserializer; nested geometry collections. For each, what was open, the limit at this commit, and the releases that brought it.

A denial of service of this kind needs no wrong answer, only an input that costs far more than it should: work quadratic in its length, a string copied again at each step, a regular expression that backtracks, a body read past its limit, a cache of keys of any length, a C library that crashes on deep nesting. Most of the fixes take one of two shapes. Some bound the input, by a length or a count past which it is refused, cut short or passed through untouched. Others make the work linear: a list joined once in place of a string grown, a stack popped only at its top. A refusal uses an exception Django already has: SuspiciousOperation, a 400 when a view lets it escape, ValidationError, LookupError, ValueError, or a false answer from a check.

Markup and text

strip_tags feeds the text to an HTML parser, MLStripper, and repeats while a pass still removes a tag. Long runs of nested incomplete entities made it extremely slow (CVE-2024-53907, 5.0.10 and 5.1.4): the loop is now allowed MAX_STRIP_TAGS_DEPTH passes, 50, and raises SuspiciousOperation if the text still holds a “<” and a “>” after them. Many unclosed opening tags made it slow too (CVE-2025-32873, 5.1.9 and 5.2.1): before any pass, an opening tag that runs a thousand characters without a “>” and holds 50 “<” or more raises the same. So does the striptags filter, which calls it.

Truncator.words with html=True, behind truncatewords_html, used a regular expression that a crafted string could make backtrack (CVE-2024-27351, 5.0.3). Since 5.1 both HTML methods parse with subclasses of html.parser.HTMLParser instead (What changed in time zones, translation and the utilities), and that parsing was quadratic on many unmatched end tags (CVE-2026-1285, 5.2.11 and 6.0.2). An end tag now pops the stack of open tags only when it closes the tag opened last; the comment there says this avoids a linear scan for each unmatched one.

wrap, behind wordwrap, was open to very long strings (CVE-2025-26699, 5.0.13 and 5.1.7). At this commit it hands each line to textwrap.TextWrapper (wrap; Text helpers: Truncator and django/utils/text.py).

Numbers: intcomma and floatformat

intcomma was open to very long strings (CVE-2024-24680, 5.0.2). With its use_l10n argument true, the default, a value that can be made a decimal goes to number_format; otherwise the leading digits of its text are grouped by one substitution over them reversed, linear in their length (intcomma; Humanize: naturaltime, intcomma and the other filters).

floatformat could use much memory on a number in scientific notation with a large exponent (CVE-2024-41989, 5.0.8). It now returns its input as given when the decimal’s digits and the absolute value of its exponent come to more than 200: "1e200" comes back as "1e200", and "1e199" is written out in its 200 digits (floatformat; The built-in filters).

URLs and Unicode on Windows

Splitting a URL with urllib.parse.urlsplit involves Unicode’s NFKC normalization, which the notes of several fixes say is slow on Windows. Several guards here stop a long URL before it is split, at one bound: MAX_URL_LENGTH, 2,048 characters.

The URL linker, django.utils.html.urlize and the urlize and urlizetrunc filters, had fixes in 2024: for input with very many brackets (CVE-2024-38875, 5.0.7), very large input with a particular sequence of characters (CVE-2024-41990, 5.0.8; CVE-2024-45230, 5.0.9 and 5.1.1), and input with very many Unicode characters, which reached the admin’s URL widget too (CVE-2024-41991, 5.0.8). At this commit a word whose text, once its punctuation is trimmed, is longer than MAX_URL_LENGTH is not tried as a URL, and trimming a word’s punctuation is linear: each kind of bracket is counted once, in a CountsDict, the trimmed pieces are joined once, and only the text from the last “&” is unescaped to judge a trailing “;”. AdminURLFieldWidget links its value only when its validator, by default URLValidator, accepts it, and the validator refuses a URL over URLValidator.max_length, MAX_URL_LENGTH, before it splits it.

LoginView, LogoutView and set_language were open to input with very many Unicode characters on Windows (CVE-2025-27556, 5.0.14 and 5.1.8). Each judges the URL it is to redirect to with url_has_allowed_host_and_scheme, which refuses one over MAX_URL_LENGTH before splitting it (_url_has_allowed_host_and_scheme; The helpers in django/utils/http.py). The redirects followed (CVE-2025-64458, 5.1.14 and 5.2.8): HttpResponseRedirectBase.__init__, and so redirect, raises DisallowedRedirect for a "Location" longer than MAX_URL_REDIRECT_LENGTH, 16,384 characters, before it splits the target (HttpResponseRedirectBase.__init__; The response object). Since 6.1 a caller may pass another max_length (What changed in handlers, requests, URLs and views).

The form field’s URLField.to_python split the value to decide whether to put a scheme in front (CVE-2026-25673, 5.2.12 and 6.0.3). It now looks only at what precedes the first colon, and never calls urllib.parse.urlsplit. Control characters inside a value are left to the validators, and URLValidator refuses a tab, a carriage return, a newline and a null (The built-in form fields).

Addresses and language codes

IPv6 validation had no upper bound on the length of its input (CVE-2024-56374, 5.0.11 and 5.1.5). is_valid_ipv6_address now refuses text longer than MAX_IPV6_ADDRESS_LENGTH, 39 characters, eight groups of four and their colons, before ipaddress parses it, and clean_ipv6_address text longer than its max_length, by default the same. django.forms.GenericIPAddressField takes 39 as its max_length unless given another, and passes it to clean_ipv6_address. The model field, the notes say, was not affected (Dates, durations and addresses: parse_datetime, parse_duration and clean_ipv6_address).

get_supported_language_variant was open to very long codes holding particular characters (CVE-2024-39614, 5.0.7). It now cuts a code longer than LANGUAGE_CODE_MAX_LENGTH, 500 characters, at its last hyphen within the limit, and refuses it with LookupError if it has none there or strict is true. The 5.0.7 notes say ValueError; the code raises the LookupError its callers catch, and 5.0.8 fixed a crash in LocaleMiddleware on such a code (ticket 35627). check_for_language kept each code it was asked about as a key of its cache, bounded in entries but not in their length (CVE-2026-15337, 5.2.17 and 6.0.8). At this commit it is not cached: it refuses a code over the limit, and asks the cached translation_catalog_exists, which holds 1,000 codes, about the rest (check_for_language; Choosing a language: LocaleMiddleware).

Headers and bodies

Under ASGI a header sent many times was combined by repeated string concatenation (CVE-2025-14550, 5.2.11 and 6.0.2). ASGIRequest.__init__ now gathers each header’s values in a list and joins them once, with commas, or for "cookie" with a semicolon and a space (ASGIRequest.__init__; Making the request: WSGIRequest and ASGIRequest).

An ASGI request with no Content-Length, or one smaller than the body, could take a body past DATA_UPLOAD_MAX_MEMORY_SIZE into HttpRequest.body (CVE-2026-33034, 5.2.13 and 6.0.4), or keep an upload in memory past FILE_UPLOAD_MAX_MEMORY_SIZE (CVE-2026-5766, 5.2.14 and 6.0.5). body refuses a declared length over the limit before reading, and then, when the stream can seek, as an ASGI request’s can, measures it and raises RequestDataTooBig if it is over (HttpRequest.body; The body: body, POST and FILES). MemoryFileUploadHandler.handle_raw_input decides whether files stay in memory by the size of a stream that can seek; a WSGI request’s LimitedStream cannot, and there the header, which it enforces, still decides (MemoryFileUploadHandler.handle_raw_input; Multipart parsing and upload handlers).

A file sent in Base64 with much white space could make the multipart parser copy memory again and again (CVE-2026-33033, 5.2.13 and 6.0.4). At this commit the parser strips a chunk’s white space, reads further chunks whole until the stripped length is a multiple of four, and joins the pieces once before decoding.

The XML deserializer

Deserializing XML took quadratic time on many nested invalid elements, as getInnerText gathered text inefficiently as it recursed (CVE-2025-64460, 5.1.15 and 5.2.9); the fix had it collect text element by element. Since 6.1 it reads only a node’s own text, and check_element_type raises SuspiciousOperation for a child element with content of its own, so a nested element with content is refused, not walked (check_element_type; What changed in caching, files, signals, serialization and pagination).

Nested geometry collections

Deeply nested geometry collections crashed GEOS, through GEOSGeometry, spatial lookups and the geometry form field (CVE-2026-15830, 5.2.17 and 6.0.8). Django now counts before GEOS reads: in WKT, how deep the collections nest; in WKB and its hex form, how many there are in all. Past the limit, by default MAX_GEOM_COLLECTIONS, 198, the reader raises ValueError (_WKTReader.limit, _WKBReader; Geometries in Python: GEOS and GDAL). The constant’s comment places 198 just under PostGIS’s own ceiling, so that Django refuses first. GeoJSON is parsed by GDAL, and GDAL’s WKB of it is counted at the default limit, whatever max_geom_collections says; geometries read back from the database are read with no limit (Spatial fields: the geometry fields and RasterField).