Importar arquivos do Amazon S3 com segurança em navegadores
Mantenha as credenciais da AWS no servidor ou na máquina do operador, emita uma URL GET pré-assinada de curta duração para um único objeto autorizado e deixe o navegador buscar seus bytes no Amazon S3. Este exemplo importa um objeto de até 1 MiB e verifica seu conteúdo completo com base no tamanho em bytes e no SHA-256 de uma fonte confiável antes de informar o sucesso da operação.
Por que importar diretamente do Amazon S3 no navegador?
O arquivo vai do S3 para o navegador, portanto o servidor da sua aplicação não retransmite o corpo da resposta. O servidor ainda decide quem pode receber uma URL para cada objeto. Este é um pequeno exemplo executado por um operador, com transferência manual do tíquete gerado para uma página local; ele não implementa login na aplicação nem um endpoint público de assinatura. Uma aplicação em produção deve autenticar quem faz a chamada e autorizar o acesso ao objeto específico antes de emitir sua URL.
Uma URL pré-assinada é uma autorização ao portador: qualquer pessoa que a obtenha pode usá-la enquanto ela permanecer válida, inclusive fora de um navegador. Ela pode ser reutilizada. O CORS controla se o JavaScript de outra origem pode ler a resposta; as permissões do S3 e a assinatura controlam o acesso ao objeto.
Configurar seu bucket S3
Use um bucket S3 privado de uso geral em us-east-1, com Block Public Access ativado e um
objeto pequeno já existente. Tenha em mãos o nome do bucket, a chave exata, o ID de 12 dígitos da conta
do proprietário do bucket, o tamanho em bytes e o SHA-256 do registro de upload do produtor confiável.
O tamanho aceito vai de zero a 1.048.576 bytes, inclusive. A soma de verificação deve corresponder aos
bytes do objeto armazenado, em vez de ser um hash calculado a partir do que o navegador receber.
Neste exemplo, use um objeto sem codificação de conteúdo HTTP, para que a decodificação do navegador
não altere esses bytes.
O operador precisa de uma fonte de credenciais do AWS SDK já configurada, com permissão para ler
esse objeto. Para esta leitura sem especificar uma versão, isso inclui s3:GetObject no ARN exato do objeto.
Outros requisitos podem se aplicar, como permissões do KMS para objetos SSE-KMS; consulte os
requisitos de autorização do GetObject.
Não torne o objeto público para corrigir uma falha de assinatura.
Para um bucket dedicado a testes, use esta configuração CORS em JSON para a página local. Em um bucket existente, incorpore a regra à configuração e inspecione as outras regras em busca de origens mais abrangentes; substituir a configuração pode prejudicar outras aplicações. O exemplo envia GET sem cabeçalhos personalizados e não inspeciona o ETag, portanto não precisa de cabeçalhos de requisição com curinga nem de cabeçalhos de resposta expostos. Consulte os elementos CORS do S3.
[
{
"AllowedOrigins": ["http://127.0.0.1:8787"],
"AllowedMethods": ["GET"],
"MaxAgeSeconds": 0
}
]
Uma origem inclui o esquema, o nome do host e a porta. localhost e 127.0.0.1 são origens diferentes.
Use a URL exata impressa pelo servidor local abaixo e ajuste a regra CORS se escolher outra porta.
Em produção, sirva a aplicação por HTTPS e permita sua origem HTTPS exata. Ativar o
CORS mantém as ACLs e as políticas do S3 em vigor.
Usar o AWS SDK para JavaScript
Use uma versão do Node.js 26 que ainda receba manutenção e esteja com as correções em dia, o Corepack instalado separadamente e um navegador atual com fluxos Fetch e Web Crypto. Os scripts de configuração e os scripts do operador foram verificados com Node.js 26.12.1, Corepack 0.36.0, Yarn 4.12.0 e pacotes do AWS SDK 3.1073.0; a página foi verificada no Chromium 145. Mantenha o Node atualizado com as correções indicadas nas notas de versões de segurança. O navegador usa APIs da plataforma e não recebe nenhuma chave de acesso secreta da AWS.
Em um diretório com permissão de escrita, cole o conteúdo a seguir no Bash. Ele cria um novo projeto
e mantém seu shell no diretório original. Ele não prossegue se o diretório s3-browser-import já existir.
A configuração local do Yarn seleciona deliberadamente uma instalação local de node_modules e ignora
um eventual caminho do executável do Yarn definido por um projeto que englobe esse diretório.
(
command -v node >/dev/null &&
command -v corepack >/dev/null &&
mkdir s3-browser-import &&
cd s3-browser-import &&
cat > package.json <<'JSON' &&
{
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@aws-sdk/client-s3": "3.1073.0",
"@aws-sdk/s3-request-presigner": "3.1073.0"
}
}
JSON
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'ignorePath: true' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn@4.12.0 install
)
Se a instalação falhar, o novo diretório será mantido para inspeção. Salve os próximos três arquivos
em s3-browser-import. Execute os comandos seguintes nesse diretório.
Emitir um tíquete na máquina do operador
Salve este conteúdo como sign.ts. Seus argumentos são bucket, chave, proprietário do bucket,
tamanho esperado em bytes, SHA-256 esperado e prazo de validade em segundos. Este assinador aceita
nomes de bucket compostos por letras minúsculas, dígitos e hífens. O prazo de validade deve ser um
inteiro de 1 a 300. Todos os metadados vêm do registro confiável do objeto; a assinatura, por si só,
não verifica se o objeto existe nem se o acesso será bem-sucedido.
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const [bucket, key, owner, sizeText, sha256, expiryText] = args
const size = Number(sizeText)
const expiresIn = Number(expiryText)
if (
args.length !== 6 || !bucket || !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket) ||
!key || new TextEncoder().encode(key).length > 1024 || !/^\d{12}$/.test(owner ?? '') ||
!/^\d+$/.test(sizeText ?? '') || !Number.isSafeInteger(size) || size > 1_048_576 ||
!/^[a-f0-9]{64}$/.test(sha256 ?? '') || !/^\d+$/.test(expiryText ?? '') ||
!Number.isSafeInteger(expiresIn) || expiresIn < 1 || expiresIn > 300
) {
throw new Error('Invalid ticket arguments')
}
const client = new S3Client({
region: 'us-east-1',
endpoint: 'https://s3.us-east-1.amazonaws.com',
forcePathStyle: true,
})
try {
const command = new GetObjectCommand({ Bucket: bucket, Key: key, ExpectedBucketOwner: owner })
const url = await getSignedUrl(client, command, { expiresIn })
process.stdout.write(`${JSON.stringify({ url, size, sha256 })}\n`)
} finally {
client.destroy()
}
}
main().catch(() => {
console.error('Could not issue ticket. Check the six arguments and operator credentials.')
process.exitCode = 1
})
Defina estas variáveis do shell conforme seu registro confiável: S3_BUCKET, S3_KEY, S3_OWNER, S3_BYTES e
S3_SHA256. Em seguida, execute o ponto de entrada exatamente como mostrado, que imprime um tíquete JSON:
node sign.ts "$S3_BUCKET" "$S3_KEY" "$S3_OWNER" "$S3_BYTES" "$S3_SHA256" 60
O pré-assinador de requisições do SDK usa a fonte de credenciais do operador. Mantenha essa fonte na máquina do operador. Não cole credenciais na página nem adicione um endpoint de assinatura que aceite nomes de bucket e chaves arbitrários sem autorização.
Importar e verificar o objeto no navegador
Salve esta página completa como index.html. O tamanho indicado no tíquete limita o buffer do objeto
na aplicação; o fluxo deve terminar exatamente nesse tamanho, e seu SHA-256 deve corresponder ao
esperado antes que a função auxiliar retorne os bytes. Um temporizador de 30 segundos solicita o
cancelamento durante a requisição e as leituras do corpo da resposta. Mantenha a aba ativa durante a
importação; abas em segundo plano podem atrasar temporizadores.
Uma transferência mais lenta pode falhar mesmo abaixo do limite de tamanho. Apenas uma importação
é executada por vez.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light dark">
<title>Private S3 import</title>
<link rel="icon" href="data:,">
<h1>Private S3 import</h1>
<form id="import-form">
<label for="ticket">Import ticket</label>
<textarea id="ticket" rows="6" cols="30" autocomplete="off" spellcheck="false"></textarea>
<button type="submit">Import</button>
</form>
<p role="status" id="status">Ready.</p>
<script>
async function importObject(ticket) {
if (!ticket || typeof ticket.url !== 'string' ||
!Number.isSafeInteger(ticket.size) || ticket.size < 0 || ticket.size > 1_048_576 ||
typeof ticket.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(ticket.sha256)) {
throw new Error('Invalid ticket')
}
const url = new URL(ticket.url)
if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
throw new Error('Invalid object URL')
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 30_000)
let reader
try {
const response = await fetch(url.href, {
mode: 'cors', credentials: 'omit', cache: 'no-store', redirect: 'error',
referrerPolicy: 'no-referrer', signal: controller.signal,
})
if (!response.ok || !response.body) throw new Error('Object read failed')
reader = response.body.getReader()
const bytes = new Uint8Array(ticket.size)
let offset = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value.length > bytes.length - offset) throw new Error('Object exceeds expected size')
bytes.set(value, offset)
offset += value.length
}
if (offset !== bytes.length) throw new Error('Incomplete object')
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
const actual = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')
if (actual !== ticket.sha256) throw new Error('Object checksum mismatch')
return bytes
} finally {
clearTimeout(timer)
controller.abort()
if (reader) {
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
}
const form = document.getElementById('import-form')
const input = document.getElementById('ticket')
const button = form.querySelector('button')
const status = document.getElementById('status')
let busy = false
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (busy) return
busy = true
button.disabled = true
input.disabled = true
status.textContent = 'Importing…'
const raw = input.value
input.value = ''
try {
const bytes = await importObject(JSON.parse(raw))
status.textContent = `Imported ${bytes.length} verified bytes.`
} catch {
status.textContent = 'Import failed. Check the ticket, object metadata, expiry, and CORS.'
} finally {
busy = false
button.disabled = false
input.disabled = false
}
})
</script>
</html>
importObject retorna um Uint8Array para sua aplicação consumir; o formulário demonstra o resultado
verificado como uma contagem de bytes. Ele não salva um arquivo em disco nem mantém os bytes
importados após o término do manipulador. Ele limpa o tíquete colado e não usa armazenamento do
navegador, scripts de terceiros nem URLs de objeto. O navegador e a implementação de hash ainda têm
seus próprios buffers, portanto 1 MiB é um limite para o objeto, e não um teto para o uso total de
memória do navegador.
Servir a página localmente
Salve este conteúdo como serve.ts. Ele serve apenas a página na interface de loopback e desativa
o cache de respostas. Ele não expõe as credenciais do operador nem implementa a assinatura.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const port = args.length === 0 ? 8787 : Number(args[0])
if (args.length > 1 || (args.length === 1 && !/^\d+$/.test(args[0])) ||
!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('Invalid port')
}
const html = await readFile(new URL('./index.html', import.meta.url))
const server = createServer((request, response) => {
const ok = request.method === 'GET' && request.url === '/'
response.writeHead(ok ? 200 : 404, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
})
response.end(ok ? html : 'Not found')
})
server.on('error', () => {
console.error('Could not start server. Check whether the port is available.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address !== 'string') {
console.log(`Open http://127.0.0.1:${address.port}/`)
}
})
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']
for (const signal of signals) {
process.once(signal, () => {
server.close()
server.closeAllConnections()
})
}
}
main().catch(() => {
console.error('Could not start server. Check the port and index.html.')
process.exitCode = 1
})
node serve.ts 8787
Abra http://127.0.0.1:8787/, cole o JSON recém-gerado em Import ticket
e selecione Import. Uma importação bem-sucedida mostra
Imported 16 verified bytes. para um objeto de 16 bytes, ou a contagem real
de bytes do objeto. Ao terminar, pare o servidor em primeiro plano com Ctrl+C. Use outra porta se
8787 estiver ocupada e atualize tanto a URL da página quanto a regra CORS do bucket.
Dicas de otimização de desempenho
Este exemplo lê um objeto com tamanho limitado para a memória e calcula seu hash antes do uso. Limite as importações simultâneas se integrar a função auxiliar a uma aplicação maior. Para arquivos que ultrapassem o orçamento de memória dessa aplicação, escolha um consumidor de fluxo ou um download normal pelo navegador; abrir uma URL de download não fornece ao JavaScript os bytes importados nem confirma que um arquivo foi salvo.
Boas práticas de segurança
Trate todo o tíquete como informação sensível. Passe-o apenas ao destinatário pretendido por um canal protegido; mantenha-o fora de URLs da aplicação, ferramentas de análise, logs, histórico compartilhado da área de transferência e armazenamento do navegador. Limpar o formulário não revoga cópias já feitas. A string de consulta contém informações de assinatura e pode incluir um token de sessão temporário, embora não contenha a chave de acesso secreta da AWS. Mantenha a fonte de credenciais do operador separada do diretório da página e evite gravar a sessão do terminal ao exibir tíquetes.
O S3 verifica a validade da URL quando uma requisição começa. Um download em andamento pode continuar após a expiração; reiniciá-lo após a expiração pode falhar. Credenciais temporárias podem reduzir a vida útil da URL, e alterações de permissão também podem impedir o acesso. A URL de 60 segundos do exemplo não é um mecanismo para apagar dados baixados. A soma de verificação do tíquete usado neste exemplo detecta divergências em relação ao registro do produtor confiável; ela não estabelece a confiabilidade de um tíquete fornecido por um invasor.
Problemas comuns e soluções
- A emissão do tíquete falha: Verifique os seis argumentos, os metadados esperados e a fonte de credenciais do operador. Não mova credenciais para o navegador para contornar a falha.
- O navegador informa uma falha: A mensagem pública agrupa falhas deliberadamente. Verifique se o tíquete expirou, se o assinador consegue ler a chave exata com o ID de proprietário fornecido e se a origem da página corresponde ao CORS. Uma resposta bloqueada pelo CORS pode ocultar seu status HTTP do JavaScript; alterar o CORS não concede as permissões que faltam no S3.
- Uma resposta legível ainda falha na verificação: Confirme o tamanho e o SHA-256 informados pelo produtor, verifique se houve substituição do objeto ou codificação de conteúdo HTTP e emita um novo tíquete para os bytes pretendidos. Uma resposta truncada, uma divergência na soma de verificação ou uma transferência cancelada não deve ser informada como um objeto importado.
Conclusão
Para importações gerenciadas pela aplicação que são executadas nos servidores da Transloadit em vez do navegador, consulte nosso Robot de importação do S3. Neste fluxo de trabalho no navegador, mantenha a autorização de acesso ao objeto sob a responsabilidade do emissor e passe apenas o tíquete de curta duração ao destinatário.
