SFTP mit WebAssembly und WebSockets an Browser anbinden
Browser können SFTP über eine Transportbrücke oder eine Server-API nutzen. Der Unterschied ist entscheidend: WebAssembly kann SSH-Code ausführen, stellt einer gewöhnlichen Webseite aber keine nativen TCP-Sockets bereit. Dieser Artikel vergleicht die Architekturen und implementiert einen kleinen Dateibrowser mit Authentifizierung.
Einführung: die Herausforderung bei SFTP im Browser
SFTP läuft normalerweise innerhalb von SSH über TCP. Eine WebSocket-Verbindung im Browser beginnt mit einem HTTP-Upgrade und überträgt WebSocket-Frames. Eine Verbindung mit Port 22 macht daraus keinen SSH-Socket. Ein Server muss den Transport übersetzen oder die SFTP-Vorgänge im Auftrag des Browsers ausführen.
Das Sicherheitsmodell verstehen
Bei browserseitigem SSH endet die SSH-Sitzung im Browser. Der Transportproxy sorgt für die Erreichbarkeit im Netzwerk, während die SSH-Implementierung im Browser den entfernten Host und den Benutzer authentifiziert. Bei einem HTTP-API-Gateway endet SSH auf dem Gateway. Dieses hält SSH-Zugangsdaten auf der Serverseite und autorisiert jeden Anwendungsvorgang.
Keiner der beiden Ansätze erlaubt einen Proxy mit uneingeschränkter Zielwahl. Authentifizieren Sie den Zugriff, beschränken Sie die Ziele, prüfen Sie den Origin des Browsers und begrenzen Sie Verbindungsdauer und Datenverkehr. WSS/HTTPS schützt den Übertragungsabschnitt zwischen Browser und Gateway; die SSH-Hostschlüsselprüfung authentifiziert davon unabhängig den SFTP-Server.
Lösung 1: SFTP-Clients mit WebAssembly
hullarb/ssheasy ist eine Go/WASM-Anwendung mit eigenem
WebSocket-zu-TCP-Proxy und eigener Frontend-Bereitstellung. Sie ist kein npm-Modul, das
SSHClient als direkt einsetzbare Komponente exportiert. Ihr Build kompiliert
sowohl Browser- als auch Proxy-Komponenten.
c2FmZQ/sshterm ist ebenfalls eine vollständige Go/WASM-Anwendung.
Zu den dokumentierten Funktionen gehören SFTP-Uploads und -Downloads, ein SSH-Agent und eine
Schlüsselverwaltung. Sie nutzt den WebSocket-Endpunkt tlsproxy, um SSH-Server
zu erreichen. Befolgen Sie die Bereitstellungsvorgaben und Proxy-Schnittstellen dieser Anwendung,
statt die Kompatibilität eines beliebigen WebSocket-Endpunkts vorauszusetzen.
Diese Anwendungen dienen hier als Architekturreferenzen, nicht als Abhängigkeiten des ausführbaren Beispiels. Prüfen Sie ihre aktuellen Releases vor dem Einsatz. Eigene SSH-Schlüssel eines Benutzers können für einen SSH-Client im Browser geeignet sein, sofern eine durchdachte Richtlinie für Speicherung und Wiederherstellung besteht. Gemeinsame Server-Zugangsdaten dürfen niemals in die Seite eingebunden werden. Schützen Sie die Hostschlüsselprüfung und die Frontend-Lieferkette.
Lösung 2: WebSocket-Proxy-Ansatz mit sftp-ws
Das historische Paket sftp-ws implementiert SFTP v3
über WebSockets statt SSH. Sein npm-Paket in Version 0.8.0 hängt von
ws ~0.8.0 ab. Es stellt eine Dateisystemschnittstelle bereit, verbindet diese
aber nicht automatisch mit einem entfernten SSH-Server. Das Repository beschreibt die SSH-Brücke
als künftige Arbeit.
Beispiel: einen Server mit sftp-ws einrichten (konzeptionell)
Der historische Server-Konstruktor nimmt Optionen wie port,
virtualRoot und readOnly entgegen. Version 0.8.0 delegiert
die Verbindungsprüfung an verifyClient(info, accept);
der Callback credentials aus dem alten Beispiel entsprach nicht dieser
Authentifizierungsschnittstelle. Die Implementierung von accept() erwartet
außerdem das Feld upgradeReq der alten WebSocket-Bibliothek. Ein lokales
Verzeichnis mit Schreibzugriff hinter diesem Codeausschnitt sollte nicht als Upload-Dienst
zugänglich gemacht werden.
Dieser Artikel behält den historischen Abschnitt zur Einordnung bei, verwendet für sein funktionsfähiges Beispiel jedoch die vollständige, begrenzte HTTP-Brücke weiter unten. Diese stellt einen festen Katalog und autorisierte Downloads bereit, anstatt Browser-Clients eine allgemeine Schnittstelle zum entfernten Dateisystem zu gewähren.
Beispiel: Client mit sftp-ws im Browser (konzeptionell)
Beim veröffentlichten Client erwartet connect(url, options, callback) eine URL.
Dies entspricht nicht der früheren konzeptionellen Schnittstelle
connect(webSocket, credentials, callback), und require() aus Node ist kein
Importmechanismus für den Browser.
Speichern Sie diese Seite für die funktionsfähige API-Brücke als public/index.html.
Jeder Benutzer erhält über Ihren bestehenden Identitätsanbieter ein kurzlebiges Zugriffstoken.
Das manuelle Eingabefeld dient zum Testen; es enthält niemals ein eingebettetes gemeinsames
Authentifizierungsgeheimnis.
<!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>
Speichern Sie Folgendes als public/files.js. Der Server begrenzt Dateien auf
5 MiB, sodass auch der Download-Blob eine bekannte Maximalgröße hat.
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
}
})
Lösung 3: eine sichere Node.js-API-Brücke mit ssh2-sftp-client erstellen
Der Server ordnet eine öffentliche Datei-ID einem festen Pfad zu. Er akzeptiert weder Verzeichnis- noch Hostnamenparameter und ruft keine unbegrenzte SFTP-Verzeichnisliste ab.
Der Berechtigungsumfang sftp:read gewährt Zugriff auf diesen gesamten
konfigurierten Katalog. Leiten Sie für mehrere Mandanten separate Kataloge und SSH-Konten aus
verifizierten serverseitigen Autorisierungsdatensätzen ab.
Beispiel: Node.js-API mit ssh2-sftp-client
Verwenden Sie Node.js 24. Erstellen Sie ein Projekt mit "type": "module" in
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
Speichern Sie dies als 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
Legen Sie APP_ORIGIN, JWT_PUBLIC_KEY,
JWT_ISSUER und JWT_AUDIENCE für Ihre Anwendung und den
Token-Aussteller fest. Legen Sie SFTP_HOST, SFTP_PORT,
SFTP_USERNAME und SFTP_PRIVATE_KEY in einer ausschließlich
serverseitigen Konfiguration fest. PEM-Werte enthalten tatsächliche Zeilenumbrüche.
Setzen Sie SFTP_HOST_SHA256 auf den vom Administrator verifizierten SHA-256-Hash
des öffentlichen SSH-Hostschlüssels in Rohform, dargestellt als 64-stellige Hexadezimalzeichenfolge
in Kleinbuchstaben, wie von der Hostprüfung von ssh2 gefordert.
Fügen Sie keinen OpenSSH-Fingerabdruck im Format SHA256:base64 in dieses
Hexadezimalfeld ein.
Richten Sie ein SFTP-Konto mit reinem Lesezugriff und chroot-Isolierung sowie eine echte Datei
/exports/report.pdf ein. Nur eine vertrauenswürdige Instanz zur Veröffentlichung darf
das Verzeichnis ändern, in dem diese Datei liegt; veröffentlichen Sie unveränderliche Dateien atomar. Prüfungen des
kanonischen Pfads und mit lstat() allein können nicht verhindern, dass ein
böswilliger Schreibzugriff mit dem späteren Öffnen der Datei konkurriert.
Führen Sie yarn node index.js aus, öffnen Sie die Seite unter ihrem konfigurierten
Origin, rufen Sie den Katalog ab und laden Sie den Bericht herunter. Testen Sie an einem temporären
SFTP-Server falsche Berechtigungsumfänge, abgelaufene Tokens, ursprungsübergreifende Anfragen,
unbekannte IDs, Pfadtraversierung, symbolische Links, abweichende Hostschlüssel, zu große oder sich
ändernde Dateien sowie Verbindungsabbrüche. Die auf eine feste Version festgelegte Bibliothek
stellt die hier verwendeten APIs für Streams und Dateistatusinformationen bereit.
Sicherheitsaspekte und bewährte Verfahren
Verwenden Sie HTTPS an der Schnittstelle zum Browser, prüfen Sie SSH-Hostschlüssel und lassen Sie niemals Anfragedaten das Ziel bestimmen. Beschränken Sie authentifizierte Benutzer auf Kataloge, die der Server verwaltet. Setzen Sie an der Bereitstellungsgrenze Ratenbegrenzungen pro Benutzer und globale Kontingente durch; die Begrenzung auf zwei Anfragen gilt hier für einen Node-Prozess.
Downloads werden mit Rückstauregelung und einer strikten Bytezählung gestreamt. Bei einer fehlgeschlagenen Übertragung kann bereits ein Teil des Antwortinhalts gesendet worden sein. Die Verbindung wird dann geschlossen, statt einen JSON-Fehler an die Dateibytes anzuhängen. Anhand der angegebenen Länge kann der Browser eine unvollständige Übertragung erkennen. Dateisignaturen und Virenscans bleiben separate Entscheidungen im Rahmen der Inhaltsrichtlinien.
Leistungsvergleich und Wahl des passenden Ansatzes
Ein WASM-Client führt SSH und Kryptografie im Browser aus und verursacht beim Start Aufwand für Download und Kompilierung. Eine WebSocket-Transportbrücke leitet diese Sitzung durch einen Server. SFTP über WebSocket ist eine andere Protokollanordnung und setzt keine SSH-Sitzung voraus.
Eine HTTP-API-Brücke zentralisiert SSH und die Anwendungsautorisierung. Das Beispiel stellt einen kleinen, festen Katalog und Dateien mit begrenzter Größe bereit; es ist weder ein Benchmark noch ein universeller SFTP-Dateimanager. Messen Sie Latenz, Parallelität und Speicherbedarf unter realer Arbeitslast, bevor Sie sich für eine Architektur entscheiden.
Fazit
WebAssembly kann eine SSH-Implementierung ausführen, WebSockets können den Transport für den Browser übernehmen, und eine Node.js-API kann eingeschränkte SFTP-Vorgänge anbieten. Entscheiden Sie, wo SSH endet, und setzen Sie an jeder Grenze eine Authentifizierung durch.
Der Robot 🤖 /sftp/import von Transloadit importiert Dateien von SFTP in Ihre Assemblies. Für Uploads im Browser siehe Uppy.
