Django/Security Subir imágenes, si no se controla, puede derribar el servidor: la guía completa que combina seguridad y eficiencia

En los servicios web, la subida de imágenes suele considerarse una función “obvia” y se implementa de forma rápida. Sin embargo, el endpoint de carga es el canal más directo por el que datos externos entran al servidor y, desde la perspectiva de un atacante, es el punto con mayor potencial de daño al menor coste (carga de web shells, vulnerabilidades de parsers de imágenes, DoS, etc.).

Esta publicación explica cómo cumplir simultáneamente los objetivos aparentemente contradictorios de seguridad paranoica y uso racional de recursos. La clave es sencilla:

  • En la fase de validación, evita leer el contenido (solo decodifica encabezados y metadatos).
  • En la fase de almacenamiento, recrea la imagen (transcodificación para sanitizar).

La esencia de la seguridad en la carga: “No confíes, confía lo más tarde posible”



En la carga, casi nada es confiable:

  • Nombre de archivo: el usuario lo cambia.
  • Extensión: el usuario la cambia.
  • Content‑Type: el cliente lo envía.
  • Contenido: el atacante lo crea.

Por ello, la estrategia se reduce a dos pasos:

  1. Filtrar rápidamente con comprobaciones baratas (cheap checks).
  2. El archivo final siempre lo genera el servidor (artefacto generado por el servidor).

Mitos comunes: “Para comprobar bien, hay que leer todo”

Un error frecuente es colocar código como el siguiente en la fase de validación:

img = Image.open(file)
img.verify()  # o img.load()

El problema es que está entregando recursos del servidor al atacante de forma anticipada.

¿Por qué es peligroso?

  • Bombas de descompresión Un archivo que parece de pocos MB puede expandirse a decenas de GB al decodificarse. load() desencadena la decodificación real de píxeles, agotando memoria/CPU y provocando DoS.
  • I/O innecesario verify() lee el archivo hasta el final, generando un coste de I/O alto; además, suele requerir seek(0) o re‑apertura para el siguiente paso.

Conclusión: en la fase de validación, no decodifiques píxeles. Un encabezado + metadatos + límite de resolución es suficiente para la primera defensa.


Defensa en profundidad: tres capas



La carga de imágenes no se resuelve con una sola línea if. Se necesita una pila de defensas.

1ª capa: la extensión es una mentira — detección MIME por Magic Number

El nombre profile.png no significa nada. Se debe leer el firmado (Magic Number) del archivo para verificar su tipo real.

  • No leas el archivo completo. Los primeros 1‑2 KB bastan.
  • Ejemplo de librería: python‑magic (basado en libmagic).

2ª capa: Pillow es seguro al abrir — carga perezosa y límite de resolución

Image.open() de Pillow normalmente no carga los píxeles inmediatamente, solo analiza el encabezado. Aprovecha esto para bloquear antes de que se produzca una explosión de memoria.

  • Verifica: width * height <= MAX_PIXELS.
  • Punto clave: usa size sin load()/verify().

3ª capa: la mejor sanitización es re‑dibujar — transcodificación

Principio fundamental.

No guardes el original.

Las imágenes pueden contener metadatos (EXIF), perfiles, trucos de parseo, etc. Al extraer solo los datos de píxeles y guardarlos en un nuevo formato, se eliminan la mayoría de los riesgos.

  • Recomendado: WebP (o AVIF/JPEG) para re‑codificar.
  • Beneficios: sanitización, compresión, política de formato consistente.

Implementación práctica: DRF Serializer (seguridad + eficiencia de memoria)

El siguiente código incorpora la filosofía de “no leer en la validación, recrear en el almacenamiento”.

  • f.size es metadato que ya conoce Django.
  • Magic Number solo lee la cabeza.
  • Pillow solo verifica la resolución.
  • Almacenamiento final: transcodificación a WebP.
  • Cada paso restaura el puntero con seek(0).
from io import BytesIO

import magic
from PIL import Image, ImageOps, UnidentifiedImageError
from rest_framework import serializers

# Políticas (ajusta a tu servicio)
MAX_SIZE = 5 * 1024 * 1024          # 5 MB
MAX_PIXELS = 4_194_304              # 2048 × 2048 ≈ 4 MP
ALLOWED_MIME = {"image/png", "image/jpeg", "image/webp"}

class SecureImageUploadSerializer(serializers.Serializer):
    file = serializers.ImageField()

    def validate_file(self, f):
        # [1] Límite de tamaño: filtro barato y rápido
        if f.size > MAX_SIZE:
            raise serializers.ValidationError("El archivo es demasiado grande.")

        # [2] MIME por Magic Number: no confiar en extensión/Content‑Type
        f.seek(0)
        head = f.read(2048)
        f.seek(0)

        mime = magic.from_buffer(head, mime=True)
        if mime not in ALLOWED_MIME:
            raise serializers.ValidationError("Formato de archivo no soportado.")

        # [3] Límite de resolución: sin load/verify, solo encabezado
        try:
            with Image.open(f) as img:
                w, h = img.size
                if (w * h) > MAX_PIXELS:
                    raise serializers.ValidationError("La resolución de la imagen es demasiado alta.")
        except UnidentifiedImageError:
            raise serializers.ValidationError("Imagen no válida.")
        except Exception:
            raise serializers.ValidationError("Error al validar la imagen.")
        finally:
            f.seek(0)

        return f

    def create(self, validated_data):
        f = validated_data["file"]

        # Almacenamiento final: siempre generado por el servidor (sanitizado)
        try:
            with Image.open(f) as img:
                # Rotación por EXIF (muy importante en móviles)
                img = ImageOps.exif_transpose(img)

                # Normalizar espacio de color
                if img.mode not in ("RGB", "RGBA"):
                    img = img.convert("RGB")

                out = BytesIO()
                img.save(out, format="WEBP", quality=85, method=6)
                out.seek(0)

                safe_bytes = out.getvalue()

                # Aquí guarda safe_bytes en tu almacenamiento.
                # - Nombra con UUID
                # - Sharding de directorios (ej.: ab/cd/uuid.webp)
                # - En la BD guarda solo la clave generada por el servidor
                return safe_bytes

        except Exception:
            raise serializers.ValidationError("Error al procesar la imagen.")

Equilibrar “seguridad paranoica” y “eficiencia de recursos”

El equilibrio clave es:

No ser codicioso en la validación

La validación es el punto de mayor tráfico y el que el atacante puede invocar repetidamente sin coste. Si ejecutas load() u operaciones costosas, el atacante puede agotar recursos del servidor.

  • ✅ Límite de tamaño / MIME por encabezado / límite de resolución
  • ❌ Decodificación de píxeles / lectura completa / re‑apertura múltiple

La verdadera seguridad se crea en el almacenamiento

La validación es filtrar, el almacenamiento es normalizar. Si el servidor genera un nuevo flujo de bytes, la seguridad y la operación se simplifican.

  • Formato único → simplifica caché y generación de miniaturas.
  • Limpieza de metadatos → elimina datos sensibles (GPS EXIF).
  • Reduce la posibilidad de inyección de carga maliciosa.

Detalles adicionales para una solución robusta

Imagen que muestra la defensa contra cargas maliciosas

  • Nunca confíes en el nombre de archivo; genera uno en el servidor (UUID recomendado).
  • Considera no recibir la carga directamente en el servidor de aplicación. En servicios grandes, usa URLs pre‑firmadas para subir directamente a un bucket de almacenamiento y procesa/convierte de forma asíncrona.
  • Limita el tiempo de procesamiento y aisla el trabajo. La conversión de imágenes consume CPU; evita mantener la solicitud web bloqueada y delega a workers/colas.
  • Registra y métrica. Almacena motivos de rechazo (MIME, resolución, tamaño) para detectar patrones de ataque.

Checklist de resumen

  1. No cargues el archivo completo en memoria. Usa read() solo en la cabeza.
  2. No confíes en extensión/Content‑Type; verifica MIME con Magic Number.
  3. En la validación, evita load()/verify(); comprueba solo la resolución.
  4. No guardes el original; re‑codifica con transcodificación.
  5. Restablece el puntero con seek(0) en cada paso.

La seguridad de la carga comienza con la sospecha del usuario, pero la eficiencia de rendimiento nace de comprender cómo funciona el sistema. Equilibrar ambos aspectos evita caídas y mantiene la experiencia del usuario.


Lee también