Relier SFTP aux navigateurs avec WebAssembly et WebSockets
Utilisez une passerelle Node.js pour permettre à un navigateur de lister et de télécharger les fichiers d’un petit catalogue SFTP autorisé tout en conservant les informations d’authentification SSH sur le serveur. Ce guide comprend un serveur OpenSSH temporaire et un émetteur local de jetons, pour que vous puissiez essayer le parcours complet du navigateur à SFTP sur votre propre machine.
Choisir où se termine la connexion SSH
SFTP fonctionne normalement à l’intérieur d’une connexion SSH sur TCP. Une connexion WebSocket dans un navigateur utilise une négociation HTTP et transporte des trames WebSocket ; la connecter au port 22 n’en fait pas une socket SSH. WebAssembly peut exécuter du code SSH, mais ne fournit pas de sockets TCP natives à une page web standard.
Avec SSH côté navigateur, un proxy de transport fournit l’accès au réseau tandis que le navigateur
authentifie l’hôte et l’utilisateur SSH. Avec une passerelle API HTTP, la connexion SSH se termine
sur la passerelle, qui autorise chaque opération de l’application. L’exemple ci-dessous utilise
cette seconde architecture : un catalogue fixe avec un unique identifiant public de fichier,
aucun chemin ni aucune destination contrôlés par le navigateur, et une portée
sftp:read obligatoire.
Créer le projet de passerelle
L’exemple local a été testé sur Linux avec Node.js 24.15.0
et 26.8.1, Yarn 4.12.0, OpenSSH 9.2 et Chromium 152.
Cette procédure cible les versions maintenues de Node 24 ou 26 ; ces versions correctives indiquent
les versions testées. Le lanceur utilise la prise en charge native de TypeScript par Node. Installez
Docker Engine et les outils clients OpenSSH séparément,
et assurez-vous que node, curl,
docker et ssh-keygen sont dans votre PATH.
Le lanceur utilise la socket Docker locale à l’emplacement /var/run/docker.sock ;
il ne démarre jamais de démon SSH sur l’hôte.
Collez ceci dans un shell POSIX. Cela crée un nouveau répertoire et utilise un cache et un fichier
de verrouillage Yarn privés, même si le répertoire parent est un projet Yarn. Le sous-shell
conserve votre répertoire courant et vos options. Si le répertoire
sftp-browser-demo existe déjà, l’opération est refusée.
(
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
)
Créez tous les fichiers suivants dans sftp-browser-demo. Si l’installation échoue,
conservez ses fichiers de paquet, corrigez le problème de dépendance ou de réseau signalé, puis
relancez l’installation depuis le répertoire parent :
(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)
Cette nouvelle tentative télécharge également à nouveau la CLI si son premier téléchargement a
échoué. La CLI est enregistrée sous .cjs pour qu’un projet englobant
utilisant des modules ES ne puisse pas la réinterpréter.
Autoriser l’accès au catalogue fixe et le lire
Enregistrez ceci sous index.mjs. La
bibliothèque SFTP à version fixe fournit les API de chemin canonique,
de métadonnées de fichier et de flux. Les fichiers peuvent être vides et sont limités à 5 MiB.
La passerelle autorise deux téléchargements actifs par processus. Chaque téléchargement dispose
de 30 secondes pour la connexion SFTP, la lecture et la transmission HTTP. La passerelle met le fichier de
taille limitée en mémoire tampon avant d’envoyer une réponse HTTP de succès, de sorte qu’une erreur
SFTP après une progression partielle reste un échec de téléchargement.
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
Connecter le navigateur
Enregistrez ceci sous public/index.html, en créant d’abord le répertoire
public. Le champ du jeton sert à cet exercice local ; aucune information
d’authentification SSH partagée n’est intégrée à la page.
<!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>
Enregistrez ceci sous public/files.js. La page désactive ses champs pendant chaque
requête et conserve au maximum une URL d’objet de téléchargement. Une requête de catalogue qui
échoue efface la liste précédente.
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)
}
})
Fournir un serveur SFTP local et un jeton
Enregistrez ceci sous sftp-server.Dockerfile. L’installation des paquets se déroule dans une
image de conteneur dédiée, et le lanceur ne copie que ce Dockerfile et la configuration SSH dans
son contexte de construction. Les clés et les jetons sont générés plus tard et n’entrent jamais
dans l’image.
FROM node:26.8.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"]
Enregistrez ceci sous sshd_config. Cela remplace intégralement la configuration
SSH du conteneur de démonstration. Elle n’inclut pas les fichiers de configuration de la
distribution. Le compte est confiné par chroot, et
internal-sftp -R refuse les écritures.
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
Enregistrez ceci sous demo.ts. Ce fichier crée de nouvelles clés SSH, calcule
l’empreinte de confiance de la clé d’hôte locale, émet un jeton RS256 valable cinq minutes et démarre
les deux services sur des ports de bouclage disponibles. Le jeton reste dans un fichier
privé. Deux jetons supplémentaires servent à tester une portée incorrecte et l’expiration.
Cet émetteur illustre le contrat JWT ; il ne vérifie pas l’intégration avec un fournisseur
d’identité hébergé.
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 })
}
Depuis le répertoire parent, exécutez le lanceur au premier plan :
node sftp-browser-demo/demo.ts
Ouvrez l’URL affichée par la passerelle. Copiez le jeton depuis sftp-browser-demo/.demo/token.txt
dans Your access token, puis cliquez sur
List available files et
Download Sample file.
Download received. signifie que le navigateur a reçu
l’intégralité du corps HTTP et a lancé son téléchargement. Consultez les téléchargements de votre
navigateur pour retrouver le fichier enregistré ; la page ne peut pas confirmer que vous l’avez
conservé sur disque.
L’échantillon contient 65 568 octets binaires. Comparez le SHA-256 du fichier
report.bin enregistré avec la valeur
Sample SHA-256: affichée par le lanceur.
Enregistrez ceci sous verify-download.ts dans le projet :
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')
Passez en arguments le chemin réel de votre téléchargement et l’empreinte affichée :
node sftp-browser-demo/verify-download.ts "$HOME/Downloads/report.bin" YOUR_PRINTED_SHA256
Un 401 signifie que l’authentification a été rejetée, notamment en cas de
jeton expiré ; un 403 signifie que l’origine de la requête, l’en-tête du
client, les paramètres de requête ou la portée ont été refusés. Un 502
signifie que la passerelle n’a pas pu confirmer la lecture SFTP, par exemple en raison d’une clé
d’hôte non concordante, d’un lien symbolique, d’un fichier trop volumineux ou d’une connexion perdue.
Essayez les jetons de .demo/wrong-scope.txt et .demo/expired.txt :
les deux requêtes de catalogue doivent échouer.
Après l’expiration du jeton, arrêtez et redémarrez la démonstration pour obtenir un nouveau jeton.
Appuyez sur Ctrl+C dans le terminal du lanceur pour arrêter la passerelle et supprimer son
conteneur, son tag d’image, ses clés et son jeton. La démonstration s’arrête également après
dix minutes. Le projet et votre téléchargement enregistré sont conservés. Pendant le démarrage,
le nettoyage attend la fin de la construction ou de la commande Docker en cours ; chaque commande
a un délai maximal de quatre minutes. Arrêtez la démonstration après le démarrage pour suivre la
procédure normale d’arrêt immédiat. Un nettoyage interrompu peut laisser
.demo en place. Inspectez .demo/resources.json et vérifiez
l’étiquette sftp.demo.owner du conteneur avant de supprimer manuellement ce conteneur
et son tag d’image. Supprimez ensuite .demo ; le lanceur refuse de
l’écraser à l’exécution suivante.
Adapter la passerelle à votre compte SFTP
Fournissez les mêmes variables d’environnement, exclusivement côté serveur, que celles utilisées
par demo.ts, avec votre émetteur, votre audience et votre clé publique
vérifiés. Définissez SFTP_HOST_SHA256 avec le SHA-256 de la clé publique brute de
l’hôte SSH, vérifié par l’administrateur et représenté par 64 caractères hexadécimaux minuscules,
comme l’exige le vérificateur d’hôte de ssh2.
Une empreinte OpenSSH SHA256:base64 utilise un encodage différent et ne peut pas
être collée dans ce champ.
Réservez le droit de modifier les fichiers du catalogue à un éditeur de confiance ; publiez des fichiers
immuables de manière atomique. Les vérifications du chemin canonique et de
lstat() ne peuvent pas empêcher un acteur malveillant disposant d’un accès
en écriture d’exploiter une condition de concurrence lors de l’ouverture ultérieure, et un
comptage des octets ne peut pas détecter le remplacement du contenu par un contenu de même
longueur. Tout jeton sftp:read valide peut lire l’ensemble du catalogue
configuré. Plusieurs locataires nécessitent des catalogues et des comptes SSH distincts,
déterminés à partir d’enregistrements d’autorisation vérifiés côté serveur.
Utilisez HTTPS à l’interface avec le navigateur et conservez la vérification de la clé d’hôte SSH. Le navigateur envoie des requêtes à une API de même origine avec un en-tête personnalisé, et la passerelle n’active aucun accès CORS. Les vérifications d’origine complètent l’autorisation par jeton ; elles n’authentifient pas les clients autres que les navigateurs. La limite de deux téléchargements s’applique à un seul processus Node, et la mise en mémoire tampon nécessite de la mémoire pour ces fichiers, ainsi que pour les copies et le surcoût du protocole. Les quotas à l’échelle du déploiement et l’analyse du contenu relèvent de décisions distinctes.
Choisir une solution SSH côté navigateur
Si les utilisateurs ont besoin de leur propre session SSH,
hullarb/ssheasy construit un client Go/WebAssembly avec un proxy
WebSocket vers TCP et une interface web.
c2FmZQ/sshterm est une application Go/WASM complète avec des
transferts SFTP dans les deux sens, la gestion des clés SSH et un point de terminaison WebSocket
tlsproxy.
Suivez le contrat de déploiement et de vérification d’hôte de chaque application. Ce sont des
références d’architecture, pas des bibliothèques installées ou testées par cet exemple. Aucun
benchmark n’est fourni ici : mesurez le coût de démarrage et la latence des transferts pour votre
propre charge de travail.
Le projet historique sftp-ws transporte SFTP v3 sur WebSockets plutôt que sur SSH, et son README avertit qu’il n’est pas suffisamment testé. Ce n’est pas un proxy de transport directement substituable pour un serveur SSH existant. La passerelle HTTP authentifiée ci-dessus constitue le parcours fonctionnel de ce guide.
Pour les importations SFTP dans les Assemblies Transloadit, consultez la documentation de 🤖 /sftp/import (English). Pour les téléversements depuis le navigateur, consultez Uppy.
