Conecta SFTP a navegadores con WebAssembly y WebSockets
Los navegadores pueden trabajar con SFTP mediante un puente de transporte o una API de servidor. La distinción importa: WebAssembly puede ejecutar código SSH, pero no proporciona sockets TCP nativos a una página web estándar. Este artículo compara esas arquitecturas e implementa un pequeño explorador de archivos con autenticación.
Introducción: el desafío de conectar navegadores con SFTP
SFTP normalmente se ejecuta dentro de SSH sobre TCP. La conexión WebSocket de un navegador comienza con un cambio de protocolo mediante HTTP y transporta tramas WebSocket; dirigirla al puerto 22 no la convierte en un socket SSH. Un servidor debe adaptar el transporte o realizar las operaciones SFTP en nombre del navegador.
Comprende el modelo de seguridad
Con SSH del lado del navegador, la sesión SSH termina en el navegador. El proxy de transporte se encarga de la conectividad de red, mientras que la implementación SSH del navegador autentica al host remoto y al usuario. Con una pasarela de API HTTP, SSH termina en la pasarela, que conserva las credenciales SSH del lado del servidor y autoriza cada operación de la aplicación.
Ninguno de los dos diseños permite un proxy con destinos sin restricciones. Autentica el acceso, restringe los destinos, comprueba el Origin del navegador y limita la duración de las conexiones y el tráfico. WSS/HTTPS protege el tramo entre el navegador y la pasarela; la verificación de la clave de host SSH autentica por separado al servidor SFTP.
Solución 1: clientes SFTP basados en WebAssembly
hullarb/ssheasy es una aplicación Go/WASM con su propio proxy
de WebSocket a TCP y su propio despliegue del frontend. No es un módulo npm que exporte un
SSHClient listo para integrar. Su proceso de compilación genera tanto los
componentes del navegador como los del proxy.
c2FmZQ/sshterm también es una aplicación Go/WASM completa.
Sus funciones documentadas incluyen subidas y descargas por SFTP, un agente SSH y gestión de claves.
Utiliza un endpoint WebSocket tlsproxy para acceder a servidores SSH.
Sigue los contratos de despliegue y proxy de esa aplicación en lugar de suponer que un endpoint
WebSocket genérico es compatible.
Estas aplicaciones sirven aquí como referencias de arquitectura, no como dependencias del ejemplo ejecutable. Revisa sus versiones actuales antes de adoptarlas. Las claves SSH propias de un usuario pueden ser adecuadas para un cliente SSH en el navegador, con una política de almacenamiento y recuperación bien definida; las credenciales compartidas del servidor nunca deben incluirse en la página. Protege la verificación de claves de host y la cadena de suministro del frontend.
Solución 2: enfoque de proxy WebSocket con sftp-ws
El antiguo paquete sftp-ws implementa SFTP v3
sobre WebSockets en lugar de SSH. Su paquete npm 0.8.0 depende de ws ~0.8.0.
Expone una interfaz de sistema de archivos; no conecta automáticamente esa interfaz con un servidor
SSH remoto. Su repositorio describe el puente SSH como un trabajo futuro.
Ejemplo: configura un servidor sftp-ws (conceptual)
El antiguo constructor del servidor acepta opciones como port, virtualRoot y
readOnly. La versión 0.8.0 delega la verificación de conexiones en verifyClient(info, accept);
el callback credentials del ejemplo anterior no correspondía a ese contrato de autenticación.
Su implementación de accept() también espera el campo upgradeReq
de la antigua biblioteca WebSocket. No se debe exponer como servicio de subida un directorio local
con permisos de escritura al que dé acceso ese fragmento de código.
Este artículo conserva la sección histórica como contexto, pero utiliza el puente HTTP completo y con límites definidos que aparece a continuación como ejemplo funcional. Ofrece un catálogo fijo y descargas autorizadas en lugar de conceder a los clientes del navegador una interfaz general para el sistema de archivos remoto.
Ejemplo: cliente sftp-ws en el navegador (conceptual)
El connect(url, options, callback) del cliente publicado espera una URL.
No es la interfaz conceptual connect(webSocket, credentials, callback) anterior, y
require() de Node no es un mecanismo de importación para el navegador.
Para el puente API funcional, guarda esta página como public/index.html. Cada usuario
obtiene un token de acceso de corta duración mediante tu proveedor de identidad existente. El campo
de entrada manual sirve para pruebas; nunca contiene un secreto compartido integrado.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SFTP file browser</title>
</head>
<body>
<h1>SFTP files</h1>
<label>Your access token <input id="token" type="password" autocomplete="off" /></label>
<button id="list" type="button">List available files</button>
<ul id="files"></ul>
<p id="status" role="status"></p>
<script src="/files.js" defer></script>
</body>
</html>
Guarda lo siguiente como public/files.js. El servidor limita los archivos a 5 MiB,
por lo que el Blob de descarga también tiene un tamaño máximo conocido.
const token = document.getElementById('token')
const list = document.getElementById('list')
const files = document.getElementById('files')
const status = document.getElementById('status')
let busy = false
async function api(path) {
const response = await fetch(path, {
headers: {
Authorization: 'Bearer ' + token.value,
'X-SFTP-Client': 'browser',
},
signal: AbortSignal.timeout(35_000),
})
if (!response.ok) throw new Error('Request rejected')
return response
}
list.addEventListener('click', async () => {
if (busy) return
busy = true
list.disabled = true
try {
const response = await api('/files')
const catalog = await response.json()
files.replaceChildren()
for (const item of catalog) {
const li = document.createElement('li')
const button = document.createElement('button')
button.type = 'button'
button.textContent = 'Download ' + item.label
button.addEventListener('click', async () => {
if (busy) return
busy = true
button.disabled = true
try {
const result = await api('/files/' + encodeURIComponent(item.id))
const blob = await result.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = item.id + '.bin'
link.click()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
status.textContent = 'Download received.'
} catch {
status.textContent = 'Download failed.'
} finally {
button.disabled = false
busy = false
}
})
li.append(button)
files.append(li)
}
status.textContent = 'Available files loaded.'
} catch {
status.textContent = 'Could not load files.'
} finally {
busy = false
list.disabled = false
}
})
Solución 3: crea un puente API seguro en Node.js con ssh2-sftp-client
El servidor asigna un ID público de archivo a una ruta fija. No acepta parámetros de directorio
ni de nombre de host, y no obtiene un listado de directorio SFTP sin límites.
El ámbito sftp:read concede acceso a todo este catálogo configurado. Para varios
inquilinos, deriva catálogos y cuentas SSH independientes a partir de registros de autorización
verificados del lado del servidor.
Ejemplo: API de Node.js con ssh2-sftp-client
Usa Node.js 24. Crea un proyecto con "type": "module" en package.json:
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
Guarda esto como index.js:
import { randomUUID } from 'node:crypto'
import { createConnection } from 'node:net'
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 catalog = new Map([
['report', { label: 'Monthly report', path: '/exports/report.pdf' }],
])
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.use('/files', async (req, res, next) => {
// Same-origin fetches may omit Origin on GET. Require a custom header and disable CORS.
const origin = req.get('origin')
if ((origin && origin !== APP_ORIGIN) || req.get('X-SFTP-Client') !== 'browser' ||
req.get('sec-fetch-site') === 'cross-site' || Object.keys(req.query).length !== 0) {
return res.status(403).json({ error: 'Request not allowed' })
}
try {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.get('authorization') ?? '')
if (!match) return res.status(401).json({ error: 'Authentication required' })
const { payload } = await jwtVerify(match[1], key, {
algorithms: ['RS256'], issuer: JWT_ISSUER, audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'], maxTokenAge: '15m',
})
if (typeof payload.scope !== 'string' || !payload.scope.split(' ').includes('sftp:read')) {
return res.status(403).json({ error: 'Permission denied' })
}
} catch {
return res.status(401).json({ error: 'Invalid access token' })
}
next()
})
app.get('/files', (_req, res) => {
res.json(Array.from(catalog, ([id, item]) => ({ id, label: item.label })))
})
app.get('/files/:id', async (req, res) => {
const item = catalog.get(req.params.id)
if (!item) return res.status(404).json({ error: 'File not found' })
if (active >= 2) return res.status(503).json({ error: 'Gateway busy' })
active += 1
const abort = new AbortController()
const sftp = new SftpClient('gateway', {
error: () => abort.abort(),
end: () => abort.abort(),
close: () => abort.abort(),
})
const timer = setTimeout(() => abort.abort(), 30_000)
const disconnect = () => { if (!res.writableFinished) abort.abort() }
res.once('close', disconnect)
const socket = createConnection({ host: SFTP_HOST, port: sshPort })
socket.on('error', () => {})
const cancelSocket = () => socket.destroy()
abort.signal.addEventListener('abort', cancelSocket)
try {
await sftp.connect({
sock: socket, username: SFTP_USERNAME, privateKey: SFTP_PRIVATE_KEY,
hostHash: 'sha256', hostVerifier: (hash) => hash === SFTP_HOST_SHA256,
readyTimeout: 10_000,
})
if (await sftp.realPath(item.path) !== item.path) throw new Error('Unexpected path')
const info = await sftp.lstat(item.path)
if (!info.isFile || info.isSymbolicLink || !Number.isSafeInteger(info.size) ||
info.size < 1 || info.size > maxBytes) throw new Error('Invalid file')
abort.signal.throwIfAborted()
let received = 0
const limit = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length
callback(received > info.size ? new Error('Byte limit exceeded') : null, chunk)
},
flush(callback) {
callback(received !== info.size ? new Error('Incomplete file') : null)
},
})
res.set('Content-Type', 'application/octet-stream')
res.set('Content-Disposition', 'attachment; filename="' + req.params.id + '.bin"')
res.set('Content-Length', String(info.size))
await pipeline(sftp.createReadStream(item.path), limit, res, { signal: abort.signal })
} catch {
console.error(JSON.stringify({ event: 'download_failed', requestId: res.locals.requestId }))
if (!res.headersSent && !res.destroyed) {
res.removeHeader('Content-Length')
res.removeHeader('Content-Disposition')
res.status(502).json({ error: 'Download failed', requestId: res.locals.requestId })
} else {
res.destroy()
}
} finally {
socket.destroy()
await sftp.end().catch(() => {})
clearTimeout(timer)
res.off('close', disconnect)
abort.signal.removeEventListener('abort', cancelSocket)
active -= 1
}
})
app.use(express.static(fileURLToPath(new URL('./public/', import.meta.url))))
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
Establece APP_ORIGIN, JWT_PUBLIC_KEY, JWT_ISSUER y JWT_AUDIENCE
para tu aplicación y emisor. Establece SFTP_HOST, SFTP_PORT, SFTP_USERNAME y
SFTP_PRIVATE_KEY en una configuración exclusiva del servidor. Los valores PEM contienen
saltos de línea reales. Establece SFTP_HOST_SHA256 en el resumen SHA-256 de la clave
pública de host SSH sin procesar, verificado por el administrador, expresado en 64 caracteres
hexadecimales en minúsculas, tal como exige el
verificador de host de ssh2.
No pegues una huella digital SHA256:base64 de OpenSSH en este campo hexadecimal.
Aprovisiona una cuenta SFTP de solo lectura aislada mediante chroot y un /exports/report.pdf
real. Solo un publicador de confianza puede modificar este directorio; publica archivos inmutables
de forma atómica. Las comprobaciones de rutas canónicas y de lstat() por sí
solas no pueden impedir que un actor malicioso con permisos de escritura provoque una condición
de carrera con la posterior apertura del archivo.
Ejecuta yarn node index.js, visita la página en su origen configurado, muestra el catálogo
y descarga el informe. Prueba los casos de ámbito incorrecto, tokens vencidos, solicitudes entre
orígenes, ID desconocidos, recorrido de directorios, enlaces simbólicos, discrepancias de claves de
host, archivos demasiado grandes o cambiantes y desconexiones con un servidor SFTP desechable.
La biblioteca con versión fijada
proporciona las API de flujos y de atributos de archivos utilizadas aquí.
Consideraciones de seguridad y buenas prácticas
Mantén HTTPS en el punto de acceso del navegador, verifica las claves de host SSH y nunca permitas que los datos de una solicitud elijan un destino. Limita a los usuarios autenticados a catálogos controlados por el servidor. Aplica límites de solicitudes por usuario y cuotas globales en el perímetro del despliegue; el límite de dos solicitudes de este ejemplo se aplica a un proceso Node.
Las descargas se transmiten con contrapresión y un control estricto del número de bytes. Una transferencia fallida puede haber enviado ya un cuerpo parcial; su conexión se cierra en lugar de añadir un error JSON a los bytes del archivo. La longitud declarada permite al navegador detectar el truncamiento. Las firmas de archivos y el análisis antivirus siguen siendo decisiones independientes de la política de contenido.
Compara el rendimiento y elige el enfoque adecuado
Un cliente WASM ejecuta SSH y criptografía en el navegador e incurre en un costo inicial de descarga y compilación. Un puente de transporte WebSocket lleva esa sesión a través de un servidor. SFTP sobre WebSocket es una combinación de protocolos diferente y no implica una sesión SSH.
Un puente API HTTP centraliza SSH y la autorización de la aplicación. El ejemplo ofrece un catálogo pequeño y fijo y archivos con límites definidos; no es una prueba de rendimiento ni un gestor de archivos SFTP de propósito general. Mide la latencia, la concurrencia y la memoria con cargas de trabajo reales antes de elegir una arquitectura.
Conclusión
WebAssembly puede alojar una implementación SSH, WebSockets puede proporcionar el transporte desde un navegador y una API de Node.js puede ofrecer operaciones SFTP restringidas. Elige dónde termina SSH y exige autenticación en cada límite.
El Robot 🤖 /sftp/import de Transloadit importa archivos desde SFTP a tus Assemblies. Para subir archivos desde el navegador, consulta Uppy.
