Subidas AJAX seguras de archivos con sesiones y controles CSRF
Una subida AJAX solo se acepta cuando el servidor ha comprobado la sesión, los permisos, la solicitud y el contenido del archivo. Esta guía incluye un formulario para el navegador y un servidor Node.js ejecutables que aplican esas comprobaciones, informan del progreso de la subida y permiten que cada usuario descargue únicamente sus propios archivos aceptados.
Define qué acepta el servidor
El ejemplo sube notas adjuntas en JSON, no imágenes ni documentos arbitrarios. Cada archivo debe
contener un objeto JSON en UTF-8 con exactamente una propiedad, message,
cuyo valor sea una cadena que no esté vacía ni contenga solo espacios en blanco.
Guarda lo siguiente como note.json para probar el formulario:
{"message":"Hello from an AJAX upload."}
El servidor acepta exactamente un campo multipart llamado file, sin campos
adicionales, y un archivo de como máximo 64 KiB. También limita todo el cuerpo multipart a 80 KiB,
incluidos los delimitadores y los encabezados. Ambos límites se aplican a los bytes recibidos.
Cambiar el nombre de un PNG a note.json no lo convierte en JSON válido.
En cambio, sí se acepta el contenido de una nota válida con el nombre
note.png o enviado con un tipo MIME incorrecto: este ejemplo ignora
intencionalmente ambos metadatos del cliente y descarga cada archivo aceptado como
note.json con application/json.
Esta es una política de contenido para una aplicación específica. Analizar JSON no demuestra que un archivo esté libre de malware, y una cadena que contiene HTML sigue siendo un dato no confiable. El ejemplo nunca renderiza esa cadena. Para admitir más tipos de archivo, elige reglas de validación y procesamiento independientes; una lista de tipos MIME permitidos o una firma de archivo por sí solas no bastan. Consulta las recomendaciones de OWASP sobre subidas.
Configura la demo local
Usa Node.js 26 y un navegador actual. El siguiente ejemplo se probó en Linux con Node.js 26.8.1 y Chromium 152. No depende de ningún paquete. Crea un directorio nuevo desde una shell POSIX:
mkdir ajax-upload-demo && cd ajax-upload-demo
Si ese comando falla, detente y elige un directorio nuevo; no sobrescribas un proyecto existente.
Guarda los siguientes tres archivos dentro de él: server.ts,
index.html y client.js.
El servidor escucha únicamente en 127.0.0.1, en un puerto disponible, e imprime
esa URL junto con contraseñas nuevas para alice y
bob. Son identidades locales desechables. Cualquier persona con la
contraseña impresa puede actuar como ese usuario. Cada inicio de sesión reemplaza la sesión
anterior de ese usuario, y las sesiones caducan después de 15 minutos. Los archivos subidos
permanecen en memoria hasta que finaliza el proceso, con un máximo de diez archivos por usuario.
Las subidas repetidas crean entradas independientes; nunca reemplazan un archivo adjunto anterior.
Aplica las reglas en el servidor
Guarda lo siguiente como server.ts. Los únicos archivos públicos son los dos
archivos del cliente que se sirven explícitamente. La autenticación y el token CSRF de la sesión
se comprueban antes de leer el cuerpo de una subida. Las descargas buscan el ID en el mapa propio
del usuario autenticado, por lo que otro usuario recibe el mismo 404
que recibiría para un ID desconocido.
import { randomBytes, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface DemoUser {
name: string
password: string
session: string
csrf: string
expires: number
files: Map<string, Buffer>
}
const token = (): string => randomBytes(32).toString('hex')
const users: DemoUser[] = ['alice', 'bob'].map((name) => ({
name, password: token(), session: '', csrf: '', expires: 0, files: new Map(),
}))
const html = await readFile(new URL('./index.html', import.meta.url))
const script = await readFile(new URL('./client.js', import.meta.url))
let origin = ''
let cookieName = ''
class Rejection extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function readBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
let size = 0
// Keep the socket writable so an oversized request can receive a 413 response.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > 80 * 1024) throw new Rejection(413, 'Request exceeds 80 KiB.')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
async function noteBytes(req: IncomingMessage): Promise<Buffer> {
const body = await readBody(req)
const contentType = req.headers['content-type'] ?? ''
if (!/^multipart\/form-data\s*;/i.test(contentType)) {
throw new Rejection(415, 'Use multipart/form-data.')
}
let form: FormData
try {
form = await new Response(new Uint8Array(body), {
headers: { 'Content-Type': contentType },
}).formData()
} catch {
throw new Rejection(400, 'Malformed multipart body.')
}
const entries = [...form.entries()]
const file = form.get('file')
if (entries.length !== 1 || !(file instanceof File)) {
throw new Rejection(400, 'Send exactly one file field and no other fields.')
}
if (file.size > 64 * 1024) throw new Rejection(413, 'File exceeds 64 KiB.')
const bytes = Buffer.from(await file.arrayBuffer())
let value: unknown
try {
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
} catch {
throw new Rejection(422, 'File must contain a UTF-8 JSON note.')
}
if (
typeof value !== 'object' || value === null || Array.isArray(value) ||
Object.keys(value).length !== 1 || !('message' in value) ||
typeof value.message !== 'string' || value.message.trim().length === 0
) {
throw new Rejection(422, 'Use an object with one nonblank message string.')
}
return bytes
}
function sessionData(user: DemoUser): unknown {
return {
user: user.name,
csrf: user.csrf,
files: [...user.files].map(([id, bytes]) => ({ id, bytes: bytes.length })),
}
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'GET' && (req.url === '/' || req.url === '/client.js')) {
res.setHeader('Content-Type', req.url === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(req.url === '/' ? html : script)
return
}
if (req.method === 'POST' && req.headers.origin !== origin) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'POST' && req.url?.startsWith('/login/')) {
const user = users.find((entry) => `/login/${entry.name}` === req.url)
if (!user || req.headers['x-demo-password'] !== user.password) {
throw new Rejection(401, 'Invalid demo credentials.')
}
user.session = token()
user.csrf = token()
user.expires = Date.now() + 15 * 60 * 1000
res.setHeader('Set-Cookie',
`${cookieName}=${user.session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=900`)
json(res, 200, sessionData(user))
return
}
const session = req.headers.cookie?.split(';').map((part) => part.trim())
.find((part) => part.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1)
const user = users.find((entry) => entry.session === session && entry.expires > Date.now())
if (!user) throw new Rejection(401, 'Log in again.')
if (req.method === 'GET' && req.url === '/session') {
json(res, 200, sessionData(user))
return
}
if (req.method === 'GET' && req.url?.startsWith('/files/')) {
const bytes = user.files.get(req.url.slice('/files/'.length))
if (!bytes) throw new Rejection(404, 'File not found.')
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Disposition': 'attachment; filename="note.json"',
})
res.end(bytes)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
throw new Rejection(404, 'Route not found.')
}
if (req.headers['x-csrf-token'] !== user.csrf) {
throw new Rejection(403, 'Refresh your session before uploading.')
}
const bytes = await noteBytes(req)
// A second login or session expiry during transfer must invalidate this request too.
if (user.session !== session || user.expires <= Date.now()) {
throw new Rejection(401, 'Log in again.')
}
if (user.files.size >= 10) throw new Rejection(409, 'Demo storage is full. Restart to clear it.')
const id = randomUUID()
user.files.set(id, bytes)
json(res, 201, { id, bytes: bytes.length })
}
const server = createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, (req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Content-Security-Policy',
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
void handle(req, res).catch((error: unknown) => {
const status = error instanceof Rejection ? error.status : 500
const message = error instanceof Rejection ? error.message : 'Unable to handle the request.'
if (status === 500) console.error('Request failed unexpectedly.')
res.setHeader('Connection', 'close')
json(res, status, { error: message })
req.resume()
})
})
server.maxConnections = 16
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
cookieName = `ajax_demo_${address.port}`
console.log(`Open ${origin}`)
for (const user of users) console.log(`${user.name} password: ${user.password}`)
})
El límite del cuerpo se aplica antes de que el analizador multipart de Node almacene los campos
individuales en un búfer. La opción destroyOnReturn: false
permite enviar la respuesta HTTP de un rechazo anticipado por tamaño antes de cerrar la conexión.
No se inserta nada en el almacenamiento hasta que todas las validaciones se completan correctamente;
los cuerpos rechazados no dejan ninguna entrada conservada ni ningún archivo en disco.
El token devuelto por /session es independiente de la cookie de sesión HttpOnly.
El navegador lo envía en X-CSRF-Token, y el servidor lo compara con el token de esa
sesión. Una comprobación exacta de Origin también protege las solicitudes POST,
incluido el inicio de sesión. No se concede acceso CORS. Estas decisiones siguen el
patrón de token sincronizador;
SameSite añade otra capa, pero no reemplaza la comprobación del token.
Añade el formulario del navegador
Guarda lo siguiente como index.html. Usa el selector de archivos nativo y botones
de envío para que el formulario funcione con un teclado. Este ejemplo selecciona intencionalmente
un archivo a la vez; si añades la función de arrastrar y soltar o una cola de procesamiento por
lotes, debes conservar el mismo contrato del servidor.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AJAX note upload</title>
<script type="module" src="/client.js"></script>
</head>
<body>
<h1>Upload a private JSON note</h1>
<p>Files live in server memory until restart. Select one JSON note, up to 64 KiB.</p>
<fieldset id="controls">
<legend>Demo session and upload</legend>
<form id="login">
<label for="user">Demo user</label>
<select id="user"><option>alice</option><option>bob</option></select>
<label for="password">Password from the server terminal</label>
<input id="password" type="password" autocomplete="current-password" required />
<button>Log in</button>
</form>
<form id="upload">
<label for="file">JSON note</label>
<input id="file" type="file" accept=".json,application/json" required />
<button>Upload</button>
</form>
<button id="refresh" type="button">Refresh accepted files</button>
</fieldset>
<p id="identity">Not logged in.</p>
<label id="progress-label" for="progress">Request bytes transferred</label>
<progress id="progress" aria-labelledby="progress-label" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite"></p>
<h2>Your accepted files</h2>
<ul id="files"></ul>
</body>
</html>
Envía el archivo y espera su aceptación
Guarda lo siguiente como client.js. Fetch gestiona las solicitudes de sesión;
XMLHttpRequest gestiona la subida porque expone el
progreso de subida de la solicitud.
Una barra de progreso completa significa que se envió el cuerpo de la solicitud, no que el servidor
aceptó el archivo. Solo una respuesta HTTP 201 de
/upload crea un enlace de descarga.
const controls = document.getElementById('controls')
const login = document.getElementById('login')
const upload = document.getElementById('upload')
const user = document.getElementById('user')
const password = document.getElementById('password')
const fileInput = document.getElementById('file')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const identity = document.getElementById('identity')
const files = document.getElementById('files')
let csrf = ''
let busy = false
function failure(code) {
const messages = {
400: 'Send exactly one file and no extra fields.',
401: 'Log in with the password from the server terminal.',
403: 'Session check failed. Refresh accepted files or log in again.',
409: 'Demo storage is full. Restart the server to clear it.',
413: 'Upload exceeds a size limit. Choose a smaller file.',
415: 'The server requires multipart form data.',
422: 'Choose a UTF-8 JSON object with one nonblank message string.',
}
return new Error(messages[code] ?? 'Acceptance is unconfirmed. Refresh accepted files before retrying.')
}
async function run(action) {
if (busy) return
busy = true
controls.disabled = true
try {
await action()
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Request failed.'
} finally {
busy = false
controls.disabled = false
}
}
function addFile(file) {
const item = document.createElement('li')
const link = document.createElement('a')
link.href = `/files/${encodeURIComponent(file.id)}`
link.textContent = `Download ${file.id} (${file.bytes.toLocaleString()} bytes)`
item.append(link)
files.append(item)
}
async function loadSession(response) {
if (!response.ok) {
if (response.status === 401) {
csrf = ''
identity.textContent = 'Not logged in.'
files.replaceChildren()
}
throw failure(response.status)
}
const data = await response.json()
csrf = data.csrf
identity.textContent = `Logged in as ${data.user}.`
files.replaceChildren()
for (const file of data.files) addFile(file)
}
login.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const response = await fetch(`/login/${encodeURIComponent(user.value)}`, {
method: 'POST',
headers: { 'X-Demo-Password': password.value },
credentials: 'same-origin',
})
password.value = ''
await loadSession(response)
status.textContent = 'Logged in. Choose a note to upload.'
})
})
function sendFile(file) {
return new Promise((resolve, reject) => {
const body = new FormData()
body.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress.value = event.loaded / event.total * 100
}
xhr.upload.onload = () => { status.textContent = 'Transferred. Waiting for server acceptance…' }
xhr.open('POST', '/upload')
xhr.setRequestHeader('X-CSRF-Token', csrf)
xhr.responseType = 'json'
xhr.timeout = 45_000
xhr.onload = () => {
if (xhr.status === 201 && typeof xhr.response?.id === 'string') resolve(xhr.response)
else reject(failure(xhr.status))
}
xhr.onerror = xhr.ontimeout = xhr.onabort = () => reject(failure(0))
xhr.send(body)
})
}
upload.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const file = fileInput.files[0]
if (!csrf) throw failure(401)
if (!file) throw new Error('Choose a file first.')
if (file.size > 64 * 1024) throw failure(413)
progress.value = 0
status.textContent = 'Uploading…'
const accepted = await sendFile(file)
addFile(accepted)
status.textContent = 'Accepted into private memory. Use the download link to check the bytes.'
})
})
async function refresh() {
await loadSession(await fetch('/session', { credentials: 'same-origin' }))
status.textContent = 'Accepted file list refreshed.'
}
document.getElementById('refresh').addEventListener('click', () => { void run(refresh) })
void run(refresh)
Deja que el navegador establezca Content-Type al enviar
FormData: debe incluir el delimitador multipart generado.
MDN explica por qué establecerlo manualmente hace que la solicitud falle.
El valor accept del selector y la comprobación de tamaño del cliente ofrecen
información temprana; ninguno de los dos es un control de seguridad del servidor. Mientras haya
alguna solicitud pendiente, el conjunto de campos se deshabilita y una protección
busy ignora los envíos repetidos. El valor
File seleccionado se captura antes de que comience la subida.
Prueba la aceptación y el rechazo
Desde el directorio que contiene los tres archivos, inicia el servidor:
node server.ts
Abre la URL exacta que se imprimió, inicia sesión como alice, selecciona
note.json y activa Upload. Cuando aparezca «Accepted into private memory»,
usa el enlace de descarga. Este devuelve los bytes originales, incluidos los espacios en blanco,
como archivo adjunto. Tu navegador decide si te pide un destino o elige un nombre nuevo cuando
note.json ya existe; hacer clic en el enlace no confirma por sí solo que se
haya guardado un archivo.
Prueba con un archivo que contenga {"message":42}: la barra puede llenarse, pero el
servidor devuelve 422, la página explica el contenido requerido y no se
añade ninguna entrada a la lista de archivos aceptados. En otra ventana privada del navegador,
inicia sesión como bob y abre la URL de descarga de Alice. Devuelve
404. Sin una sesión, devuelve 401. Los errores
de aplicación del servidor contienen mensajes fijos en lugar de detalles del analizador, rutas o
contenido enviado.
| Respuesta | Significado y siguiente acción |
|---|---|
201 | Archivo aceptado y conservado en este proceso; disponible para su propietario. |
400 / 415 | Corrige la solicitud multipart o sus campos. |
401 / 403 | Restablece la sesión o la prueba CSRF antes de otra subida. |
413 | Se superó un límite fijo de tamaño. Elige un archivo más pequeño. |
422 | Corrige el contenido del archivo. |
409 | Ya hay diez archivos almacenados para este usuario. Reiniciar borra todos los datos de la demo. |
| Error de red, tiempo de espera agotado u otro estado | La aceptación no está confirmada. Actualiza la lista de archivos aceptados antes de decidir qué hacer. |
No hay reintentos automáticos, ni siquiera ante 413 o una respuesta
perdida. Si el servidor almacenó el archivo pero se perdió la respuesta, al actualizar la lista
aparece la nueva entrada; descárgala para identificar sus bytes. Volver a subirlo manualmente crea
un segundo ID. Los reintentos en producción requieren un contrato de deduplicación persistente,
limitado a cada usuario, antes de poder repetir una subida de forma segura. Este servidor no indica
fallos transitorios ni Retry-After.
Conecta el ejemplo con tu aplicación
Detén la demo con Ctrl+C; los archivos aceptados, las contraseñas y las sesiones se descartan. Para
el despliegue, reemplaza las identidades locales y los mapas en memoria por la autenticación, la
autorización, el middleware CSRF y el almacenamiento privado duradero de tu aplicación. Usa HTTPS y
una cookie de sesión Secure. La cookie HTTP de la demo se limita a este
ejercicio en la interfaz de bucle local;
los atributos de las cookies tienen funciones distintas.
Establece límites de frecuencia y concurrencia específicos para el despliegue, además de cuotas de
almacenamiento. Los límites de tamaño de solicitud y de conexiones de la demo no reemplazan esos
controles.
Los archivos más grandes necesitan un analizador y una ruta de almacenamiento que procesen los datos a medida que llegan, en lugar de este analizador acotado en memoria. Si necesitas reanudar las subidas, usa un servidor del protocolo tus y un cliente compatible. Dividir un archivo en fragmentos no implementa por sí solo la autorización, la reconstrucción, la limpieza ni la repetición segura de solicitudes; este ejemplo no incluye endpoints para fragmentos de forma intencional.
