StackPractices
intermediate Por Mathias Paulenko

Validación de Subida de Archivos

Cómo manejar subidas de archivos de forma segura con validación de tamaño, tipo y contenido.

Visión General

A los atacantes les encantan las subidas de archivos — son la vía más fácil de meter código en tu servidor. Un archivo que pase la validación puede significar ejecución remota de código, XSS almacenado o una filtración de datos. Esta receta valida las subidas en cinco capas — límites de tamaño, tipos MIME, magic bytes y estructura del contenido — antes de que nada del usuario toque tu filesystem.

Cuándo Usar

Usa este recurso cuando:

  • Construyas una app web que acepte imágenes, documentos o media generados por usuarios. Consulta Optimización de Imágenes para procesamiento post-subida.
  • Implementes un CMS, foro o SaaS con soporte de adjuntos. Consulta Exportar CSV Excel para capacidades de exportación de datos.
  • Necesites cumplir con estándares de seguridad (PCI-DSS, SOC 2). Consulta Gestión de Secretos para almacenamiento seguro de credenciales.
  • Proceses archivos de fuentes no confiables (formularios públicos, APIs). Consulta Input Validation para manejo de entrada no confiable.

Solución

Python

import os
import magic
from werkzeug.utils import secure_filename

ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
MAX_FILE_SIZE = 5 * 1024 * 1024  # 5 MB

def validate_upload(file_storage):
    # 1. Verificar extensión del archivo
    filename = secure_filename(file_storage.filename)
    ext = filename.rsplit(".", 1)[1].lower() if "." in filename else ""
    if ext not in ALLOWED_EXTENSIONS:
        raise ValueError(f"Extensión no permitida: {ext}")

    # 2. Verificar tamaño del archivo
    file_storage.seek(0, os.SEEK_END)
    size = file_storage.tell()
    file_storage.seek(0)
    if size > MAX_FILE_SIZE:
        raise ValueError(f"Archivo demasiado grande: {size} bytes")

    # 3. Verificar magic bytes (libmagic)
    mime = magic.from_buffer(file_storage.read(2048), mime=True)
    file_storage.seek(0)
    expected_mimes = {
        "png": "image/png", "jpg": "image/jpeg",
        "jpeg": "image/jpeg", "gif": "image/gif", "pdf": "application/pdf"
    }
    if mime != expected_mimes.get(ext):
        raise ValueError(f"MIME no coincide: recibido {mime}, esperado {expected_mimes.get(ext)}")

    return filename

JavaScript (Node.js)

const path = require("path");
const multer = require("multer");
// file-type es ESM-only desde v19 — en CommonJS fija file-type@18
// o cárgalo dinámicamente dentro de un contexto async.
const { fileTypeFromBuffer } = await import("file-type");
const fs = require("fs");

const ALLOWED = { png: "image/png", jpg: "image/jpeg", pdf: "application/pdf" };
const MAX_SIZE = 5 * 1024 * 1024;

const upload = multer({
  limits: { fileSize: MAX_SIZE },
  fileFilter: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase().replace(".", "");
    if (!ALLOWED[ext]) return cb(new Error("Extensión no permitida"));
    cb(null, true);
  },
});

async function validateBuffer(buffer, ext) {
  const type = await fileTypeFromBuffer(buffer);
  if (!type || type.mime !== ALLOWED[ext]) {
    throw new Error(`MIME no coincide: ${type?.mime}`);
  }
  return true;
}

Java (Spring Boot)

import org.springframework.web.multipart.MultipartFile;
import java.util.Set;

public class UploadValidator {
    private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg", "application/pdf");
    private static final long MAX_SIZE = 5L * 1024 * 1024;

    public static void validate(MultipartFile file) {
        if (file.getSize() > MAX_SIZE) {
            throw new IllegalArgumentException("Archivo excede 5 MB");
        }
        String contentType = file.getContentType();
        if (!ALLOWED.contains(contentType)) {
            throw new IllegalArgumentException("Tipo MIME no permitido: " + contentType);
        }
        // Adicional: verificar magic bytes con Apache Tika o similar
    }
}

Explicación

La validación debe ocurrir en capas:

  1. Cliente — mejora la UX pero es trivial de evitar.
  2. Verificación de extensión en servidor — rápida pero fácil de falsificar.
  3. Verificación de tipo MIME en servidor — mejor, pero aún depende de headers HTTP.
  4. Magic bytes — lee la firma real del archivo en lugar de confiar en lo que declara el cliente. La verificación individual más fuerte.
  5. Escaneo de contenido / AV — esencial para cualquier entorno que maneje archivos no confiables.

Cada capa detecta lo que las demás dejan pasar — no apuestes a una sola.

flowchart diagram: Cliente: extensión + tamaño

Variantes

TecnologíaLibrería de ValidaciónNotas
Pythonpython-magicLee base de datos libmagic; muy preciso
Node.jsfile-typePuro JS, rápido, sin dependencias nativas
JavaApache TikaPesado pero maneja 1000+ formatos
GomimetypeRápido, puro Go, lecturas sin allocación
RubyMarcelDefault de Rails, usa extensión y magic

Lo que funciona

  • Valida antes de guardar en disco: verifica todo en memoria o en un buffer temporal primero.
  • Usa nombres de archivo aleatorios: nunca guardes archivos con el nombre original del usuario — mapea a UUIDs internamente.
  • Almacena fuera del web root: que un controlador o API sirva los archivos, nunca el filesystem directamente.
  • Escanea con AV: integra ClamAV o un scanner en la nube para subidas no confiables.
  • Limita la tasa de subidas: previene abuso y agotamiento de disco.

Cuotas de subida por usuario

Rastrea el total de bytes subidos por usuario para que una sola cuenta no llene el disco. Verifica la cuota antes de las comprobaciones caras y consúmela solo después de que el archivo pase la validación — si no, las subidas rechazadas también gastan cuota:

import redis

r = redis.Redis()

def has_quota(user_id: str, file_size: int, max_quota: int = 100 * 1024 * 1024) -> bool:
    key = f"upload_quota:{user_id}"
    used = int(r.get(key) or 0)
    return used + file_size <= max_quota

def consume_quota(user_id: str, file_size: int) -> None:
    r.incrby(f"upload_quota:{user_id}", file_size)

# if not has_quota(user_id, file_size):
#     raise ValueError("Cuota de subida excedida")

Elimina datos EXIF de las imágenes subidas

EXIF puede llevar coordenadas GPS, números de serie de cámara y otro PII. Re-codificar elimina la mayoría — sharp quita los metadatos por defecto (solo withMetadata() los conserva):

const sharp = require('sharp');

async function stripExif(inputBuffer) {
    // EXIF/ICC se eliminan salvo que llames a .withMetadata()
    return sharp(inputBuffer)
        .rotate()
        .png()
        .toBuffer();
}

Sirve las subidas con Content-Disposition: attachment

Evita que el navegador renderice archivos subidos inline, lo que podría ejecutar scripts en el contexto de tu dominio:

location /uploads/ {
    add_header Content-Disposition "attachment";
    add_header X-Content-Type-Options "nosniff";
    add_header Content-Security-Policy "default-src 'none'";
}

Errores Comunes

  • Confiar en el header Content-Type: los atacantes lo ponen a lo que quieran.
  • Depender solo de la extensión: un .jpg puede contener código PHP.
  • Sin límite de tamaño: una sola subida puede llenar tu disco.
  • Guardar en directorios públicos: deja un ejecutable bajo el web root y el servidor puede correrlo por ti.
  • Sin escaneo de virus: un MIME limpio no dice nada del payload que lleva dentro.

Ignorar las bombas de descompresión

Un ZIP pequeño puede expandirse a gigabytes al extraerlo. Limita el tamaño descomprimido durante la extracción:

import zipfile

MAX_DECOMPRESSED_SIZE = 100 * 1024 * 1024  # 100 MB

def safe_extract_zip(zip_path: str, dest_dir: str) -> int:
    """Extrae un ZIP con protección contra bombas de descompresión."""
    total_size = 0
    count = 0
    with zipfile.ZipFile(zip_path, 'r') as zf:
        for info in zf.infolist():
            total_size += info.file_size
            if total_size > MAX_DECOMPRESSED_SIZE:
                raise ValueError(f"Bomba de descompresión: {total_size} bytes")
            zf.extract(info, dest_dir)
            count += 1
    return count

Permitir SVGs sin sanitizar

Un SVG puede llevar tags <script> y handlers onload. O lo quitas de la lista de permitidos o lo sanitizas en servidor:

// Opción 1: no aceptar SVG
const ALLOWED = { png: 'image/png', jpg: 'image/jpeg', pdf: 'application/pdf' };

// Opción 2: sanitizar con DOMPurify (server-side)
// const DOMPurify = require('isomorphic-dompurify');
// const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true, svgFilters: true } });

No loguear los fallos de subida

Los fallos de validación son eventos de seguridad. Loguéalos con contexto para auditoría y respuesta a incidentes:

import logging

logger = logging.getLogger('upload_security')

def validate_upload_with_logging(file_storage, user_id: str):
    try:
        filename = validate_upload(file_storage)  # el validador de Solución
        logger.info(f"Subida aceptada: user={user_id} file={filename}")
        return filename
    except ValueError as e:
        logger.warning(f"Subida rechazada: user={user_id} reason={e} filename={file_storage.filename}")
        raise
    except Exception as e:
        logger.error(f"Error de subida: user={user_id} error={e} filename={file_storage.filename}", exc_info=True)
        raise

Lecturas Adicionales

Preguntas frecuentes

¿Debería validar en el cliente o en el servidor?

En ambos. El cliente da feedback instantáneo; el servidor es la única capa que cuenta para seguridad — nunca confíes en nada que venga del cliente.

¿Cuál es la diferencia entre tipo MIME y magic bytes?

El tipo MIME viene del header Content-Type que envía el cliente — trivial de falsificar. Los magic bytes son la firma del archivo dentro del propio contenido, mucho más difíciles de falsificar.

¿Cómo evito que usuarios suban malware disfrazado de imágenes?

Combina verificación de magic bytes, re-codificación (procesa la imagen y guárdala de nuevo — eso elimina payloads embebidos) y escaneo antivirus.

¿Qué tipos de archivo no debería aceptar nunca?

Todo lo que pueda ejecutarse: .exe, .bat, .sh, .php, .py, .jar, .war, .jsp, .aspx, además de archivos de configuración del servidor como .htaccess o .env. La regla práctica: si puede ejecutarse en tu servidor, no se sube.

¿Cómo manejo subidas grandes (video, datasets)?

A partir de ~50 MB, pasa a subidas por chunks resumibles: divide en el cliente, valida cada chunk y reensambla en el servidor. tus, Uppy y Dropzone.js implementan este patrón.

¿Esta solución está lista para producción?

Sí — el enfoque por capas (extensión + MIME + magic bytes + AV + re-codificación) es lo que recomienda el OWASP File Upload Cheat Sheet. Las librerías de Variantes son las opciones estándar en cada ecosistema; añade el monitoreo y las piezas de cuota/logging de arriba y tienes un setup de producción.

¿Cuáles son las características de rendimiento?

Las verificaciones corren de más barata a más cara a propósito: extensión y MIME son lookups O(1), los magic bytes solo leen los primeros ~2 KB. La re-codificación y el escaneo AV son las etapas caras — dominan la latencia por archivo, así que ejecútalas de forma asíncrona cuando puedas. Usa streaming a almacenamiento temporal en vez de bufferar la subida completa en memoria.

¿Cómo depuro problemas de validación de subida?

Comprueba lo que cada herramienta ve realmente: file --mime-type upload.bin para el veredicto de libmagic, xxd upload.bin | head -5 para la firma (PNG 89504e47, JPEG ffd8ff, PDF 25504446). Luego revisa los límites de cada capa — fileSize de multer, spring.servlet.multipart.max-file-size de Spring, o client_max_body_size de nginx si recibes errores 413.