Conectar SFTP a navegadores com WebAssembly e WebSockets
Use um gateway Node.js para permitir que um navegador liste e baixe um pequeno catálogo SFTP com acesso autorizado, mantendo as credenciais SSH no servidor. Este tutorial inclui um servidor OpenSSH descartável e um emissor local de tokens, para que você possa testar o caminho completo do navegador ao SFTP na sua própria máquina.
Escolher onde a conexão SSH termina
O SFTP normalmente opera dentro de uma conexão SSH sobre TCP. Um WebSocket no navegador usa um handshake HTTP e transporta quadros WebSocket; conectá-lo à porta 22 não o transforma em um socket SSH. O WebAssembly pode executar código SSH, mas não fornece sockets TCP nativos a uma página web padrão.
Com SSH no navegador, um proxy de transporte fornece conectividade de rede, enquanto o navegador
autentica o host e o usuário SSH. Com um gateway de API HTTP, a conexão SSH termina no gateway, que
autoriza cada operação da aplicação. O exemplo abaixo usa a segunda arquitetura: um catálogo fixo
com um único ID público de arquivo, sem caminhos ou destinos controlados pelo navegador, e com o
escopo sftp:read obrigatório.
Criar o projeto do gateway
O exemplo local foi testado no Linux com Node.js 24.15.0
e 26.8.1, Yarn 4.12.0, OpenSSH 9.2 e Chromium 152.
Este exemplo se destina às versões do Node 24 ou 26 que recebem manutenção; as versões de patch
indicadas registram as versões testadas. O inicializador usa o suporte integrado do Node a
TypeScript. Instale o Docker Engine e as ferramentas do
cliente OpenSSH separadamente e certifique-se de que node, curl, docker e ssh-keygen estejam no seu PATH.
O inicializador usa o socket local do Docker em /var/run/docker.sock; ele nunca inicia um daemon SSH no host.
Cole isto em um shell POSIX. O comando cria um novo diretório e usa um cache e um arquivo de lock
privativos do Yarn, inclusive quando o diretório pai é um projeto Yarn. O subshell mantém seu
diretório atual e suas opções inalterados. Um diretório sftp-browser-demo já existente é recusado.
(
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
)
Crie todos os arquivos a seguir dentro de sftp-browser-demo. Se a instalação falhar, mantenha os
arquivos de pacote, corrija a falha de dependência ou de rede relatada e tente instalar novamente a
partir do diretório pai:
(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)
A nova tentativa também baixa a CLI novamente se o primeiro download tiver falhado. A CLI é salva
como .cjs para que um projeto com módulos ES que contenha esse diretório não possa reinterpretá-la.
Autorizar o acesso e ler o catálogo fixo
Salve isto como index.mjs. A biblioteca SFTP com versão fixada
fornece as APIs de caminho canônico, metadados de arquivo e fluxo. Os arquivos podem estar vazios e
são limitados a 5 MiB. O gateway permite dois downloads ativos por processo. Cada download tem
30 segundos para a conexão SFTP, a leitura e a entrega HTTP. O gateway armazena o arquivo de tamanho
limitado em um buffer antes de enviar uma resposta HTTP de sucesso, para que um erro SFTP após um
progresso parcial continue sendo tratado como um download com falha.
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
Conectar o navegador
Salve isto como public/index.html, criando primeiro o diretório public. O campo de token é
para este exercício local; nenhuma credencial SSH compartilhada é incorporada à página.
<!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>
Salve isto como public/files.js. A página desativa seus campos de entrada durante cada requisição
e mantém no máximo uma URL de objeto para download. Uma requisição de catálogo com falha limpa a
lista anterior.
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)
}
})
Fornecer um servidor SFTP local e um token
Salve isto como sftp-server.Dockerfile. A instalação de pacotes ocorre dentro de uma imagem de contêiner
própria, e o inicializador copia apenas este Dockerfile e a configuração SSH para seu contexto de
build. As chaves e os tokens são gerados depois e nunca entram na imagem.
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"]
Salve isto como sshd_config. Isso substitui por completo a configuração SSH do contêiner de
demonstração. Ela não inclui os arquivos de configuração da distribuição. A conta fica confinada
por chroot, e internal-sftp -R recusa operações de escrita.
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
Salve isto como demo.ts. O inicializador cria novas chaves SSH, calcula o valor de referência
confiável da chave do host local, emite um token RS256 com validade de cinco minutos e inicia os dois
serviços em portas de loopback disponíveis. O token permanece em um arquivo privado. Dois tokens
adicionais permitem testar um escopo incorreto e a expiração. Este emissor demonstra o contrato JWT;
ele não verifica a integração com um provedor de identidade hospedado.
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 })
}
A partir do diretório pai, execute o inicializador em primeiro plano:
node sftp-browser-demo/demo.ts
Abra a URL exibida pelo gateway. Copie o token de sftp-browser-demo/.demo/token.txt
para Your access token e clique em
List available files e
Download Sample file.
Download received. significa que o navegador recebeu todo o corpo HTTP
e iniciou o download. Confira os downloads do navegador para localizar o arquivo salvo; a página
não pode confirmar que você o manteve em disco.
A amostra contém 65.568 bytes binários. Compare o SHA-256 do report.bin salvo com o
Sample SHA-256: exibido pelo inicializador. Salve isto como verify-download.ts dentro do projeto:
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')
Passe o caminho real do arquivo baixado e o resumo criptográfico exibido como argumentos:
node sftp-browser-demo/verify-download.ts "$HOME/Downloads/report.bin" YOUR_PRINTED_SHA256
O código 401 indica que a autenticação foi rejeitada, inclusive no caso de um token
expirado; 403 indica que a origem da requisição, o cabeçalho do cliente, a consulta
ou o escopo foi recusado. O código 502 indica que o gateway não conseguiu confirmar
a leitura SFTP, por exemplo, devido a uma divergência na chave do host, um link simbólico, um arquivo
acima do tamanho permitido ou uma conexão perdida. Teste os tokens de .demo/wrong-scope.txt e .demo/expired.txt:
as duas requisições de catálogo devem falhar. Após a expiração do token, pare e reinicie a
demonstração para obter um novo token.
Pressione Ctrl+C no terminal do inicializador para parar o gateway e remover seu contêiner, a tag
da imagem, as chaves e o token. A demonstração também para após dez minutos. O projeto e o arquivo
baixado que você salvou permanecem. Durante a inicialização, a limpeza espera que o build ou o
comando Docker em andamento termine; cada comando tem um limite de quatro minutos. Pare a
demonstração após a inicialização para seguir o fluxo normal de encerramento imediato.
Uma limpeza interrompida pode deixar .demo para trás. Inspecione .demo/resources.json e verifique
o rótulo sftp.demo.owner do contêiner antes de remover manualmente esse contêiner e a tag da imagem.
Depois, remova .demo; o inicializador se recusa a sobrescrevê-lo na próxima execução.
Adaptar o gateway à sua conta SFTP
Forneça as mesmas variáveis de ambiente exclusivas do servidor usadas por demo.ts, com seu
emissor, público-alvo e chave pública verificados. Defina SFTP_HOST_SHA256 com o SHA-256 da chave
pública SSH bruta do host, verificado pelo administrador e representado por 64 caracteres
hexadecimais minúsculos, conforme exigido pelo
verificador de host do ssh2.
Uma impressão digital SHA256:base64 do OpenSSH usa uma codificação diferente e não pode ser
colada nesse campo.
Somente um publicador confiável pode modificar os arquivos do catálogo; publique arquivos imutáveis
de forma atômica. As verificações de caminho canônico e de lstat() não conseguem impedir
que um agente malicioso com acesso de escrita explore uma condição de corrida antes da abertura
posterior, e uma contagem de bytes não consegue detectar a substituição do conteúdo por outro de
mesmo tamanho. Todo token sftp:read válido pode ler o catálogo configurado inteiro.
Múltiplos tenants precisam de catálogos e contas SSH separados, derivados de registros verificados
de autorização no servidor.
Use HTTPS na borda voltada ao navegador e mantenha a verificação da chave do host SSH. O navegador faz requisições a uma API da mesma origem com um cabeçalho personalizado, e o gateway não habilita acesso CORS. As verificações de origem complementam a autorização por token; elas não autenticam clientes fora do navegador. O limite de dois downloads se aplica a um único processo Node, e o uso de buffers exige memória para esses arquivos, além de cópias e da sobrecarga dos protocolos. As cotas para toda a implantação e a varredura de segurança do conteúdo são decisões separadas.
Escolher uma alternativa com SSH no navegador
Se os usuários precisarem de sua própria sessão SSH, hullarb/ssheasy
compila um cliente Go/WebAssembly junto com um proxy de WebSocket para TCP e um frontend.
c2FmZQ/sshterm é uma aplicação Go/WASM completa com uploads e
downloads SFTP, gerenciamento de chaves SSH e um endpoint WebSocket tlsproxy.
Siga o contrato de implantação e de verificação de host de cada aplicação. Esses projetos são
referências de arquitetura, e não bibliotecas instaladas ou testadas por este exemplo. Não há um
benchmark aqui: meça o custo de inicialização e a latência de transferência para sua própria carga
de trabalho.
O projeto histórico sftp-ws transporta SFTP v3 sobre WebSockets em vez de SSH, e seu README alerta que ele não foi suficientemente testado. Ele não é um proxy de transporte que possa ser usado sem adaptações com um servidor SSH existente. O gateway HTTP autenticado acima é o caminho funcional deste tutorial.
Para importações SFTP em Assemblies da Transloadit, consulte a documentação de 🤖 /sftp/import. Para uploads pelo navegador, consulte o Uppy.
