Subidas seguras de archivos con AJAX: buenas prácticas y técnicas
Manejar las subidas de archivos de forma segura es fundamental en el desarrollo web moderno. A medida que las subidas de archivos con AJAX se vuelven más habituales, resulta esencial proteger tu aplicación frente a posibles vulnerabilidades. En esta publicación exploramos buenas prácticas y técnicas modernas (como el uso de la API Fetch, las subidas por fragmentos y un manejo integral de errores) para construir sistemas seguros de subida de archivos.
Entender las subidas de archivos con AJAX
AJAX (Asynchronous JavaScript and XML) permite que las aplicaciones web envíen y reciban datos del servidor de forma asíncrona sin recargar la página. Al implementar subidas de archivos con AJAX, es importante atender las consideraciones de seguridad tanto del lado del cliente como del lado del servidor.
Compatibilidad con navegadores
Los navegadores modernos ofrecen un soporte sólido para las subidas de archivos mediante varias API:
- La API FormData está disponible en todos los navegadores modernos.
- La API Fetch es el enfoque recomendado frente a las técnicas heredadas.
- La API File ofrece capacidades avanzadas de manejo de archivos.
- XMLHttpRequest admite eventos de progreso de subida de la solicitud, que Fetch no proporciona directamente.
Para más detalles, consulta la documentación de MDN sobre FormData y la API Fetch.
Implementar subidas de archivos modernas
Uso de la API Fetch con async/await
La API Fetch, combinada con async/await, ofrece un método limpio para subir archivos. El endpoint /upload
que se muestra más adelante acepta un único campo multipart file y devuelve JSON. Sirve el cliente
desde el mismo origen o configura CORS para tu aplicación:
const uploadFile = async (file, idempotencyKey) => {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData,
headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined,
})
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error('Upload error:', error)
throw error
}
}
Seguimiento del progreso de subida
Usa los eventos de XMLHttpRequest.upload
para hacer seguimiento de los bytes enviados en la solicitud. Leer response.body mide la descarga de la
respuesta, no la subida del archivo. Un valor de progreso del 100 % significa que el cuerpo se
envió; espera la respuesta del servidor antes de informar del éxito:
const uploadWithProgress = (file, onProgress = console.log) => {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress((event.loaded / event.total) * 100)
}
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 120000
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response)
} else {
reject(new Error(`Upload failed (HTTP ${xhr.status})`))
}
}
xhr.onerror = () => reject(new Error('Upload failed: network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new DOMException('Upload canceled', 'AbortError'))
xhr.send(formData)
})
}
Manejo de subidas por fragmentos
En archivos grandes, dividir la subida en fragmentos más pequeños mejora la fiabilidad en
condiciones de red inestables. Esto ilustra el contrato de un endpoint aparte, no endpoints
implementados por el servidor Multer que aparece más abajo. El servidor debe vincular fileId al
usuario autenticado, validar los índices de fragmento, los recuentos y el tamaño agregado, y
ensamblar todos los fragmentos antes de confirmar la finalización. Debe expirar de forma segura las
subidas incompletas y hacer idempotentes los fragmentos repetidos y las solicitudes de finalización.
Usa un servidor y un cliente del protocolo tus cuando necesites un
protocolo reanudable estandarizado:
const CHUNK_SIZE = 1024 * 1024 // 1MB chunks
const uploadLargeFile = async (file) => {
if (file.size === 0) throw new Error('Please select a nonempty file')
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
const fileId = crypto.randomUUID()
for (let chunk = 0; chunk < totalChunks; chunk++) {
const start = chunk * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const fileChunk = file.slice(start, end)
const formData = new FormData()
formData.append('chunk', fileChunk)
formData.append('fileId', fileId)
formData.append('chunkIndex', chunk)
formData.append('totalChunks', totalChunks)
formData.append('fileSize', file.size)
try {
const response = await fetch('/upload/chunk', {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`Chunk ${chunk} failed (HTTP ${response.status})`)
}
} catch (error) {
console.error(`Chunk ${chunk} failed:`, error)
throw error
}
}
// Finalize the upload
const response = await fetch('/upload/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ fileId }),
})
if (!response.ok) {
throw new Error(`Upload completion failed (HTTP ${response.status})`)
}
return response
}
Manejo de subidas de varios archivos
Subir varios archivos de forma concurrente puede mejorar el rendimiento. Con el atributo multiple
del input y JavaScript moderno, puedes manejar una selección pequeña de forma concurrente. Este
ejemplo limita cada selección a cinco archivos; usa una cola acotada para lotes más grandes:
const uploadMultipleFiles = async (files) => {
if (files.length > 5) throw new Error('Select up to five files at a time')
const uploadPromises = Array.from(files).map((file) => {
const formData = new FormData()
formData.append('file', file)
return fetch('/upload', { method: 'POST', body: formData })
.then((response) => {
if (!response.ok) {
throw new Error(`Upload failed for ${file.name}`)
}
return response.json()
})
.catch((error) => {
console.error(`Error uploading ${file.name}:`, error)
throw error
})
})
return Promise.all(uploadPromises)
}
Subida de archivos con arrastrar y soltar
Mejora la experiencia de usuario implementando subidas de archivos con arrastrar y soltar. El siguiente ejemplo define una zona para soltar archivos que responde a los eventos de arrastre:
const dropZone = document.getElementById('drop-zone')
dropZone.addEventListener('dragover', (e) => {
e.preventDefault()
dropZone.classList.add('highlight')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('highlight')
})
dropZone.addEventListener('drop', async (e) => {
e.preventDefault()
dropZone.classList.remove('highlight')
const files = e.dataTransfer.files
try {
const results = await uploadMultipleFiles(files)
console.log('Files uploaded:', results)
} catch (error) {
console.error('Error during drag-and-drop upload:', error)
}
})
Validación del tipo de archivo del lado del cliente
Antes de subir, valida los tipos de archivo con la API File para ofrecer retroalimentación inmediata a los usuarios:
const validateFileType = (file) => {
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
alert(`Invalid file type: ${file.type}`)
return false
}
return true
}
const handleFileInput = async (event) => {
const files = event.target.files
try {
for (const file of files) {
if (validateFileType(file)) await uploadFile(file)
}
} catch {
alert('Unable to upload the selected file. Please try again.')
}
}
document.getElementById('file-input').addEventListener('change', handleFileInput)
Implementación del lado del servidor
Este ejemplo de Node.js usa almacenamiento en disco, límites de solicitud y errores saneados. Multer 2.3.0 corrige el problema de denegación de servicio descrito en el aviso oficial. Instala las dependencias:
npm install express@5 multer@2.3.0 express-rate-limit@8
Ejecuta esto como un archivo de servidor CommonJS con Node.js. Guarda uploads/ fuera de la raíz
web. Este ejemplo acepta archivos en un almacenamiento privado; se deben añadir autorización,
protección CSRF para sesiones basadas en cookies, inspección de contenido y análisis de malware
antes de publicar o procesar los archivos. El valor MIME que proporciona el cliente es solo un filtro
preliminar, no una prueba del contenido del archivo.
const express = require('express')
const multer = require('multer')
const path = require('path')
const crypto = require('crypto')
const app = express()
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
// Generate a secure random filename
crypto.randomBytes(16, (err, raw) => {
if (err) return cb(err)
cb(null, raw.toString('hex') + path.extname(file.originalname))
})
},
})
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (allowedTypes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('Invalid file type'), false)
}
}
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
files: 1,
fields: 0,
parts: 2,
},
fileFilter,
})
const { rateLimit } = require('express-rate-limit')
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 uploads per window
})
app.post('/upload', uploadLimiter, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded.' })
}
res.json({ message: 'File accepted into private storage.' })
})
// Error handling middleware
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400
return res.status(status).json({ error: 'File exceeds an upload limit.' })
}
if (err.message === 'Invalid file type') {
return res.status(400).json({ error: 'Unsupported file type.' })
}
console.error('Upload failed', { code: err.code ?? 'UNKNOWN' })
res.status(500).json({ error: 'Unable to upload the file.' })
})
app.listen(3000)
Buenas prácticas de seguridad
Protege tu sistema de subida de archivos implementando medidas de seguridad sólidas:
- Define una Content Security Policy (CSP) en las respuestas HTML. Registra este middleware antes de las rutas de página y carga los scripts desde archivos separados. Esta línea base no permite scripts en línea; adapta a tu aplicación una política basada en nonce o en hash si necesitas scripts en línea:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'",
)
next()
})
- Ofrece retroalimentación temprana a los usuarios con comprobaciones en el navegador y luego aplica reglas de tamaño y contenido en el servidor:
const validateFile = (file) => {
const maxSize = 5 * 1024 * 1024 // 5MB
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (file.size > maxSize) {
throw new Error('File too large')
}
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type')
}
}
-
Configura un almacenamiento seguro:
- Guarda los archivos fuera de la raíz web.
- Usa nombres de archivo aleatorios.
- Establece permisos de archivo adecuados.
- Considera opciones de almacenamiento en la nube para la escalabilidad.
-
Implementa limitación de tasa para frenar los abusos.
-
Exige HTTPS para cifrar las transferencias de archivos.
-
Usa URL firmadas para subidas seguras directas al almacenamiento.
-
Integra el análisis de virus con soluciones fiables basadas en la nube.
Manejo y recuperación de errores
Un manejo sólido de errores mejora la experiencia de usuario. Implementa lógica de reintentos para
fallos de red transitorios. Se puede perder una respuesta después de que el servidor haya almacenado
un archivo, así que usa esto solo con un servidor que deduplique los reintentos mediante una clave
de idempotencia definida por la aplicación. El servidor Multer anterior no implementa deduplicación;
añade un manejo persistente de Idempotency-Key, acotado al usuario autenticado, antes de habilitar este
wrapper:
const uploadWithRetry = async (file, maxRetries = 3) => {
if (!Number.isInteger(maxRetries) || maxRetries < 1) {
throw new Error('maxRetries must be a positive integer')
}
const idempotencyKey = crypto.randomUUID()
let attempts = 0
while (attempts < maxRetries) {
try {
const result = await uploadFile(file, idempotencyKey)
return result
} catch (error) {
attempts++
if (attempts === maxRetries) {
throw new Error(`Upload failed after ${maxRetries} attempts`, { cause: error })
}
// Exponential backoff before retrying
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempts) * 1000))
}
}
}
Conclusión
Las subidas seguras de archivos requieren medidas exhaustivas del lado del cliente y del lado del servidor. Al implementar técnicas modernas de validación, manejo de errores y subidas eficientes, puedes construir un sistema sólido que mitiga las vulnerabilidades comunes.
Para el progreso de subida y las transferencias reanudables, considera Uppy con un plugin de subida acorde al protocolo de tu servidor.
