Domina la subida de archivos: guía completa para desarrolladores
Una subida de archivos puede llegar a tu servidor y aun así fallar antes de que se almacene nada. Esta guía ofrece manejadores locales de subida de imágenes para Node.js, Flask y PHP, una integración autenticada con Rails y un flujo de trabajo con Postman para distinguir fallos de solicitud, validación y almacenamiento.
Introducción: ¿qué es una subida de archivos?
Un navegador envía los bytes del archivo seleccionado a un endpoint receptor. Con
multipart/form-data, cada archivo o campo de texto es una parte independiente con un nombre
de campo. El receptor analiza esas partes, aplica su política de aceptación y escribe los bytes
aceptados en el almacenamiento. Un nombre de archivo o tipo MIME proporcionado por el cliente es una
etiqueta, no una prueba de lo que contienen esos bytes.
Configura una función básica de subida de archivos
Elige un manejador y ejecuta solo uno a la vez en 127.0.0.1:3000. Este formulario HTML
envía un archivo con el nombre de campo myfile:
<form action="http://127.0.0.1:3000/upload" method="post" enctype="multipart/form-data">
<label for="myfile">Image</label>
<input id="myfile" 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 normales del formulario.
Los programas de Node, Flask y PHP que aparecen a continuación son demostraciones locales de manejadores de subida. Vincúlalos a la interfaz de loopback y mantén su almacenamiento fuera de cualquier raíz de archivos estáticos o documentos. No implementan inicio de sesión: antes de implementarlos en producción, antepón autenticación, autorización, protección CSRF, cuotas por usuario y límites de frecuencia. El fragmento de Rails se integra con una aplicación autenticada existente.
Usa Bash y versiones de Node.js y Corepack instaladas por separado; consulta la guía de instalación de Yarn. Las versiones probadas son Node.js 24.21.0 y Yarn 4.18.0; son versiones fijadas para reproducir el ejemplo, no versiones mínimas intrínsecas. Trabaja fuera de un proyecto JavaScript existente para que su configuración del gestor de paquetes no pueda cambiar la instalación. Pega este bloque en tu shell; su subshell conserva tu directorio de trabajo y las opciones del shell si se produce un fallo:
(
for tool in node corepack; do
command -v "$tool" >/dev/null || { printf 'Missing %s\n' "$tool" >&2; exit 1; }
done
[ -z "${YARN_RC_FILENAME:-}" ] || { printf 'Use the default Yarn configuration filename\n' >&2; exit 1; }
node --input-type=module -e '
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
let directory = process.cwd();
for (;;) {
if (["package.json", "yarn.lock", ".yarnrc.yml", ".pnp.cjs"].some(name => existsSync(join(directory, name)))) {
throw new Error("Choose a directory outside an existing JavaScript project");
}
const parent = dirname(directory);
if (parent === directory) break;
directory = parent;
}
' &&
mkdir upload-node && cd upload-node &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.18.0"}' > package.json &&
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml &&
corepack yarn add express@5.2.1 express-fileupload@1.5.2 file-type@22.1.0 &&
corepack yarn node --input-type=module -e "await import('express'); await import('express-fileupload'); await import('file-type')"
)
El bloque crea upload-node, pero deja el shell desde el que lo ejecutaste en su
directorio original. Si la instalación falla, conserva ese directorio y sus archivos. Tras resolver
la conectividad o el problema de instalación indicado, vuelve a intentar instalar la lista explícita
de dependencias; ejecutar solo yarn install puede tener éxito con un manifiesto
vacío después de un fallo temprano de resolución:
(
cd upload-node &&
corepack yarn add express@5.2.1 express-fileupload@1.5.2 file-type@22.1.0 &&
corepack yarn node --input-type=module -e "await import('express'); await import('express-fileupload'); await import('file-type')"
)
Guarda el archivo como upload-node/upload.ts. El analizador mantiene los datos de los archivos
en memoria, con hasta dos búferes de archivos por solicitud. El manejador exige una longitud
declarada del cuerpo, permite dos respuestas HTTP activas y genera las rutas de almacenamiento en
el servidor. Su contador HTTP libera el cupo al cerrarse la respuesta; no limita las escrituras
asíncronas que siguen finalizando después de que un cliente se desconecte.
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({
// Busboy signals its limit at the threshold; the extra byte keeps maxBytes inclusive.
limits: { fileSize: maxBytes + 1, 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', (error) => {
if (error) {
console.error('Upload server could not start')
process.exitCode = 1
}
})
server.requestTimeout = 15_000
server.headersTimeout = 10_000
Inicia el servidor desde el mismo shell en el que ejecutaste la configuración:
(
cd upload-node && corepack yarn node upload.ts
)
Deja esa terminal abierta y detén el servidor con Ctrl+C cuando termines. Guarda el formulario como
form.html y ábrelo en tu navegador, o sigue los pasos de Postman que aparecen más
abajo. Cada solicitud aceptada devuelve 201 y crea un nuevo directorio privado que contiene
image.jpg, image.png o image.gif; el nombre
de archivo enviado nunca se usa como ruta de almacenamiento.
express-fileupload proporciona el análisis multipart;
no es intrínsecamente más seguro que otro analizador con mantenimiento activo.
file-type identifica firmas, no la seguridad de los archivos.
Un PNG con bytes de 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 desde la web
con un nombre proporcionado por el cliente; la entrega pública segura de imágenes necesita una
política independiente de decodificación, recodificación y análisis de seguridad.
Manejo de subidas de archivos en distintos frameworks
Python (Flask)
El entorno de ejecución de Python probado es 3.12.3, con Flask 3.1.3 y Pillow 12.3.0. El módulo
venv de Python y pip deben estar instalados. En un directorio de trabajo
nuevo, usa un proyecto independiente y evita activar su venv en el shell desde el que lo ejecutas:
(
command -v python3 >/dev/null || { printf 'Missing python3\n' >&2; exit 1; }
mkdir upload-flask && cd upload-flask &&
python3 -m venv .venv &&
.venv/bin/python -m pip install Flask==3.1.3 Pillow==12.3.0
)
Guarda el archivo como upload-flask/app.py. El manejador completo crea almacenamiento
privado, verifica la imagen con un analizador real y usa únicamente una extensión elegida por el
servidor. El límite de la solicitud incluye la sobrecarga 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)
Inícialo desde el shell original y detenlo con Ctrl+C:
(
cd upload-flask && .venv/bin/python app.py
)
Si pip falló después de crear el venv, consérvalo y vuelve a intentarlo desde el shell original:
(
cd upload-flask && .venv/bin/python -m pip install Flask==3.1.3 Pillow==12.3.0
)
El servidor integrado es para desarrollo local. Usa el mismo formulario y campo
myfile que en Node.
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
Este ejemplo se probó con PHP 8.4.26 y su extensión FileInfo. Crea primero los directorios:
(
command -v php >/dev/null || { printf 'Missing php\n' >&2; exit 1; }
php -r 'exit(extension_loaded("fileinfo") ? 0 : 1);' &&
mkdir upload-php && cd upload-php &&
mkdir public && mkdir -m 700 private-uploads
)
Guarda el manejador como upload-php/public/upload.php. Inícialo desde el shell original y detenlo
con Ctrl+C:
(
cd upload-php &&
php -d display_errors=0 -d upload_max_filesize=5M -d post_max_size=6M -d max_file_uploads=2 -S 127.0.0.1:3000 -t public
)
Establece la acción del formulario o la URL de Postman en
http://127.0.0.1:3000/upload.php. PHP debe ser el propietario del directorio privado y poder escribir
allí.
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 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.';
}
Desactiva display_errors en una implementación por HTTP para que las advertencias del
sistema de archivos de PHP no puedan revelar rutas. FileInfo identifica el tipo de contenido; no es
un análisis 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 que
move_uploaded_file() sobrescriba
otros archivos subidos y aceptados.
Ruby on Rails
Los validadores attached, content_type y
size provienen de
active_storage_validations, no de Rails.
Este ejemplo usa Rails 8.1.3.1, el entorno de ejecución probado Ruby 3.3.10 y la versión 4.1.1 de esa
gem. Añádela al Gemfile de tu aplicación existente y ejecuta bundle install:
gem 'active_storage_validations', '4.1.1'
Conserva las dependencias resueltas de tu aplicación en Gemfile.lock.
Usa una aplicación autenticada existente que tenga Active Storage instalado. Configura un servicio
Disk privado o almacenamiento de objetos privado mediante la
guía de Active Storage.
Para un servicio Disk local, coloca su raíz fuera de public y asigna a ese
directorio el modo 0700. La opción de detección de suplantación de contenido que aparece abajo
necesita además el ejecutable UNIX file. Las herramientas auxiliares
probadas fueron file 5.46 y 5.48; su clasificación de PNG difiere, como se
describe en la tabla de aceptación más abajo. Este host usó Rack 3.2.7. Mantén la herramienta auxiliar
instalada por separado de las gems de Ruby.
# app/models/user.rb
class User < ApplicationRecord
has_one_attached :myfile
validates :myfile,
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 proporcione current_user, usa este
controlador. No selecciones un usuario a partir de params[:user_id]. Solo acepta una
nueva subida multipart, no un ID firmado de blob 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 new
render :new
end
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
user = current_user
if user.update(myfile: file)
render plain: 'File uploaded successfully.', status: :created
else
reasons = user.errors.details[:myfile].map { |detail| detail[:error].to_s }.uniq
Rails.logger.warn('Upload validation rejected: ' + reasons.join(', '))
render plain: 'File rejected.', status: :unprocessable_entity
end
end
private
def require_upload_user
head :unauthorized unless current_user
end
end
Añade estas rutas a config/routes.rb:
get '/upload', to: 'uploads#new'
post '/upload', to: 'uploads#create'
Guarda esta vista como app/views/uploads/new.html.erb. 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 %>
Inicia la aplicación existente desde el directorio de su proyecto:
bin/rails server -b 127.0.0.1 -p 3000
Inicia sesión mediante su procedimiento habitual y visita /upload. Detén el
servidor local con Ctrl+C.
current_user debe devolver el
User que ha iniciado sesión; la función mantiene los archivos adjuntos como
opcionales para que crear un usuario o editar su nombre siga funcionando.
Mantén privado el servicio de almacenamiento y configura controladores de descarga autenticados. Las URL firmadas predeterminadas de Active Storage no constituyen autorización por usuario. Impón un límite al cuerpo de la solicitud 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 proporcionar ese límite de recursos de red. Estos ejemplos no habilitan las subidas directas; los blobs no utilizados de otros flujos necesitan una política de retención independiente.
Problemas comunes de subida de archivos y soluciones
¿Por qué no se sube mi archivo?
Empieza por la respuesta y luego comprueba qué etapa rechazó la solicitud. Todos estos manejadores locales requieren un archivo no vacío de como máximo 5 MiB (5.242.880 bytes), incluidos exactamente 5 MiB. Node, Flask y PHP también limitan el cuerpo declarado de la solicitud a 6 MiB, incluida la sobrecarga multipart. Rails necesita que tu servidor web existente imponga ese límite del cuerpo antes de la validación del modelo.
| Síntoma | Qué comprobar en estos ejemplos |
|---|---|
| 404 o 405 | Confirma la URL y el método POST. PHP recibe /upload.php; los demás ejemplos reciben /upload. |
| 400 | Comprueba la codificación multipart, el campo esperado y la política de reconocimiento de contenido que aparece abajo. Flask también devuelve 400 si se supera su límite de píxeles o falla la verificación de la imagen. El analizador de Node puede dejar la solicitud sin archivo tras agotarse el tiempo de espera por inactividad de la subida, lo que produce el mismo 400 que un campo ausente. |
| 411 en Node | Envía una longitud conocida del cuerpo. Esta demostración rechaza las solicitudes de streaming con transferencia por fragmentos. |
| 413 | Comprueba los límites de bytes del archivo y de la solicitud. Node, Flask y PHP también usan 413 para un archivo vacío. El analizador de Flask puede devolver 413 si hay demasiadas partes o se alcanza su límite de memoria del formulario. |
| 401 en Rails | Inicia sesión mediante la aplicación anfitriona. Una subida no puede elegir un usuario por su ID. |
| 422 en Rails | Proporciona el token CSRF de la sesión y el campo de archivo anidado user[myfile]. Una solicitud con CSRF válido aún puede fallar en la validación de contenido o tamaño, incluido el caso de un archivo vacío. |
| 500 | Comprueba los permisos de almacenamiento. El host de Rails probado también devolvió 500 al alcanzar los límites de archivos o partes de Rack, antes de que se ejecutara el manejador. |
| 503 en Node | Ya hay dos respuestas HTTP activas. Espera a que terminen y vuelve a intentarlo. |
Un PNG pequeño puede superar un límite de píxeles sin superar el límite de bytes. Flask rechaza
imágenes de más de 20.000.000 píxeles; una imagen con exactamente esa cantidad supera su comprobación
de dimensiones. Su llamada a verify() comprueba la estructura del archivo sin
decodificar completamente los píxeles. La búsqueda de firmas de Node, FileInfo de PHP y la
comprobación de suplantación de MIME de Rails tampoco establecen que todas las imágenes puedan
decodificarse, que se eliminen los metadatos o que un archivo esté libre de malware.
Los siguientes límites de reconocimiento se observaron con las versiones fijadas de las dependencias que se muestran. «Aceptado» significa almacenado como bytes originales privados, no aprobado para entrega pública:
| Entrada | Node | Flask | FileInfo de PHP 8.4.26 | Rails con file 5.46 / 5.48 |
|---|---|---|---|---|
| PNG normal, JPEG con metadatos, GIF | Aceptado | Aceptado | Aceptado | Aceptado / aceptado |
| JPEG válido con múltiples imágenes MPF/MPO | Aceptado como JPEG | 400: Pillow informa MPO, fuera de la lista de formatos devueltos permitidos | Aceptado como JPEG | Aceptado / aceptado |
| APNG compacto | 400: reconocido como APNG | Aceptado como PNG | Aceptado como PNG | Aceptado / 422: discrepancia de MIME |
| APNG con 12 KB de metadatos antes de su marcador de animación | Aceptado como PNG | Aceptado como PNG | Aceptado como PNG | Aceptado / 422: discrepancia de MIME |
| APNG con 40 KB o 90 KB antes de ese marcador | Aceptado como PNG | Aceptado como PNG | Aceptado como PNG | Aceptado / aceptado |
Node solo toma muestras de los primeros 4.100 bytes. En file 5.48, la
regla de reconocimiento por firma de PNG
busca el marcador de animación en 16.384 bytes a partir del desplazamiento 33. Mover un marcador
fuera de una ventana de búsqueda cambia el reconocimiento sin cambiar si la imagen es animada.
Estos manejadores no imponen una prohibición general de animaciones. El rechazo de MPO por Flask
es también un límite de su política, no una prueba de que el JPEG esté dañado; consulta el
soporte de Pillow para MPO.
La cardinalidad multipart tiene otra trampa: los campos analizados no equivalen a un recuento de
todas las partes enviadas. Node examina como máximo dos partes de archivo y tres partes en total,
descarta los campos de texto y rechaza la presencia de varios archivos visibles en su mapa
analizado. Un archivo ajeno que esté más allá del límite de partes del analizador puede omitirse
mientras el myfile visible se almacena con 201. Flask permite dos partes en
total, cuenta también las partes de texto y devuelve 413 por encima de ese límite; los archivos
duplicados visibles devuelven 400. Su límite predeterminado de memoria del formulario sigue siendo
500.000 bytes, independiente de los límites del archivo y del cuerpo.
El max_file_uploads=2 de PHP que se muestra procesa hasta dos partes de archivo. Las
partes escalares duplicadas de myfile se reducen a la última procesada, y las
partes de archivo posteriores pueden ignorarse. De forma similar, Rails toma el último
user[myfile] analizado e ignora los demás campos excluidos de la entrada permitida
por el controlador. En el host de Rack probado, se aceptaron 127 partes de archivo y 4.095 partes
en total; alcanzar 128 partes de archivo o 4.096 partes en total provocó una excepción del
analizador. Estas políticas que limitan los campos analizados no garantizan el rechazo de cada
duplicado o archivo adicional presente en la solicitud sin analizar. Envía un archivo y ningún
campo innecesario para esta tarea.
Soluciones
Usa primero una imagen conocida que pueda decodificarse y luego cambia una entrada a la vez: el nombre del campo, el tamaño del cuerpo, el contenido o los permisos del destino. Node tiene además un temporizador de inactividad de subida de diez segundos y un tiempo de espera configurado para las solicitudes HTTP de 15 segundos. Una solicitud lenta puede fallar sin superar el límite de tamaño; esos ajustes no garantizan un plazo exacto de respuesta medido por el reloj.
Para los fallos de validación de Rails, el controlador que se muestra registra únicamente códigos
de motivo de
errors.details,
con el prefijo «Upload validation rejected». Consulta ese registro después de un 422 para distinguir
los rechazos por tipo de contenido, suplantación y tamaño. No registres los bytes enviados, nombres
de archivo, cookies de sesión ni tokens.
Inspecciona el estado de Rails antes de volver a intentar una operación que falló por un error de almacenamiento. Un rechazo de validación conserva la referencia al archivo adjunto anterior y sus bytes. Un fallo de escritura posterior es diferente: Active Storage guarda la referencia antes de su subida posterior al commit. En el servicio Disk probado con el adaptador de tareas inline, un fallo de escritura forzado devolvió 500 con una nueva referencia confirmada que apuntaba a un archivo inexistente. El blob anterior y sus bytes se conservaron, pero ya no eran el archivo adjunto actual del usuario. Lee el usuario mediante una consulta nueva y comprueba el archivo real o la descarga de ese blob; luego inspecciona el blob anterior y tu cola de tareas. Un 500 no demuestra que se haya revertido la operación; resuelve la causa del fallo de almacenamiento y reconcilia la referencia antes de otra actualización.
Prueba las API de subida de archivos con Postman
Usa primero la demostración de Node o Flask en ejecución. Estos pasos se probaron en el cliente de
escritorio para Linux de Postman 12.31.7; PHP usa el mismo campo con su URL
/upload.php. Para Rails, usa el formulario generado por la aplicación, a menos
que también proporciones su sesión autenticada y su token CSRF.
- Crea una solicitud POST a
http://127.0.0.1:3000/upload. - Abre Body y selecciona form-data.
- Añade
myfile, elige File como tipo y selecciona una imagen local con el selector de archivos del sistema operativo. - Deja que Postman establezca el encabezado Content-Type multipart para que incluya el delimitador correspondiente.
- Haz clic en Send. Espera un 201 y un nuevo archivo almacenado; luego compara sus bytes con la imagen que seleccionaste.
- Cambia únicamente el campo a
wrongfieldy vuelve a enviar la solicitud. Node, Flask y PHP devuelven 400 sin añadir un archivo almacenado.
Para comprobarlo desde la línea de comandos, coloca el PNG válido que elegiste en
sample.png, dentro del directorio del shell desde el que ejecutas los comandos.
Usa cURL con soporte para --fail-with-body; este comando se probó con cURL 8.5.0:
curl --fail-with-body --silent --show-error -F "myfile=@./sample.png" http://127.0.0.1:3000/upload
Una respuesta exitosa indica que el manejador completó su escritura. Inspecciona también
private-uploads: compara todos los bytes almacenados y abre la imagen almacenada con
un decodificador independiente. Una respuesta por sí sola no puede indicarte que inspeccionaste la
salida correcta. Las demostraciones conservan los originales aceptados; detén el servidor y elimina
únicamente tus propias subidas de prueba cuando termines.
Buenas prácticas de seguridad para la subida de archivos
Mantén los originales fuera de las raíces de archivos estáticos o documentos. Los ejemplos de Node
y PHP usan un nuevo subdirectorio privado con nombre generado por el servidor para cada subida;
Flask usa la creación exclusiva de un archivo aleatorio con nombre generado por el servidor.
Las solicitudes aceptadas repetidas conservan salidas independientes. Ninguno de los ejemplos usa
el nombre de archivo enviado como ruta de almacenamiento, por lo que cambiar el nombre de una foto
a photo.php no crea un script PHP con ese nombre.
El reconocimiento, la decodificación, el análisis de malware y la publicación son decisiones diferentes. Un encabezado reconocible puede conservarse aunque haya daños en una parte posterior de la imagen; los ejemplos de Node y PHP pueden almacenar un archivo así. Incluso la verificación de Pillow no es una decodificación completa. Antes de la entrega pública, usa una etapa de procesamiento aislada que decodifique completamente el contenido compatible, aplique límites de recursos, produzca la salida prevista y siga tu política de análisis de seguridad. Mantén el original privado hasta que esos pasos se completen. Estos manejadores locales no implementan ningún analizador de seguridad ni etapa de publicación.
Técnicas avanzadas de subida de archivos
Si tu problema real es reanudar transferencias interrumpidas o mostrar el progreso de subida, continúa con optimiza la subida de archivos en aplicaciones web. Esa guía cubre un contrato diferente entre el cliente y la puerta de enlace; no ofrece un cliente que puedas usar sin cambios con estos endpoints multipart. Para una integración de Laravel y Vue basada en sesiones, usa la guía de subida de archivos con Laravel.
Conclusión y recursos adicionales
Ante una subida multipart fallida, conserva una solicitud mínima exitosa y compara con ella una sola condición modificada. La guía de subida de archivos con Flask, la documentación de subida de archivos de PHP y la guía de Active Storage explican los contratos nativos en los que se basan estos ejemplos.
