Exportar archivos a servidores SFTP desde navegadores web
Un navegador puede subir archivos a una pasarela HTTPS, que luego los escribe en un servidor SFTP configurado. Este tutorial implementa ese flujo con almacenamiento temporal limitado, claves de host SSH verificadas y solicitudes de subida autenticadas y autorizadas.
Introducción
Este ejemplo exporta archivos opacos de hasta 5 MiB. No identifica documentos seguros, no analiza malware ni hace que el contenido subido sea seguro para su ejecución. Los archivos reciben nombres generados por el servidor y permanecen en una bandeja de entrada SFTP privada. La pasarela no acepta ningún nombre de host remoto, directorio, nombre de usuario ni nombre de archivo del navegador.
Comprende el desafío
Las páginas web estándar no pueden abrir conexiones TCP directas a servidores SSH. SFTP funciona sobre SSH; la pasarela termina la conexión HTTPS y crea una conexión SSH independiente. La autenticación del navegador y la verificación de la clave de host SSH protegen límites distintos, y ambas son obligatorias.
Usa Node.js 24 y los paquetes con versiones fijadas que aparecen a continuación. Este tutorial
presupone que un proveedor de identidad emite tokens de acceso RS256 de corta duración para esta
pasarela, con sub, iat, exp y un
ámbito sftp:upload asignado solo a quienes tienen autorización para subir archivos.
Cada persona usa su propio token. No distribuyas un secreto de aplicación compartido ni una
credencial SSH a los navegadores.
Configura el frontend
Guarda lo siguiente como public/index.html. Obtén el token de acceso mediante el flujo
de inicio de sesión existente de tu aplicación; el campo de contraseña permite introducir
manualmente un token individual de corta duración para hacer pruebas.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SFTP file export</title>
</head>
<body>
<h1>Export a file</h1>
<form id="uploadForm">
<label>Your access token <input id="token" type="password" autocomplete="off" required /></label>
<label>File (up to 5 MiB) <input type="file" id="fileInput" required /></label>
<button type="submit">Export to SFTP</button>
</form>
<p id="status" role="status"></p>
<script src="/upload.js" defer></script>
</body>
</html>
Guarda lo siguiente como public/upload.js. Envía el archivo en sí, sin análisis
multipart ni un destino elegido por el cliente. El navegador proporciona
Content-Length para un cuerpo de tipo File.
const form = document.getElementById('uploadForm')
const token = document.getElementById('token')
const input = document.getElementById('fileInput')
const status = document.getElementById('status')
const button = form.querySelector('button')
form.addEventListener('submit', async (event) => {
event.preventDefault()
const file = input.files[0]
if (!file || file.size < 1 || file.size > 5 * 1024 * 1024) {
status.textContent = 'Choose a nonempty file of up to 5 MiB.'
return
}
button.disabled = true
status.textContent = 'Exporting…'
const accessToken = token.value
token.value = ''
try {
const response = await fetch('/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/octet-stream',
},
body: file,
signal: AbortSignal.timeout(35_000),
})
if (!response.ok) throw new Error('Export rejected')
const result = await response.json()
status.textContent = 'File exported. Receipt: ' + result.id
} catch {
status.textContent = 'Export could not be confirmed. Check your inbox before retrying.'
} finally {
button.disabled = false
}
})
Crea la API del backend con Node.js
mkdir sftp-upload
cd sftp-upload
yarn init -2
yarn add express@5.2.1 helmet@8.3.0 jose@6.2.12 ssh2-sftp-client@12.1.1
mkdir public
Establece "type": "module" en package.json.
Guarda el servidor como index.js.
La API de ssh2-sftp-client
expone createWriteStream(), realPath(), rename() y delete().
Las opciones de conexión de ssh2 que utiliza internamente incluyen
sock, hostHash y hostVerifier.
import { randomUUID } from 'node:crypto'
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { createConnection } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
import express from 'express'
import helmet from 'helmet'
import { importSPKI, jwtVerify } from 'jose'
import SftpClient from 'ssh2-sftp-client'
const {
APP_ORIGIN, JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE,
SFTP_HOST, SFTP_USERNAME, SFTP_PRIVATE_KEY, SFTP_HOST_SHA256,
} = process.env
if (!APP_ORIGIN || !JWT_PUBLIC_KEY || !JWT_ISSUER || !JWT_AUDIENCE ||
!SFTP_HOST || !SFTP_USERNAME || !SFTP_PRIVATE_KEY ||
!/^[a-f0-9]{64}$/.test(SFTP_HOST_SHA256 ?? '')) {
throw new Error('Gateway configuration is incomplete')
}
const sshPort = Number(process.env.SFTP_PORT ?? 22)
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) {
throw new Error('Invalid SFTP port')
}
const key = await importSPKI(JWT_PUBLIC_KEY, 'RS256')
const webOrigin = new URL(APP_ORIGIN)
const localHttp = webOrigin.protocol === 'http:' &&
['localhost', '127.0.0.1', '[::1]'].includes(webOrigin.hostname)
if (webOrigin.origin !== APP_ORIGIN || (!localHttp && webOrigin.protocol !== 'https:')) {
throw new Error('Use an HTTPS origin or loopback HTTP for local development')
}
const remoteRoot = '/incoming'
const maxBytes = 5 * 1024 * 1024
let active = 0
export const app = express()
app.disable('x-powered-by')
app.use(helmet({
contentSecurityPolicy: {
// Keep local Safari from upgrading this demo's HTTP assets to HTTPS.
directives: { 'upgrade-insecure-requests': localHttp ? null : [] },
},
strictTransportSecurity: localHttp ? false : undefined,
}))
app.use((_req, res, next) => {
res.locals.requestId = randomUUID()
res.set('X-Request-ID', res.locals.requestId)
res.set('Cache-Control', 'no-store')
next()
})
app.post('/upload', async (req, res) => {
const fail = (status, error) => {
if (!res.destroyed && !res.headersSent) {
res.status(status).json({ error, requestId: res.locals.requestId })
}
}
if (req.get('origin') !== APP_ORIGIN || req.originalUrl !== '/upload') {
return fail(403, 'Request not allowed')
}
let claims
try {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.get('authorization') ?? '')
if (!match) return fail(401, 'Authentication required')
const verified = await jwtVerify(match[1], key, {
algorithms: ['RS256'], issuer: JWT_ISSUER, audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'], maxTokenAge: '15m',
})
claims = verified.payload
} catch {
return fail(401, 'Invalid access token')
}
if (typeof claims.scope !== 'string' || !claims.scope.split(' ').includes('sftp:upload')) {
return fail(403, 'Permission denied')
}
const length = req.get('content-length') ?? ''
if (!/^[1-9][0-9]*$/.test(length)) return fail(411, 'Content length required')
const expected = Number(length)
if (!Number.isSafeInteger(expected) || expected > maxBytes) return fail(413, 'File too large')
if (req.get('content-type') !== 'application/octet-stream') {
return fail(415, 'Send an octet-stream file')
}
if (active >= 2) return fail(503, 'Gateway busy')
active += 1
const id = randomUUID()
const remotePart = remoteRoot + '/' + id + '.part'
const remoteFinal = remoteRoot + '/' + id + '.bin'
const abort = new AbortController()
const timer = setTimeout(() => abort.abort(), 30_000)
const disconnect = () => { if (!res.writableFinished) abort.abort() }
req.once('aborted', disconnect)
res.once('close', disconnect)
let directory
let socket
let connected = false
let pending = false
const sftp = new SftpClient('gateway', {
error: () => abort.abort(),
end: () => abort.abort(),
close: () => abort.abort(),
})
const cancelSocket = () => socket?.destroy()
abort.signal.addEventListener('abort', cancelSocket)
try {
directory = await mkdtemp(join(tmpdir(), 'sftp-upload-'))
const localPath = join(directory, 'payload')
let received = 0
const limit = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length
callback(received > expected ? new Error('Byte limit exceeded') : null, chunk)
},
flush(callback) {
callback(received !== expected ? new Error('Incomplete upload') : null)
},
})
await pipeline(req, limit, createWriteStream(localPath, { flags: 'wx', mode: 0o600 }), {
signal: abort.signal,
})
abort.signal.throwIfAborted()
socket = createConnection({ host: SFTP_HOST, port: sshPort })
// ssh2 observes socket failures; this also covers the handoff before its listeners attach.
socket.on('error', () => {})
await sftp.connect({
sock: socket,
username: SFTP_USERNAME,
privateKey: SFTP_PRIVATE_KEY,
hostHash: 'sha256',
hostVerifier: (hash) => hash === SFTP_HOST_SHA256,
readyTimeout: 10_000,
})
connected = true
abort.signal.throwIfAborted()
if (await sftp.realPath(remoteRoot) !== remoteRoot) throw new Error('Unexpected inbox')
pending = true
// The private inbox has no other writers; exclusive creation refuses an existing .part file.
await pipeline(
createReadStream(localPath),
sftp.createWriteStream(remotePart, { flags: 'wx', mode: 0o600 }),
{ signal: abort.signal },
)
abort.signal.throwIfAborted()
await sftp.rename(remotePart, remoteFinal)
pending = false
if (!abort.signal.aborted) res.status(201).json({ id })
} catch {
console.error(JSON.stringify({ event: 'export_failed', requestId: res.locals.requestId }))
fail(502, 'Export could not be confirmed')
} finally {
// A disconnected SSH session cannot guarantee deletion; the inbox janitor handles those parts.
if (connected && pending && !socket.destroyed) {
await sftp.delete(remotePart, true).catch(() => {
console.error(JSON.stringify({ event: 'cleanup_pending', requestId: res.locals.requestId }))
})
}
socket?.destroy()
await sftp.end().catch(() => {})
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {
console.error(JSON.stringify({ event: 'local_cleanup_failed', requestId: res.locals.requestId }))
})
clearTimeout(timer)
abort.signal.removeEventListener('abort', cancelSocket)
req.off('aborted', disconnect)
res.off('close', disconnect)
active -= 1
}
})
app.use(express.static(fileURLToPath(new URL('./public/', import.meta.url))))
app.use((_req, res) => res.status(404).json({ error: 'Not found' }))
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).json({ error: 'Request failed' })
})
const server = app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1')
server.requestTimeout = 35_000
server.headersTimeout = 10_000
Configura APP_ORIGIN con el origen exacto del navegador, por ejemplo,
http://localhost:3000 para pruebas locales. Establece los valores del emisor para
JWT_PUBLIC_KEY, JWT_ISSUER y JWT_AUDIENCE.
Establece SFTP_HOST, SFTP_PORT, SFTP_USERNAME y SFTP_PRIVATE_KEY solo del lado del servidor.
Las claves PEM deben contener saltos de línea reales.
SFTP_HOST_SHA256 es el resumen SHA-256 hexadecimal en minúsculas de 64 caracteres de
la clave pública de host SSH en bruto, que coincide con el formato
hostHash: 'sha256' de ssh2. No es la cadena de visualización
SHA256:base64 de OpenSSH. Obtén y verifica este valor con quien administra el
servidor SFTP mediante un canal de confianza. Nunca aceptes automáticamente la primera clave ni
obtengas el valor fijado de una conexión sin verificar.
Consideraciones de seguridad
Crea una cuenta SFTP dedicada, restringida mediante chroot a su área de transferencia privada.
Dentro de ese entorno chroot, crea de antemano /incoming con permisos de
escritura solo para la cuenta de la pasarela. Ningún proceso que no sea de confianza debe poder
renombrar este directorio, crear enlaces simbólicos en él ni competir por la creación de archivos.
La comprobación de la ruta canónica no sustituye estos permisos del sistema de archivos.
Mantén el directorio temporal local en un volumen privado con una cuota de almacenamiento. El cuerpo HTTP completo se almacena allí temporalmente antes de que comience la transferencia SFTP; ninguna de las dos transferencias carga todo el archivo en memoria. Los límites para este único proceso son dos solicitudes activas, 5 MiB por archivo y un plazo máximo de 30 segundos. Configura cuotas globales, límites de frecuencia por usuario y límites equivalentes de tamaño del cuerpo y tiempo en el proxy para un servicio desplegado.
Los consumidores deben ignorar los archivos .part. Ejecuta un proceso de
limpieza de la bandeja de entrada bajo la cuenta del administrador que elimine las partes obsoletas
solo después del plazo máximo de transferencia, y limpia los directorios locales de almacenamiento
temporal abandonados tras fallos. Un fallo de red puede impedir la eliminación remota inmediata,
y una respuesta perdida después de renombrar el archivo puede dejar un archivo exportado
correctamente. El ID devuelto es un comprobante, no un protocolo que garantice una ejecución
exactamente una vez.
Usa HTTPS para la página y la API, conserva el encabezado Origin del navegador al pasar por el
proxy y mantén CORS desactivado. La verificación JWT autentica a quien realiza la solicitud;
comprobar solo Origin no lo haría. Las etiquetas MIME no se usan para determinar la seguridad:
los datos .bin exportados siguen sin ser de confianza.
Prueba la funcionalidad de exportación de archivos
yarn node index.js
Abre la página desde el origen configurado de la pasarela, no desde una URL local
file://. Haz primero las pruebas con un servidor SFTP desechable.
Confirma que el resultado coincide byte por byte y que los permisos mantienen privados los archivos.
Verifica también los casos de tokens ausentes, ámbito incorrecto, Origin incorrecto, cadenas de
consulta que intentan recorrer directorios, longitud ausente, cuerpos de tamaño excesivo, claves
de host SSH incorrectas, errores de permisos remotos y subidas interrumpidas, además de la limpieza.
Ninguna solicitud rechazada debe publicar un archivo final.
Conclusión
Esta pasarela demuestra una exportación completa del navegador a SFTP con almacenamiento temporal limitado y un límite de autorización explícito. El despliegue sigue requiriendo la creación de cuentas, cuotas, una política sobre malware, monitoreo y operaciones de limpieza.
Para procesar y transformar archivos además de exportarlos, explora Transloadit y su Robot de exportación SFTP.
