SFTP mit WebAssembly und WebSockets an Browser anbinden
Mit einem Node.js-Gateway kann ein Browser einen kleinen, freigegebenen SFTP-Katalog auflisten und Dateien daraus herunterladen. Die SSH-Zugangsdaten bleiben dabei auf dem Server. Diese Anleitung enthält einen temporären OpenSSH-Server und einen lokalen Token-Aussteller, damit Sie den gesamten Pfad vom Browser zu SFTP auf Ihrem eigenen Rechner ausprobieren können.
Wählen Sie, wo SSH endet
SFTP läuft normalerweise innerhalb von SSH über TCP. Ein WebSocket im Browser nutzt einen HTTP-Handshake und überträgt WebSocket-Frames. Eine Verbindung mit Port 22 macht ihn nicht zu einem SSH-Socket. WebAssembly kann SSH-Code ausführen, gibt einer normalen Webseite aber keine nativen TCP-Sockets.
Bei browserseitigem SSH stellt ein Transport-Proxy die Netzwerkerreichbarkeit bereit, während der
Browser den SSH-Host und den Benutzer authentifiziert. Bei einem HTTP-API-Gateway endet SSH am
Gateway, das jeden Anwendungsvorgang autorisiert. Das folgende Beispiel nutzt den zweiten Ansatz:
einen festen Katalog mit einer öffentlichen Datei-ID, ohne Pfade oder Ziele unter Browserkontrolle
und mit dem erforderlichen Scope sftp:read.
Erstellen Sie das Gateway-Projekt
Das lokale Beispiel wurde unter Linux mit Node.js 24.22.1
und 26.12.1, Yarn 4.12.0, OpenSSH 9.2 und Chromium 152 getestet.
Diese Anleitung richtet sich an gepflegte Node-Versionen der Reihen 24 oder 26; die genannten
Patchversionen dokumentieren die getesteten Versionen. Das Startskript nutzt die integrierte
TypeScript-Unterstützung von Node. Installieren Sie
Docker Engine und die OpenSSH-Clienttools separat.
Stellen Sie sicher, dass node, curl, docker und ssh-keygen in Ihrem PATH verfügbar sind.
Das Startskript nutzt den lokalen Docker-Socket unter /var/run/docker.sock;
es startet niemals einen SSH-Daemon auf dem Host.
Fügen Sie Folgendes in eine POSIX-Shell ein. Es erstellt ein neues Verzeichnis und nutzt einen eigenen
Yarn-Cache und eine eigene Lockdatei, auch wenn das übergeordnete Verzeichnis ein Yarn-Projekt ist.
Die Subshell lässt Ihr aktuelles Verzeichnis und Ihre Optionen unverändert. Ein bereits vorhandenes
Verzeichnis sftp-browser-demo wird abgelehnt.
(
command -v node >/dev/null && command -v curl >/dev/null &&
command -v docker >/dev/null && command -v ssh-keygen >/dev/null &&
mkdir sftp-browser-demo && cd sftp-browser-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"express":"5.2.1","helmet":"8.3.0","jose":"6.2.12","ssh2-sftp-client":"12.1.1"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' 'cacheFolder: .yarn/cache' 'globalFolder: .yarn/global' 'installStatePath: .yarn/install-state.gz' 'enableTelemetry: false' 'injectEnvironmentFiles: []' > .yarnrc.yml &&
touch yarn.lock &&
curl -fsSLo yarn-4.12.0.cjs https://repo.yarnpkg.com/4.12.0/packages/yarnpkg-cli/bin/yarn.js &&
node yarn-4.12.0.cjs install
)
Erstellen Sie alle folgenden Dateien in sftp-browser-demo. Falls die Installation fehlschlägt,
behalten Sie die Paketdateien, beheben Sie den gemeldeten Abhängigkeits- oder Netzwerkfehler und
versuchen Sie die Installation erneut vom übergeordneten Verzeichnis aus:
(cd sftp-browser-demo &&
curl -fsSLo yarn-4.12.0.cjs https://repo.yarnpkg.com/4.12.0/packages/yarnpkg-cli/bin/yarn.js &&
node yarn-4.12.0.cjs install)
Beim erneuten Versuch wird auch die CLI erneut heruntergeladen, falls ihr erster Download
fehlgeschlagen ist. Die CLI wird als .cjs gespeichert,
damit ein umgebendes ES-Modul-Projekt sie nicht anders interpretieren kann.
Autorisieren und lesen Sie den festen Katalog
Speichern Sie dies als index.mjs. Die
SFTP-Bibliothek mit festgelegter Version bietet APIs für kanonische
Pfade, Dateistatus und Streams. Dateien dürfen leer sein und sind auf 5 MiB begrenzt. Das Gateway
erlaubt zwei aktive Downloads pro Prozess. Jeder Download erhält 30 Sekunden für seine
SFTP-Verbindung, den Lesezugriff und die HTTP-Auslieferung. Das Gateway puffert die größenbegrenzte
Datei, bevor es eine erfolgreiche HTTP-Antwort sendet. So bleibt ein Download auch dann
fehlgeschlagen, wenn nach einem Teilfortschritt ein SFTP-Fehler auftritt.
import { randomUUID } from 'node:crypto'
import { createConnection } from 'node:net'
import { Readable, Transform, Writable } 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: 'Sample file', path: '/exports/report.bin' }],
])
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 < 0 || 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)
},
})
// With files this small, finish SFTP before committing an HTTP success response.
const chunks = []
const sink = new Writable({
write(chunk, _encoding, callback) { chunks.push(chunk); callback() },
})
await pipeline(sftp.createReadStream(item.path), limit, sink, { signal: abort.signal })
abort.signal.throwIfAborted()
res.set('Content-Type', 'application/octet-stream')
res.set('Content-Disposition', 'attachment; filename="' + req.params.id + '.bin"')
res.set('Content-Length', String(received))
await pipeline(Readable.from([Buffer.concat(chunks, received)]), 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 port = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid HTTP port')
const server = app.listen(port, '127.0.0.1', (error) => {
if (error) {
console.error('Gateway could not listen; check PORT and whether it is in use')
process.exitCode = 1
return
}
console.log('Open ' + APP_ORIGIN)
})
server.requestTimeout = 35_000
server.headersTimeout = 10_000
Verbinden Sie den Browser
Speichern Sie dies als public/index.html und erstellen Sie zuvor das Verzeichnis
public. Das Token-Feld dient dieser lokalen Übung;
in die Seite sind keine gemeinsamen SSH-Zugangsdaten eingebettet.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<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 dies als public/files.js. Die Seite deaktiviert ihre Eingabefelder während
jeder Anfrage und hält höchstens eine Objekt-URL für Downloads vor. Eine fehlgeschlagene
Kataloganfrage löscht die vorherige Liste.
const token = document.getElementById('token')
const list = document.getElementById('list')
const files = document.getElementById('files')
const status = document.getElementById('status')
let busy = false
let downloadUrl
function setBusy(value) {
busy = value
token.disabled = value
for (const button of document.querySelectorAll('button')) button.disabled = value
}
window.addEventListener('pagehide', () => {
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
})
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
setBusy(true)
status.textContent = 'Loading files…'
files.replaceChildren()
try {
const response = await api('/files')
const catalog = await response.json()
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
setBusy(true)
status.textContent = 'Downloading…'
try {
const result = await api('/files/' + encodeURIComponent(item.id))
const blob = await result.blob()
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
downloadUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = item.id + '.bin'
link.click()
status.textContent = 'Download received.'
} catch {
status.textContent = 'Download failed.'
} finally {
setBusy(false)
}
})
li.append(button)
files.append(li)
}
status.textContent = 'Available files loaded.'
} catch {
status.textContent = 'Could not load files.'
} finally {
setBusy(false)
}
})
Stellen Sie einen lokalen SFTP-Server und ein Token bereit
Speichern Sie dies als sftp-server.Dockerfile. Die Paketinstallation erfolgt in einem eigenen
Container-Image. Das Startskript kopiert nur dieses Dockerfile und die SSH-Konfiguration in den
Build-Kontext. Schlüssel und Tokens werden erst später erzeugt und gelangen nie in das Image.
FROM node:26.12.1-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && \
rm -rf /var/lib/apt/lists/* /etc/ssh/ssh_host_* && \
useradd --uid 10001 --home-dir /exports --shell /usr/sbin/nologin fixture && \
passwd -d fixture && mkdir -p /srv/sftp/exports && chmod 755 /srv/sftp
COPY sshd_config /etc/ssh/demo_config
CMD ["sh", "-c", "mkdir -p /run/sshd && cp /fixture/host-key /run/host-key && chmod 600 /run/host-key && cp /fixture/client-key.pub /run/authorized_keys && chmod 644 /run/authorized_keys && timeout --signal=TERM --kill-after=5 600 /usr/sbin/sshd -D -e -f /etc/ssh/demo_config"]
Speichern Sie dies als sshd_config. Damit wird die SSH-Konfiguration des
Demo-Containers vollständig ersetzt. Sie bindet die Konfigurationsdateien der Distribution nicht
ein. Das Konto ist per chroot isoliert, und
internal-sftp -R verweigert Schreibzugriffe.
Port 22
ListenAddress 0.0.0.0
HostKey /run/host-key
PidFile /run/sshd.pid
AuthorizedKeysFile /run/authorized_keys
StrictModes yes
AllowUsers fixture
PermitRootLogin no
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
X11Forwarding no
PermitTTY no
PermitUserRC no
Subsystem sftp internal-sftp
Match User fixture
ChrootDirectory /srv/sftp
ForceCommand internal-sftp -R -d /exports
Speichern Sie dies als demo.ts. Es erzeugt neue SSH-Schlüssel, berechnet den
vertrauenswürdigen lokalen Host-Key-Pin, stellt ein RS256-Token mit fünf Minuten Gültigkeit aus und
startet beide Dienste auf verfügbaren Loopback-Ports. Das Token bleibt in einer privaten Datei.
Zwei weitere Tokens dienen dazu, einen falschen Scope und den Ablauf der Gültigkeit zu testen.
Dieser Aussteller demonstriert den JWT-Vertrag; er prüft keine Integration mit einem gehosteten
Identitätsanbieter.
import { type ChildProcess, execFile, spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { createConnection, createServer } from 'node:net'
import { join } from 'node:path'
import { setTimeout as pause } from 'node:timers/promises'
import { promisify } from 'node:util'
import { exportSPKI, generateKeyPair, SignJWT } from 'jose'
const root = import.meta.dirname
const state = join(root, '.demo')
const owner = randomUUID()
const name = 'sftp-browser-' + owner
const exec = promisify(execFile)
await mkdir(state, { mode: 0o700 }) // Refuse an existing directory; never clear someone else's state.
const environment = { PATH: process.env.PATH, DOCKER_HOST: 'unix:///var/run/docker.sock',
DOCKER_CONFIG: join(state, 'docker') }
async function command(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return exec(file, args, { cwd: root, env: environment, timeout: 240_000, maxBuffer: 8 * 1024 * 1024 })
}
const docker = (args: string[]) => command('docker', args)
let gateway: ChildProcess | undefined
let imageBuilt = false
let stopped = false
const stop = () => { stopped = true; gateway?.kill('SIGTERM') }
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const lifetime = setTimeout(stop, 10 * 60_000)
try {
await writeFile(join(state, 'resources.json'), JSON.stringify({ owner, name }) + '\n', { mode: 0o600 })
for (const dir of ['docker', 'build', 'exports']) await mkdir(join(state, dir))
await copyFile(join(root, 'sftp-server.Dockerfile'), join(state, 'build', 'Dockerfile'))
await copyFile(join(root, 'sshd_config'), join(state, 'build', 'sshd_config'))
await docker(['build', '--tag', name, join(state, 'build')])
imageBuilt = true
if (stopped) throw new Error('Demo was stopped during startup')
for (const key of ['host-key', 'client-key']) {
await command('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(state, key)])
}
if (stopped) throw new Error('Demo was stopped during startup')
// A deterministic binary sample, including NUL bytes and content beyond the first read.
const sample = Buffer.from(Array.from({ length: 65_568 }, (_, i) => i % 251))
await writeFile(join(state, 'exports', 'report.bin'), sample, { mode: 0o644 })
await chmod(join(state, 'exports'), 0o755)
const publicHost = (await readFile(join(state, 'host-key.pub'), 'utf8')).split(' ')[1]
if (!publicHost) throw new Error('Missing host public key')
const pin = createHash('sha256').update(Buffer.from(publicHost, 'base64')).digest('hex')
await docker(['run', '--detach', '--rm', '--name', name, '--label', 'sftp.demo.owner=' + owner,
'--read-only', '--tmpfs', '/run', '-p', '127.0.0.1::22',
'--mount', `type=bind,src=${state},dst=/fixture,readonly`,
'--mount', `type=bind,src=${join(state, 'exports')},dst=/srv/sftp/exports,readonly`, name])
const sshPort = Number((await docker(['port', name, '22/tcp'])).stdout.trim().split(':').at(-1))
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) throw new Error('Invalid published SSH port')
let ready = false
for (let attempt = 0; attempt < 40 && !stopped; attempt++) {
ready = await new Promise<boolean>((resolve) => {
const socket = createConnection({ host: '127.0.0.1', port: sshPort })
socket.setTimeout(500)
const done = (ok: boolean) => { socket.destroy(); resolve(ok) }
socket.once('data', (data) => done(data.toString().startsWith('SSH-2.0-OpenSSH_')))
socket.once('error', () => done(false))
socket.once('timeout', () => done(false))
})
if (ready) break
await pause(100)
}
if (!ready || stopped) throw new Error('Demo SSH server did not become ready')
const probe = createServer()
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve))
const address = probe.address()
if (!address || typeof address === 'string') throw new Error('Missing HTTP port')
const port = address.port
await new Promise<void>((resolve) => probe.close(() => resolve()))
const { publicKey, privateKey } = await generateKeyPair('RS256')
const now = Math.floor(Date.now() / 1000)
for (const [file, scope, expiry] of [
['token.txt', 'sftp:read', now + 300],
['wrong-scope.txt', 'sftp:upload', now + 300],
['expired.txt', 'sftp:read', now - 60],
] satisfies [string, string, number][]) {
const access = await new SignJWT({ scope }).setProtectedHeader({ alg: 'RS256' })
.setSubject('local-reader').setIssuer('urn:sftp-browser-demo').setAudience('sftp-browser')
.setIssuedAt(now).setExpirationTime(expiry).sign(privateKey)
await writeFile(join(state, file), access + '\n', { mode: 0o600 })
}
const jwtPublicKey = await exportSPKI(publicKey)
await writeFile(join(state, 'jwt-public.pem'), jwtPublicKey, { mode: 0o600 })
console.log('Copy the token from ' + join(state, 'token.txt'))
console.log('Sample SHA-256: ' + createHash('sha256').update(sample).digest('hex'))
if (stopped) throw new Error('Demo was stopped before gateway startup')
gateway = spawn(process.execPath, [join(root, 'index.mjs')], { stdio: 'inherit', env: {
PATH: process.env.PATH, APP_ORIGIN: 'http://127.0.0.1:' + port, PORT: String(port),
JWT_PUBLIC_KEY: jwtPublicKey, JWT_ISSUER: 'urn:sftp-browser-demo',
JWT_AUDIENCE: 'sftp-browser', SFTP_HOST: '127.0.0.1', SFTP_PORT: String(sshPort),
SFTP_USERNAME: 'fixture', SFTP_PRIVATE_KEY: await readFile(join(state, 'client-key'), 'utf8'),
SFTP_HOST_SHA256: pin,
} })
const child = gateway
const exit = await new Promise<number | null>((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code) => resolve(code))
})
if (!stopped && exit !== 0) throw new Error('Gateway exited unsuccessfully')
} finally {
clearTimeout(lifetime)
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
gateway?.kill('SIGTERM')
// The UUID and label restrict cleanup to the container this invocation created.
const existing = await docker(['inspect', '--format', '{{ index .Config.Labels "sftp.demo.owner" }}', name]).catch(() => null)
if (existing?.stdout.trim() === owner) await docker(['rm', '--force', name])
if (imageBuilt) await docker(['image', 'rm', name])
await rm(state, { recursive: true })
}
Führen Sie das Startskript vom übergeordneten Verzeichnis aus im Vordergrund aus:
node sftp-browser-demo/demo.ts
Öffnen Sie die vom Gateway ausgegebene URL. Kopieren Sie das Token aus sftp-browser-demo/.demo/token.txt
in Your access token und klicken Sie dann auf
List available files und
Download Sample file.
Download received. bedeutet, dass der Browser den gesamten
HTTP-Antwortkörper empfangen und dessen Download gestartet hat. Prüfen Sie die Downloads Ihres
Browsers auf die gespeicherte Datei. Die Seite kann nicht bestätigen, dass Sie sie auf dem
Datenträger behalten haben.
Die Beispieldatei umfasst 65.568 binäre Bytes. Vergleichen Sie den SHA-256 der gespeicherten Datei
report.bin mit dem vom Startskript ausgegebenen Wert
Sample SHA-256:. Speichern Sie dies als verify-download.ts im Projekt:
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
const path = process.argv[2]
const expected = process.argv[3]
if (!path || !/^[a-f0-9]{64}$/.test(expected ?? '')) throw new Error('Supply a file and SHA-256')
const actual = createHash('sha256').update(await readFile(path)).digest('hex')
if (actual !== expected) throw new Error('Downloaded bytes do not match')
console.log('Downloaded bytes match')
Übergeben Sie den tatsächlichen Downloadpfad und den ausgegebenen Hashwert als Argumente:
node sftp-browser-demo/verify-download.ts "$HOME/Downloads/report.bin" YOUR_PRINTED_SHA256
401 bedeutet, dass die Authentifizierung abgelehnt wurde, auch bei einem
abgelaufenen Token. 403 bedeutet, dass der Ursprung der Anfrage, der
Client-Header, die Abfrage oder der Scope abgelehnt wurde. 502 bedeutet,
dass das Gateway den SFTP-Lesezugriff nicht bestätigen konnte, etwa wegen eines abweichenden
Host-Schlüssels, eines symbolischen Links, einer zu großen Datei oder eines Verbindungsabbruchs.
Probieren Sie die Tokens aus .demo/wrong-scope.txt und .demo/expired.txt aus:
Beide Kataloganfragen müssen fehlschlagen. Wenn das Token abgelaufen ist, stoppen Sie die Demo und
starten Sie sie erneut, um ein neues Token zu erhalten.
Drücken Sie im Terminal des Startskripts Ctrl+C, um das Gateway zu stoppen und seinen Container,
Image-Tag, seine Schlüssel und sein Token zu entfernen. Die Demo stoppt auch nach zehn Minuten.
Das Projekt und Ihr gespeicherter Download bleiben erhalten. Während des Starts wartet die
Bereinigung, bis der aktuelle Build oder Docker-Befehl beendet ist; jeder Befehl hat ein Zeitlimit
von vier Minuten. Stoppen Sie die Demo nach dem Start, um den normalen Ablauf für ein sofortiges
Beenden zu nutzen. Bei unterbrochener Bereinigung kann .demo zurückbleiben.
Prüfen Sie .demo/resources.json und verifizieren Sie das Label
sftp.demo.owner des Containers, bevor Sie diesen Container und seinen Image-Tag
manuell entfernen. Entfernen Sie anschließend .demo;
das Startskript verweigert beim nächsten Lauf das Überschreiben.
Passen Sie das Gateway an Ihr SFTP-Konto an
Geben Sie dieselben ausschließlich serverseitigen Umgebungsvariablen an, die
demo.ts verwendet, mit Ihrem verifizierten Aussteller, Ihrer Zielgruppe und
Ihrem öffentlichen Schlüssel. Setzen Sie SFTP_HOST_SHA256 auf den administrativ
verifizierten SHA-256 des öffentlichen SSH-Host-Schlüssels in seiner Rohform: 64 Hexadezimalzeichen
in Kleinschreibung, wie von der
Host-Verifizierung von ssh2 gefordert.
Ein OpenSSH-Fingerabdruck im Format SHA256:base64 nutzt eine andere Kodierung
und kann nicht in dieses Feld eingefügt werden.
Nur ein vertrauenswürdiger Herausgeber darf die Dateien des Katalogs ändern; veröffentlichen Sie
unveränderliche Dateien atomar. Prüfungen des kanonischen Pfads und mit
lstat() können nicht verhindern, dass ein böswilliger Schreiber einem
späteren Öffnen zuvorkommt. Eine Bytezählung kann den Austausch gegen gleich lange Inhalte nicht
erkennen. Jedes gültige Token mit sftp:read kann den gesamten konfigurierten
Katalog lesen. Mehrere Mandanten benötigen getrennte Kataloge und SSH-Konten, die aus verifizierten
serverseitigen Autorisierungsdatensätzen abgeleitet werden.
Verwenden Sie HTTPS am Zugangspunkt für den Browser und behalten Sie die Verifizierung des SSH-Host-Schlüssels bei. Der Browser ruft eine API desselben Ursprungs mit einem eigenen Header auf, und das Gateway erlaubt keinen CORS-Zugriff. Ursprungsprüfungen ergänzen die Token-Autorisierung; sie authentifizieren keine Aufrufer außerhalb des Browsers. Die Grenze von zwei Downloads gilt für einen Node-Prozess. Die Pufferung benötigt Speicher für diese Dateien sowie für Kopien und Protokoll-Overhead. Kontingente für die gesamte Bereitstellung und Sicherheitsscans der Inhalte sind gesonderte Entscheidungen.
Wählen Sie eine Alternative mit browserseitigem SSH
Wenn Benutzer eine eigene SSH-Sitzung benötigen, bietet
hullarb/ssheasy einen Go/WebAssembly-Client zusammen mit einem
WebSocket-zu-TCP-Proxy und einem Frontend.
c2FmZQ/sshterm ist eine vollständige Go/WASM-Anwendung mit
SFTP-Uploads und -Downloads, SSH-Schlüsselverwaltung und einem WebSocket-Endpunkt
tlsproxy. Beachten Sie die Vorgaben jeder Anwendung zur Bereitstellung und
Host-Verifizierung. Diese Projekte dienen als Architekturvorbilder; dieses Beispiel installiert
oder erprobt ihre Bibliotheken nicht. Hier gibt es keinen Benchmark: Messen Sie den Startaufwand
und die Übertragungslatenz für Ihre eigene Arbeitslast.
Das historische Projekt sftp-ws überträgt SFTP v3 über WebSockets statt über SSH. Seine README warnt, dass es nicht ausreichend getestet ist. Es ist kein Transport-Proxy, der ohne Anpassung vor einen bestehenden SSH-Server geschaltet werden kann. Das authentifizierte HTTP-Gateway oben ist der funktionsfähige Weg in dieser Anleitung.
Für SFTP-Importe in Transloadit-Assemblies lesen Sie die Dokumentation zum Robot 🤖 /sftp/import. Für Browser-Uploads lesen Sie Uppy.
