Reports
BAJA[EXPOSICIÓN DE DATOS]#4062511

Django: la caché de páginas confunde una cabecera ausente con una vacía y puede servir la respuesta equivocada con Vary

El middleware de caché de Django generaba la misma clave para una petición sin la cabecera indicada en Vary y para otra con esa cabecera vacía, de modo que una respuesta privada podía servirse a quien no debía.

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

La caché de páginas de Django (UpdateCacheMiddleware / CacheMiddleware) calcula la clave de cada respuesta teniendo en cuenta las cabeceras de la petición que la vista declara en Vary. El problema estaba en cómo trataba dos situaciones que HTTP considera distintas:

  • la cabecera no viene en la petición;
  • la cabecera viene, pero con valor vacío (por ejemplo X-Scope:).

Para Django ambas producían exactamente la misma clave de caché. La RFC 9111 (§4.1) es clara: una cabecera nominada en Vary que falta solo puede casar con otra petición en la que también falte, y la gramática de HTTP admite valores vacíos, así que son dos casos diferentes. Al mezclarlos, una respuesta generada para un estado de la petición podía entregarse desde la caché al otro.

El investigador comprobó además que el adaptador ASGI de Django sí conserva la diferencia (la cabecera vacía llega como META["HTTP_X_SCOPE"] == ""), por lo que el fallo se produce justo en el generador de la clave de caché, no antes. La prueba de concepto se ejecuta contra Django 6.1.1.

Pasos de reproducción

El investigador preparó una prueba de concepto local que monta una vista con este comportamiento: si la petición trae la cabecera X-Scope, responde PRIVATE, y la respuesta declara Vary: X-Scope. Se ejecuta así:

PYTHONPATH=/path/to/django-6.1.1 python poc_django_vary_missing_empty.py

Lo que hace, paso a paso:

  1. Envía GET /account con la cabecera presente pero vacía (X-Scope:). La vista responde PRIVATE y el middleware guarda esa respuesta en caché.
  2. Envía de nuevo GET /account, esta vez sin ninguna cabecera X-Scope, y recibe otra vez PRIVATE, servido desde la caché.
  3. Comprueba el contador de invocaciones de la vista: vale 1, lo que demuestra que la segunda respuesta no se generó, sino que salió de la caché.

Salida obtenida:

first=PRIVATE second=PRIVATE view_calls=1
VULNERABLE: missing and empty varied headers were treated as one cache key

Impacto

Afecta a aplicaciones que usan Vary correctamente para separar representaciones según si una cabecera está presente o no. En ellas, lo que se cacheó para una petición con la cabecera vacía podía servirse a una petición sin la cabecera, o al revés. Si una de las dos representaciones contiene datos pensados solo para ese estado de la petición, el resultado es la divulgación de una respuesta privada a través de la propia caché de Django.

Lo que necesita el atacante es poco: enviar una cabecera vacía y provocar (o solicitar) que la caché se llene con esa respuesta. No le hace falta conocer la clave secreta, ni SQL en crudo, ni ningún componente especial en el despliegue. El reporte figura con severidad baja y como resuelto.

Qué aprender de este caso

  • Al revisar una caché que respeta Vary, prueba siempre tres variantes de cada cabecera nominada: ausente, presente vacía y presente con valor. Si dos de ellas devuelven la misma entrada, la clave está perdiendo información.
  • En el código que construye claves de caché, un valor None (cabecera ausente) y uno "" (cabecera vacía) no deben acabar igual en el hash: codifica la ausencia con un marcador propio en lugar de saltarte el valor o convertirlo en cadena vacía.
  • No hagas que una vista decida entre contenido privado y público solo por la presencia de una cabecera si la respuesta va a pasar por una caché compartida; si no queda otra, marca la respuesta privada como no cacheable (Cache-Control: private o no-store).
  • Cuando una capa intermedia (aquí, el adaptador ASGI) conserva un matiz como el valor vacío, sigue ese dato a lo largo de toda la cadena: el fallo suele estar en el último paso que lo normaliza.