Dateien per Webbrowser auf SFTP-Server exportieren
Ein Browser kann Dateien auf ein HTTPS-Gateway hochladen, das sie anschließend auf einen konfigurierten SFTP-Server schreibt. Dieses Tutorial setzt diesen Ablauf mit begrenztem temporärem Speicher, verifizierten SSH-Hostschlüsseln sowie authentifizierten und autorisierten Upload-Anfragen um.
Einführung
Dieses Beispiel exportiert Dateien bis zu 5 MiB, ohne ihren Inhalt zu interpretieren. Es erkennt keine sicheren Dokumente, sucht nicht nach Schadsoftware und macht hochgeladene Inhalte nicht sicher ausführbar. Dateien erhalten serverseitig erzeugte Namen und verbleiben in einem privaten SFTP-Eingangsverzeichnis. Das Gateway akzeptiert vom Browser weder einen entfernten Hostnamen noch ein Verzeichnis, einen Benutzernamen oder einen Dateinamen.
Die Herausforderung verstehen
Standardwebseiten können keine direkten TCP-Verbindungen zu SSH-Servern öffnen. SFTP läuft über SSH; das Gateway terminiert HTTPS und baut eine separate SSH-Verbindung auf. Die Authentifizierung im Browser und die Verifizierung des SSH-Hostschlüssels schützen unterschiedliche Grenzen. Beides ist erforderlich.
Verwenden Sie Node.js 24 und die unten aufgeführten Pakete mit festgelegten Versionen. Diese
Anleitung setzt voraus, dass ein Identitätsanbieter kurzlebige RS256-Zugriffstokens für dieses
Gateway ausstellt, mit sub, iat,
exp und dem Berechtigungsumfang sftp:upload, der nur
Personen zugewiesen wird, die zum Hochladen berechtigt sind. Jede Person nutzt ihr eigenes Token.
Geben Sie weder ein gemeinsames Anwendungsgeheimnis noch SSH-Zugangsdaten an Browser weiter.
Das Frontend einrichten
Speichern Sie dies als public/index.html. Beziehen Sie das Zugriffstoken über den
bestehenden Anmeldeablauf Ihrer Anwendung; das Passwortfeld dient zur manuellen Eingabe eines
individuellen kurzlebigen Tokens für Tests.
<!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>
Speichern Sie dies als public/upload.js. Senden Sie die Datei selbst, ohne
Multipart-Parsing oder ein vom Client gewähltes Ziel. Bei einem File-Body liefert der Browser
Content-Length.
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
}
})
Die Backend-API mit Node.js erstellen
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
Setzen Sie "type": "module" in package.json. Speichern Sie den Server
als index.js.
Die ssh2-sftp-client-API
stellt createWriteStream(), realPath(),
rename() und delete() bereit.
Zu den zugrunde liegenden ssh2-Verbindungsoptionen gehören
sock, hostHash und 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
Konfigurieren Sie APP_ORIGIN als exakten Browser-Ursprung, beispielsweise
http://localhost:3000 für lokale Tests. Setzen Sie für den Aussteller
JWT_PUBLIC_KEY, JWT_ISSUER und JWT_AUDIENCE.
Setzen Sie SFTP_HOST, SFTP_PORT,
SFTP_USERNAME und SFTP_PRIVATE_KEY ausschließlich serverseitig.
PEM-Schlüssel müssen echte Zeilenumbrüche enthalten.
SFTP_HOST_SHA256 ist der 64-stellige hexadezimale SHA-256-Hash des öffentlichen
SSH-Hostschlüssels in Rohform, mit Kleinbuchstaben, entsprechend dem Format
hostHash: 'sha256' von ssh2. Es handelt sich nicht um die Anzeigezeichenfolge
SHA256:base64 von OpenSSH. Beschaffen und verifizieren Sie den Hash gemeinsam mit
der SFTP-Administration über einen vertrauenswürdigen Kanal. Akzeptieren Sie niemals den ersten
Schlüssel automatisch und beziehen Sie den fest hinterlegten Prüfwert niemals aus einer
unverifizierten Verbindung.
Sicherheitsaspekte
Richten Sie ein dediziertes SFTP-Konto ein, das per chroot auf seinen privaten Transferbereich
beschränkt ist. Erstellen Sie innerhalb dieses chroot vorab /incoming, mit
Schreibrechten ausschließlich für das Gateway-Konto. Kein nicht vertrauenswürdiger Prozess darf
dieses Verzeichnis umbenennen, dort symbolische Links erstellen oder mit der Dateierstellung
konkurrieren. Die Prüfung des kanonischen Pfads ersetzt diese Dateisystemberechtigungen nicht.
Legen Sie das lokale temporäre Verzeichnis auf einem privaten Volume mit Speicherkontingent an. Ein vollständig empfangener HTTP-Body wird dort zwischengespeichert, bevor die SFTP-Übertragung beginnt; keine der beiden Übertragungen lädt die gesamte Datei in den Arbeitsspeicher. Zwei aktive Anfragen, 5 MiB pro Datei und eine Frist von 30 Sekunden begrenzen diesen einzelnen Prozess. Konfigurieren Sie für einen bereitgestellten Dienst globale Kontingente, Ratenbegrenzungen pro Person sowie entsprechende Größen- und Zeitlimits am Proxy.
Weiterverarbeitende Anwendungen müssen Dateien mit der Endung .part
ignorieren. Betreiben Sie einen Bereinigungsdienst für das Eingangsverzeichnis unter Kontrolle der
Administration, der veraltete Dateifragmente erst nach Ablauf des maximalen Übertragungszeitfensters
löscht. Bereinigen Sie nach Abstürzen auch verwaiste lokale Zwischenverzeichnisse. Ein
Netzwerkausfall kann das sofortige Löschen auf dem entfernten Server verhindern. Geht eine Antwort
nach dem Umbenennen verloren, kann eine erfolgreich exportierte Datei zurückbleiben. Die
zurückgegebene ID ist eine Empfangsbestätigung, kein Protokoll, das eine genau einmalige Ausführung
garantiert.
Verwenden Sie HTTPS für die Seite und die API, erhalten Sie den Origin-Header des Browsers bei der
Weiterleitung durch den Proxy und lassen Sie CORS deaktiviert. Die JWT-Verifizierung
authentifiziert den Aufrufer; eine alleinige Prüfung von Origin würde das nicht leisten.
MIME-Angaben dienen nicht als Sicherheitsbewertung: Exportierte Daten mit der Endung
.bin bleiben nicht vertrauenswürdig.
Die Dateiexportfunktion testen
yarn node index.js
Öffnen Sie die Seite vom konfigurierten Ursprung des Gateways aus, nicht über eine lokale URL mit
file://.
Testen Sie zunächst mit einem SFTP-Server, den Sie anschließend verwerfen können. Bestätigen Sie,
dass die Ausgabe Byte für Byte übereinstimmt und die Dateiberechtigungen den Zugriff auf einen
privaten Bereich beschränken. Prüfen Sie außerdem fehlende Tokens, einen falschen
Berechtigungsumfang, einen falschen Origin-Wert, Query-Strings mit Pfadtraversierung, fehlende
Längenangaben, zu große Bodys, falsche SSH-Hostschlüssel, Berechtigungsfehler auf dem entfernten
Server, unterbrochene Uploads und die Bereinigung. Keine abgelehnte Anfrage darf eine endgültige
Datei bereitstellen.
Fazit
Dieses Gateway demonstriert einen vollständigen Export vom Browser auf einen SFTP-Server mit begrenzter Zwischenspeicherung und einer expliziten Autorisierungsgrenze. Für die Bereitstellung sind weiterhin Kontoeinrichtung, Kontingente, Richtlinien zum Umgang mit Schadsoftware, Überwachung und Bereinigungsabläufe erforderlich.
Wenn Sie Dateien neben dem Export auch verarbeiten und umwandeln möchten, entdecken Sie Transloadit und den zugehörigen Robot für den SFTP-Export.
