Domina la subida de archivos: guía completa para desarrolladores
La subida de archivos es una función fundamental de muchas aplicaciones web que permite a los usuarios transferir archivos desde sus dispositivos locales a tu servidor. Sin embargo, implementar una API de subida de archivos segura y eficiente puede ser un desafío. En esta guía completa, explicamos qué es la subida de archivos, configuramos una función básica de subida, mostramos cómo gestionarla en distintos frameworks, resolvemos problemas comunes, probamos tus API y repasamos las buenas prácticas de seguridad y las técnicas avanzadas.
Introducción: ¿qué es la subida de archivos?
Antes de abordar los detalles de implementación, es importante entender qué es la subida de archivos. En el desarrollo web, permite a los usuarios enviar archivos desde sus dispositivos locales a tu servidor a través de tu aplicación. Esta función es esencial en aplicaciones que requieren contenido generado por los usuarios, como imágenes de perfil, documentos o archivos multimedia, y garantiza una experiencia de usuario fluida a la vez que mantiene una seguridad sólida.
Configura una función básica de subida de archivos
Comienza creando un formulario HTML que permita a los usuarios seleccionar un archivo:
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="myfile" />
<button type="submit">Upload File</button>
</form>
El atributo enctype="multipart/form-data" es esencial porque indica al navegador que envíe
los datos del archivo junto con los campos habituales del formulario.
Los siguientes programas de Node, Flask y PHP son demostraciones locales de manejadores de subida. Vincúlalos a la interfaz de bucle local y mantén su almacenamiento fuera de cualquier directorio raíz estático o de documentos. No implementan el inicio de sesión: incorpora autenticación, autorización, protección CSRF, cuotas por usuario y límites de frecuencia antes de que las solicitudes lleguen a ellos y antes del despliegue. El fragmento de Rails se integra con una aplicación existente que ya cuenta con autenticación.
Para Node.js 24, instala los paquetes exactos indicados a continuación y configura
"type": "module" en package.json:
yarn init -2
yarn add express@5.2.1 express-fileupload@1.5.2 file-type@22.1.0
Guarda el código como upload.js. Este ejemplo utiliza subidas en memoria con
límites para evitar que queden archivos temporales del analizador tras un rechazo. Exige que se
declare la longitud del cuerpo multipart, permite como máximo dos solicitudes activas y genera
todas las rutas de almacenamiento en el servidor.
import { randomUUID } from 'node:crypto'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import express from 'express'
import fileUpload from 'express-fileupload'
import { fileTypeFromBuffer } from 'file-type'
const app = express()
const maxBytes = 5 * 1024 * 1024
const root = join(process.cwd(), 'private-uploads')
await mkdir(root, { recursive: true, mode: 0o700 })
let active = 0
const parse = fileUpload({
limits: { fileSize: maxBytes, files: 2, fields: 0, parts: 3 },
abortOnLimit: true,
useTempFiles: false,
uploadTimeout: 10_000,
})
app.post('/upload', (req, res, next) => {
const length = req.get('content-length') ?? ''
if (!/^[1-9][0-9]*$/.test(length)) return res.status(411).send('Content length required')
if (Number(length) > 6 * 1024 * 1024) return res.status(413).send('Request too large')
if (active >= 2) return res.status(503).send('Busy')
active += 1
res.once('close', () => { active -= 1 })
next()
}, parse, async (req, res) => {
const files = req.files
const file = files?.myfile
if (!files || Object.keys(files).length !== 1 || !file || Array.isArray(file)) {
return res.status(400).send('Send exactly one myfile')
}
if (file.truncated || file.size < 1 || file.size > maxBytes) {
return res.status(413).send('Invalid file size')
}
let type
try {
type = await fileTypeFromBuffer(file.data.subarray(0, 4100))
} catch {
return res.status(400).send('Unrecognized image')
}
const extensions = new Map([['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/gif', 'gif']])
const extension = extensions.get(type?.mime)
if (!extension) return res.status(400).send('Unrecognized image')
const directory = join(root, randomUUID())
let created = false
try {
await mkdir(directory, { mode: 0o700 })
created = true
await writeFile(join(directory, 'image.' + extension), file.data, { flag: 'wx', mode: 0o600 })
if (res.destroyed) {
await rm(directory, { recursive: true, force: true })
return
}
res.status(201).send('File uploaded successfully')
} catch {
if (created) await rm(directory, { recursive: true, force: true })
throw new Error('Storage failed')
}
})
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).send('Upload failed')
})
const server = app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1')
server.requestTimeout = 15_000
server.headersTimeout = 10_000
Ejecuta yarn node upload.js y envía una solicitud multipart con el campo
myfile.
express-fileupload proporciona el análisis de datos multipart;
no es intrínsecamente más seguro que otro analizador con mantenimiento activo.
file-type identifica firmas de archivo, pero no determina su seguridad.
Un PNG con bytes de un script añadidos al final puede seguir identificándose como PNG. El
almacenamiento privado y las extensiones generadas impiden que el manejador cree un archivo
ejecutable en la web con un nombre proporcionado por el cliente; la entrega pública segura de
imágenes requiere una política independiente de decodificación, recodificación y análisis de amenazas.
Gestiona la subida de archivos en distintos frameworks
Python (Flask)
Usa Python 3.10 o posterior con Flask 3.1.3 y Pillow 12.3.0 en un entorno aislado:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install Flask==3.1.3 Pillow==12.3.0
Guarda el código como app.py. El manejador completo crea almacenamiento
privado, valida la imagen con un analizador real y utiliza únicamente una extensión elegida por el
servidor. El límite de la solicitud incluye los datos adicionales de multipart; el límite del archivo
se comprueba por separado.
from io import BytesIO
from pathlib import Path
import os
import uuid
import warnings
from flask import Flask, abort, request
from PIL import Image, UnidentifiedImageError
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 6 * 1024 * 1024
# Keep Flask's default form-memory limit; Werkzeug also applies it to parser buffers.
app.config['MAX_FORM_PARTS'] = 2
root = Path.cwd() / 'private-uploads'
root.mkdir(mode=0o700, exist_ok=True)
Image.MAX_IMAGE_PIXELS = 20_000_000
extensions = {'JPEG': 'jpg', 'PNG': 'png', 'GIF': 'gif'}
@app.post('/upload')
def upload():
if set(request.files) != {'myfile'} or len(request.files.getlist('myfile')) != 1:
abort(400)
data = request.files['myfile'].stream.read(5 * 1024 * 1024 + 1)
if not data or len(data) > 5 * 1024 * 1024:
abort(413)
try:
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(BytesIO(data), formats=['JPEG', 'PNG', 'GIF']) as image:
extension = extensions.get(image.format)
image.verify()
except (UnidentifiedImageError, OSError, SyntaxError, ValueError,
Image.DecompressionBombError, Image.DecompressionBombWarning):
abort(400)
if extension is None:
abort(400)
destination = root / (uuid.uuid4().hex + '.' + extension)
created = False
try:
descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
created = True
with os.fdopen(descriptor, 'wb') as output:
output.write(data)
except OSError:
if created:
destination.unlink(missing_ok=True)
abort(500)
return 'File uploaded successfully.', 201
@app.errorhandler(500)
def storage_error(_error):
return 'Upload failed.', 500
if __name__ == '__main__':
app.run(host='127.0.0.1', port=int(os.environ.get('PORT', '3000')), debug=False)
Ejecuta python app.py. El servidor integrado de Flask es para desarrollo local.
La verificación de Pillow
no recodifica la imagen ni elimina el contenido añadido al final. Almacena los originales de forma
privada; aísla el procesamiento de imágenes e impón límites de CPU y memoria antes de aceptar tráfico
público no confiable.
PHP
Usa PHP 8.4 o posterior con FileInfo habilitado. Coloca upload.php en un directorio
public y crea un directorio hermano private-uploads, cuyo
propietario sea el proceso de PHP y con modo 0700.
Ejecuta el ejemplo localmente con php -d upload_max_filesize=5M -d post_max_size=6M -S 127.0.0.1:3000 -t public.
La acción del formulario para este ejemplo es /upload.php.
El contrato de subida de PHP incluye un código de error de subida y
una ruta temporal del servidor. Compruébalos antes de inspeccionar el contenido.
Nunca conserves la extensión enviada: un archivo llamado picture.php puede
contener bytes de una imagen.
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
function rejectUpload(int $status): never {
http_response_code($status);
exit('Upload rejected.');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
rejectUpload(405);
}
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 6 * 1024 * 1024) {
rejectUpload(413);
}
$file = $_FILES['myfile'] ?? null;
if (count($_FILES) !== 1 || !is_array($file) ||
!isset($file['error']) || !is_int($file['error'])) {
rejectUpload(400);
}
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) {
rejectUpload(413);
}
if ($file['error'] !== UPLOAD_ERR_OK || !is_string($file['tmp_name'] ?? null) ||
!is_uploaded_file($file['tmp_name'])) {
rejectUpload(400);
}
$directory = null;
try {
$size = filesize($file['tmp_name']);
if ($size === false || $size < 1 || $size > 5 * 1024 * 1024) {
rejectUpload(413);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
if (!is_string($mime) || !isset($extensions[$mime])) {
rejectUpload(400);
}
$root = dirname(__DIR__) . '/private-uploads';
if (!is_dir($root) || !is_writable($root)) {
throw new RuntimeException('Storage unavailable');
}
$candidate = $root . '/' . bin2hex(random_bytes(16));
if (!mkdir($candidate, 0700)) {
throw new RuntimeException('Storage unavailable');
}
$directory = $candidate;
$destination = $directory . '/image.' . $extensions[$mime];
if (!move_uploaded_file($file['tmp_name'], $destination) || !chmod($destination, 0600)) {
throw new RuntimeException('Storage unavailable');
}
http_response_code(201);
echo 'File uploaded successfully.';
} catch (Throwable $error) {
if ($directory !== null) {
if (isset($destination) && is_file($destination)) {
unlink($destination);
}
rmdir($directory);
}
error_log('Upload storage failed');
http_response_code(500);
echo 'Upload failed.';
}
Deshabilita display_errors para un despliegue HTTP, de modo que las advertencias de
PHP sobre el sistema de archivos no puedan exponer rutas. FileInfo detecta el tipo de contenido;
no es un analizador de malware ni un decodificador completo de imágenes. PHP elimina sus archivos
temporales de subida cuando termina la solicitud; el manejador mueve únicamente los archivos
aceptados al almacenamiento privado. El subdirectorio privado aleatorio evita
las sobrescrituras mediante move_uploaded_file()
de otros archivos subidos y aceptados.
Ruby on Rails
Los validadores attached, content_type y
size provienen de
active_storage_validations, no del propio Rails.
Este ejemplo utiliza Rails 8.1.3.1, Ruby 3.3 o posterior y la versión 4.1.1 de esa gema.
Añádela al Gemfile de tu aplicación existente y ejecuta bundle install:
gem 'active_storage_validations', '4.1.1'
gem 'json', '2.21.2'
La restricción a JSON 2.x conserva la convención de llamadas al analizador que utiliza esta versión
de Rails; JSON 3.x cambia esa interfaz. Mantén las dependencias resueltas de tu aplicación en
Gemfile.lock.
Ejecuta bin/rails active_storage:install y bin/rails db:migrate si Active Storage no está
instalado. Configura un servicio Disk privado o almacenamiento de objetos privado con ayuda de la
guía de Active Storage. La opción de detección de suplantación de
contenido que aparece a continuación también requiere el ejecutable file
de UNIX.
# app/models/user.rb
class User < ApplicationRecord
has_one_attached :myfile
validates :myfile, attached: true,
content_type: { in: ['image/png', 'image/jpeg', 'image/gif'], spoofing_protection: true },
size: { between: 1..5.megabytes }
end
En una aplicación cuya capa de autenticación ya proporciona current_user, usa este
controlador. No selecciones un usuario a partir de params[:user_id]. Solo acepta una
nueva subida multipart, no un ID de blob firmado de Active Storage proporcionado por el cliente.
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
before_action :require_upload_user
protect_from_forgery with: :exception
def create
file = params.require(:user).permit(:myfile)[:myfile]
unless file.is_a?(ActionDispatch::Http::UploadedFile)
return render plain: 'Send a file.', status: :bad_request
end
if current_user.update(myfile: file)
render plain: 'File uploaded successfully.', status: :created
else
render plain: 'File rejected.', status: :unprocessable_entity
end
end
private
def require_upload_user
head :unauthorized unless current_user
end
end
Añade post '/upload', to: 'uploads#create' a config/routes.rb y usa esta vista.
Rails genera el token CSRF y el nombre de campo anidado user[myfile] que espera
el controlador:
<%= form_with scope: :user, url: '/upload', multipart: true do |form| %>
<%= form.label :myfile, 'Image' %>
<%= form.file_field :myfile, accept: 'image/png,image/jpeg,image/gif', required: true %>
<%= form.submit 'Upload file' %>
<% end %>
Mantén privado el servicio de almacenamiento y configura controladores de descarga con autenticación; las URL firmadas predeterminadas de Active Storage no proporcionan autorización por usuario. Impón un límite al cuerpo de las solicitudes en tu servidor web antes de que Rails analice los datos multipart. La validación del modelo ocurre después del análisis y no puede imponer ese límite de recursos de red. Estos ejemplos no habilitan las subidas directas; los blobs sin utilizar de otros flujos necesitan una política de retención independiente.
Problemas comunes de subida de archivos y sus soluciones
¿Por qué no se sube mi archivo?
Entre los problemas comunes que pueden impedir la subida de un archivo se incluyen:
- Codificación incorrecta del formulario: Verifica que tu formulario HTML use
enctype="multipart/form-data". - Límites de tamaño de archivo: Es posible que se superen los límites de tamaño de archivo
impuestos por el servidor o su configuración (por ejemplo,
upload_max_filesizeen PHP). - Errores de permisos: Asegúrate de que el servidor tenga permisos de escritura en el directorio de destino de las subidas.
- Tipos de archivo no válidos: Confirma que el archivo cumpla los criterios de tipos permitidos.
- Campo de archivo ausente: Comprueba que el atributo
namedel campo de selección de archivos coincida con lo que espera tu servidor (por ejemplo,myfile). - Discrepancias en las rutas: Asegúrate de que las rutas de archivo y las URL de acción del formulario apunten correctamente al manejador de subida.
Soluciones
- Consulta los registros del servidor: Revisa los registros de errores para localizar el problema.
- Usa herramientas de depuración: Inserta instrucciones de registro o depuración para seguir el proceso de subida.
- Prueba con varios archivos: Experimenta con distintos tipos y tamaños de archivo para aislar el problema.
Prueba las API de subida de archivos con Postman
Probar tu API de subida de archivos es esencial para garantizar que funcione correctamente. Para probarla con Postman:
- Abre Postman y crea una nueva solicitud POST a tu endpoint (por ejemplo,
http://localhost:3000/upload). - Ve a la pestaña Body y selecciona form-data.
- Añade una clave llamada
myfile, cambia su tipo a File y selecciona un archivo de tu sistema. - (Opcional) Incluye los campos adicionales que requiera tu API.
- Haz clic en Send y revisa la respuesta.
- Como alternativa, prueba con cURL:
curl --fail-with-body -F "myfile=@/path/to/your/file.jpg" http://localhost:3000/upload
Buenas prácticas de seguridad para la subida de archivos
La subida de archivos puede introducir vulnerabilidades de seguridad si no se gestiona correctamente. Considera estas buenas prácticas:
- Valida los tipos de archivo: Permite únicamente tipos de archivo específicos mediante la comprobación de la extensión y del tipo MIME.
- Verifica las firmas de archivo: Usa bytes mágicos para asegurarte de que el contenido del archivo coincida con su extensión.
- Limita el tamaño de los archivos: Impón límites estrictos de tamaño para prevenir ataques de denegación de servicio.
- Almacena los archivos de forma segura: Guarda los archivos en directorios sin acceso público o utiliza servicios seguros de almacenamiento en la nube.
- Genera nombres de archivo únicos: Usa identificadores únicos (por ejemplo, UUID) para evitar la sobrescritura de archivos y reducir la exposición de información.
- Analiza los archivos en busca de malware: Integra herramientas de análisis antivirus como ClamAV para comprobar los archivos subidos.
- Evita los archivos ejecutables: Bloquea las subidas de tipos de archivo potencialmente peligrosos, como ejecutables o scripts.
- Implementa una política de seguridad de contenido (CSP): Usa encabezados CSP (por ejemplo,
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:;) para mitigar los ataques XSS. - Sanea los nombres de archivo: Elimina o reemplaza los caracteres potencialmente inseguros de los nombres de archivo.
- Usa protocolos seguros: Usa siempre HTTPS para cifrar los datos en tránsito.
- Implementa límites de frecuencia: Limita la cantidad de subidas por usuario o dirección IP para prevenir abusos.
- Usa URL firmadas: Genera URL con una validez temporal limitada para acceder a los archivos de forma segura.
- Actualiza periódicamente: Mantén tu servidor y sus dependencias actualizados con los parches de seguridad más recientes.
Técnicas avanzadas de subida de archivos
Para gestionar archivos grandes o un volumen elevado de subidas, considera estas técnicas avanzadas:
- Subidas por fragmentos: Divide los archivos grandes en partes más pequeñas para reducir el riesgo de que se agote el tiempo de espera y permitir que se reanuden las subidas.
- Subidas en streaming: Transmite los datos de los archivos directamente a los servicios de almacenamiento para minimizar el uso de memoria en tu servidor.
- Indicadores de progreso: Proporciona información en tiempo real a los usuarios durante el proceso de subida.
- Integración con la nube: Aprovecha soluciones de almacenamiento en la nube que admitan subidas por fragmentos y en streaming para mejorar la escalabilidad.
Conclusión y recursos adicionales
Implementar la subida de archivos en tu aplicación requiere considerar cuidadosamente la funcionalidad, la experiencia de usuario y la seguridad. Si sigues las buenas prácticas, pruebas rigurosamente tu implementación y exploras técnicas avanzadas como las subidas por fragmentos y en streaming, puedes crear un sistema sólido de subida de archivos. Para facilitar aún más la integración, considera explorar servicios y herramientas como Transloadit, Uppy o el protocolo tus.
