Uploads de arquivos AJAX seguros com sessões e verificações CSRF
Um upload AJAX só é aceito quando o servidor verificou a sessão, a permissão, a requisição e o conteúdo do arquivo. Este passo a passo oferece um formulário de navegador executável e um servidor Node.js que aplicam essas verificações, informam o progresso do upload e permitem que cada usuário baixe apenas os próprios arquivos aceitos.
Defina o que o servidor aceita
O exemplo faz upload de anexos de nota em JSON, não de imagens ou documentos arbitrários. Cada
arquivo deve conter um objeto JSON em UTF-8 com exatamente uma propriedade, message, cujo valor é uma
string que não esteja em branco. Salve isto como note.json ao testar o formulário:
{"message":"Hello from an AJAX upload."}
O servidor aceita exatamente um campo multipart chamado file, nenhum campo extra e um arquivo de no
máximo 64 KiB. Ele também limita todo o corpo multipart a 80 KiB, incluindo delimitadores e
cabeçalhos. Os dois limites são aplicados aos bytes recebidos. Renomear um PNG para note.json não o
transforma em JSON válido. Por outro lado, um conteúdo de nota válido chamado note.png, ou enviado com
um tipo MIME incorreto, é aceito: este exemplo ignora deliberadamente esses dois metadados do
cliente e entrega todo arquivo aceito para download como note.json com application/json.
Esta é uma política de conteúdo para uma aplicação específica. Analisar o JSON não prova que um arquivo está livre de malware, e uma string contendo HTML continua sendo um dado não confiável. O exemplo nunca renderiza essa string. Para tipos de arquivo mais amplos, escolha regras separadas de validação e processamento; uma lista de tipos MIME permitidos ou uma assinatura de arquivo, por si só, não basta. Veja as orientações de upload da OWASP.
Configure a demonstração local
Use o Node.js 26 e um navegador atual. O exemplo abaixo foi testado no Linux com Node.js 26.8.1 e Chromium 152. Ele não tem dependências de pacotes. Crie um novo diretório a partir de um shell POSIX:
mkdir ajax-upload-demo && cd ajax-upload-demo
Se esse comando falhar, pare e escolha um novo diretório; não sobrescreva um projeto existente.
Salve os próximos três arquivos dentro dele: server.ts, index.html e client.js.
O servidor escuta apenas em 127.0.0.1 em uma porta disponível e imprime essa URL, além de senhas novas
para alice e bob. Essas são identidades locais descartáveis. Qualquer pessoa com a senha
impressa pode agir como esse usuário. Cada login substitui a sessão anterior desse usuário, e as
sessões expiram após 15 minutos. Os uploads permanecem na memória até o processo terminar, com no
máximo dez arquivos por usuário. Uploads repetidos criam entradas separadas; eles nunca substituem
um anexo anterior.
Aplique as regras no servidor
Salve isto como server.ts. Os únicos arquivos públicos são os dois assets de cliente servidos
explicitamente. A autenticação e o token CSRF da sessão são verificados antes de ler o corpo de um
upload. Os downloads procuram o ID no mapa do próprio usuário autenticado, então outro usuário
recebe o mesmo 404 que receberia para um ID desconhecido.
import { randomBytes, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface DemoUser {
name: string
password: string
session: string
csrf: string
expires: number
files: Map<string, Buffer>
}
const token = (): string => randomBytes(32).toString('hex')
const users: DemoUser[] = ['alice', 'bob'].map((name) => ({
name, password: token(), session: '', csrf: '', expires: 0, files: new Map(),
}))
const html = await readFile(new URL('./index.html', import.meta.url))
const script = await readFile(new URL('./client.js', import.meta.url))
let origin = ''
let cookieName = ''
class Rejection extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function readBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
let size = 0
// Keep the socket writable so an oversized request can receive a 413 response.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > 80 * 1024) throw new Rejection(413, 'Request exceeds 80 KiB.')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
async function noteBytes(req: IncomingMessage): Promise<Buffer> {
const body = await readBody(req)
const contentType = req.headers['content-type'] ?? ''
if (!/^multipart\/form-data\s*;/i.test(contentType)) {
throw new Rejection(415, 'Use multipart/form-data.')
}
let form: FormData
try {
form = await new Response(new Uint8Array(body), {
headers: { 'Content-Type': contentType },
}).formData()
} catch {
throw new Rejection(400, 'Malformed multipart body.')
}
const entries = [...form.entries()]
const file = form.get('file')
if (entries.length !== 1 || !(file instanceof File)) {
throw new Rejection(400, 'Send exactly one file field and no other fields.')
}
if (file.size > 64 * 1024) throw new Rejection(413, 'File exceeds 64 KiB.')
const bytes = Buffer.from(await file.arrayBuffer())
let value: unknown
try {
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
} catch {
throw new Rejection(422, 'File must contain a UTF-8 JSON note.')
}
if (
typeof value !== 'object' || value === null || Array.isArray(value) ||
Object.keys(value).length !== 1 || !('message' in value) ||
typeof value.message !== 'string' || value.message.trim().length === 0
) {
throw new Rejection(422, 'Use an object with one nonblank message string.')
}
return bytes
}
function sessionData(user: DemoUser): unknown {
return {
user: user.name,
csrf: user.csrf,
files: [...user.files].map(([id, bytes]) => ({ id, bytes: bytes.length })),
}
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'GET' && (req.url === '/' || req.url === '/client.js')) {
res.setHeader('Content-Type', req.url === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(req.url === '/' ? html : script)
return
}
if (req.method === 'POST' && req.headers.origin !== origin) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'POST' && req.url?.startsWith('/login/')) {
const user = users.find((entry) => `/login/${entry.name}` === req.url)
if (!user || req.headers['x-demo-password'] !== user.password) {
throw new Rejection(401, 'Invalid demo credentials.')
}
user.session = token()
user.csrf = token()
user.expires = Date.now() + 15 * 60 * 1000
res.setHeader('Set-Cookie',
`${cookieName}=${user.session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=900`)
json(res, 200, sessionData(user))
return
}
const session = req.headers.cookie?.split(';').map((part) => part.trim())
.find((part) => part.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1)
const user = users.find((entry) => entry.session === session && entry.expires > Date.now())
if (!user) throw new Rejection(401, 'Log in again.')
if (req.method === 'GET' && req.url === '/session') {
json(res, 200, sessionData(user))
return
}
if (req.method === 'GET' && req.url?.startsWith('/files/')) {
const bytes = user.files.get(req.url.slice('/files/'.length))
if (!bytes) throw new Rejection(404, 'File not found.')
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Disposition': 'attachment; filename="note.json"',
})
res.end(bytes)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
throw new Rejection(404, 'Route not found.')
}
if (req.headers['x-csrf-token'] !== user.csrf) {
throw new Rejection(403, 'Refresh your session before uploading.')
}
const bytes = await noteBytes(req)
// A second login or session expiry during transfer must invalidate this request too.
if (user.session !== session || user.expires <= Date.now()) {
throw new Rejection(401, 'Log in again.')
}
if (user.files.size >= 10) throw new Rejection(409, 'Demo storage is full. Restart to clear it.')
const id = randomUUID()
user.files.set(id, bytes)
json(res, 201, { id, bytes: bytes.length })
}
const server = createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, (req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Content-Security-Policy',
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
void handle(req, res).catch((error: unknown) => {
const status = error instanceof Rejection ? error.status : 500
const message = error instanceof Rejection ? error.message : 'Unable to handle the request.'
if (status === 500) console.error('Request failed unexpectedly.')
res.setHeader('Connection', 'close')
json(res, status, { error: message })
req.resume()
})
})
server.maxConnections = 16
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
cookieName = `ajax_demo_${address.port}`
console.log(`Open ${origin}`)
for (const user of users) console.log(`${user.name} password: ${user.password}`)
})
O limite de corpo é aplicado antes que o parser multipart do Node armazene em buffer os campos
individuais. A opção destroyOnReturn: false permite
que uma rejeição antecipada por tamanho envie sua resposta HTTP antes de fechar a conexão. Nada é
inserido no armazenamento até que todas as validações sejam bem-sucedidas; corpos rejeitados não
deixam entrada retida nem arquivo em disco.
O token retornado por /session é separado do cookie de sessão HttpOnly. O navegador o envia em
X-CSRF-Token, e o servidor o compara com o token pertencente àquela sessão. Uma verificação exata de
Origin também protege as requisições POST, incluindo o login. Nenhum acesso CORS é concedido. Essas
escolhas seguem o padrão de token sincronizador;
SameSite adiciona outra camada, mas não substitui a verificação do token.
Adicione o formulário do navegador
Salve isto como index.html. Use o seletor de arquivos e os botões de envio nativos para que o
formulário funcione com teclado. Este exemplo seleciona intencionalmente um arquivo por vez;
adicionar arrastar e soltar ou uma fila de envio em lote deve preservar o mesmo contrato do
servidor.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AJAX note upload</title>
<script type="module" src="/client.js"></script>
</head>
<body>
<h1>Upload a private JSON note</h1>
<p>Files live in server memory until restart. Select one JSON note, up to 64 KiB.</p>
<fieldset id="controls">
<legend>Demo session and upload</legend>
<form id="login">
<label for="user">Demo user</label>
<select id="user"><option>alice</option><option>bob</option></select>
<label for="password">Password from the server terminal</label>
<input id="password" type="password" autocomplete="current-password" required />
<button>Log in</button>
</form>
<form id="upload">
<label for="file">JSON note</label>
<input id="file" type="file" accept=".json,application/json" required />
<button>Upload</button>
</form>
<button id="refresh" type="button">Refresh accepted files</button>
</fieldset>
<p id="identity">Not logged in.</p>
<label id="progress-label" for="progress">Request bytes transferred</label>
<progress id="progress" aria-labelledby="progress-label" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite"></p>
<h2>Your accepted files</h2>
<ul id="files"></ul>
</body>
</html>
Envie o arquivo e aguarde a aceitação
Salve isto como client.js. O Fetch cuida das requisições de sessão; XMLHttpRequest cuida do upload
porque expõe o progresso de upload da requisição.
Uma barra de progresso cheia significa que o corpo da requisição foi enviado, não que o servidor
aceitou o arquivo. Somente um HTTP 201 de /upload cria um link de download.
const controls = document.getElementById('controls')
const login = document.getElementById('login')
const upload = document.getElementById('upload')
const user = document.getElementById('user')
const password = document.getElementById('password')
const fileInput = document.getElementById('file')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const identity = document.getElementById('identity')
const files = document.getElementById('files')
let csrf = ''
let busy = false
function failure(code) {
const messages = {
400: 'Send exactly one file and no extra fields.',
401: 'Log in with the password from the server terminal.',
403: 'Session check failed. Refresh accepted files or log in again.',
409: 'Demo storage is full. Restart the server to clear it.',
413: 'Upload exceeds a size limit. Choose a smaller file.',
415: 'The server requires multipart form data.',
422: 'Choose a UTF-8 JSON object with one nonblank message string.',
}
return new Error(messages[code] ?? 'Acceptance is unconfirmed. Refresh accepted files before retrying.')
}
async function run(action) {
if (busy) return
busy = true
controls.disabled = true
try {
await action()
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Request failed.'
} finally {
busy = false
controls.disabled = false
}
}
function addFile(file) {
const item = document.createElement('li')
const link = document.createElement('a')
link.href = `/files/${encodeURIComponent(file.id)}`
link.textContent = `Download ${file.id} (${file.bytes.toLocaleString()} bytes)`
item.append(link)
files.append(item)
}
async function loadSession(response) {
if (!response.ok) {
if (response.status === 401) {
csrf = ''
identity.textContent = 'Not logged in.'
files.replaceChildren()
}
throw failure(response.status)
}
const data = await response.json()
csrf = data.csrf
identity.textContent = `Logged in as ${data.user}.`
files.replaceChildren()
for (const file of data.files) addFile(file)
}
login.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const response = await fetch(`/login/${encodeURIComponent(user.value)}`, {
method: 'POST',
headers: { 'X-Demo-Password': password.value },
credentials: 'same-origin',
})
password.value = ''
await loadSession(response)
status.textContent = 'Logged in. Choose a note to upload.'
})
})
function sendFile(file) {
return new Promise((resolve, reject) => {
const body = new FormData()
body.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress.value = event.loaded / event.total * 100
}
xhr.upload.onload = () => { status.textContent = 'Transferred. Waiting for server acceptance…' }
xhr.open('POST', '/upload')
xhr.setRequestHeader('X-CSRF-Token', csrf)
xhr.responseType = 'json'
xhr.timeout = 45_000
xhr.onload = () => {
if (xhr.status === 201 && typeof xhr.response?.id === 'string') resolve(xhr.response)
else reject(failure(xhr.status))
}
xhr.onerror = xhr.ontimeout = xhr.onabort = () => reject(failure(0))
xhr.send(body)
})
}
upload.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const file = fileInput.files[0]
if (!csrf) throw failure(401)
if (!file) throw new Error('Choose a file first.')
if (file.size > 64 * 1024) throw failure(413)
progress.value = 0
status.textContent = 'Uploading…'
const accepted = await sendFile(file)
addFile(accepted)
status.textContent = 'Accepted into private memory. Use the download link to check the bytes.'
})
})
async function refresh() {
await loadSession(await fetch('/session', { credentials: 'same-origin' }))
status.textContent = 'Accepted file list refreshed.'
}
document.getElementById('refresh').addEventListener('click', () => { void run(refresh) })
void run(refresh)
Deixe o navegador definir Content-Type ao enviar FormData: ele precisa incluir o delimitador
multipart gerado. O MDN explica por que defini-lo manualmente quebra a requisição.
O valor accept do seletor e a verificação de tamanho no cliente oferecem feedback antecipado; nenhum
dos dois é um controle de segurança do servidor. Enquanto qualquer requisição estiver pendente, o
fieldset fica desabilitado e uma trava busy ignora envios repetidos. O File selecionado é
capturado antes de o upload começar.
Teste a aceitação e a rejeição
No diretório que contém os três arquivos, inicie o servidor:
node server.ts
Abra exatamente a URL impressa, faça login como alice, selecione note.json e acione Upload.
Depois de “Accepted into private memory”, use o link de download. Ele retorna os bytes originais,
incluindo espaços em branco, como anexo. Seu navegador decide se pede um destino ou escolhe um novo
nome de arquivo quando note.json já existe; clicar no link, por si só, não confirma que um arquivo foi
salvo.
Teste um arquivo contendo {"message":42}: a barra pode encher, mas o servidor retorna 422, a página
explica o conteúdo exigido e a lista de aceitos não ganha nenhuma entrada. Em uma janela anônima
separada do navegador, faça login como bob e abra a URL de download da Alice. Ela retorna 404.
Sem sessão, ela retorna 401. Os erros de aplicação do servidor contêm mensagens fixas, em vez de
detalhes do parser, caminhos ou conteúdo enviado.
| Resposta | Significado e próxima ação |
|---|---|
201 | Aceito e retido neste processo; disponível para o dono. |
400 / 415 | Corrija a requisição multipart ou seus campos. |
401 / 403 | Restaure a sessão ou a evidência CSRF antes de outro upload. |
413 | Um limite fixo de tamanho foi excedido. Escolha um arquivo menor. |
422 | Corrija o conteúdo do arquivo. |
409 | Já há dez arquivos armazenados para este usuário. Reiniciar apaga todos os dados da demonstração. |
| Erro de rede, timeout ou outro status | A aceitação não foi confirmada. Atualize a lista de aceitos antes de decidir o que fazer. |
Não há novas tentativas automáticas, inclusive para 413 ou uma resposta perdida. Se o servidor
armazenou o arquivo, mas a resposta se perdeu, atualizar a lista revela a nova entrada; baixe-a
para identificar seus bytes. Fazer o upload manualmente de novo cria um segundo ID. Novas tentativas
em produção exigem um contrato de deduplicação persistente e com escopo por usuário antes de
poderem repetir um upload com segurança. Este servidor não anuncia falhas transitórias nem
Retry-After.
Conecte o exemplo à sua aplicação
Pare a demonstração com Ctrl+C; arquivos aceitos, senhas e sessões são descartados. Para a
implantação, substitua as identidades locais e os mapas em memória pela autenticação, autorização,
middleware CSRF e armazenamento durável privado da sua aplicação. Use HTTPS e um cookie de sessão
Secure. O cookie HTTP da demonstração se restringe a este exercício de loopback; os atributos de cookie têm funções distintas.
Defina limites de taxa e de concorrência específicos da implantação, além de cotas de
armazenamento. Os limites de tamanho de requisição e de conexões da demonstração não substituem
esses controles.
Arquivos maiores precisam de um parser com streaming e de um caminho de armazenamento em vez deste parser em memória limitado. Se você precisa poder retomar uploads, use um servidor tus e um cliente compatível. Dividir um arquivo em blocos, por si só, não implementa autorização, remontagem, limpeza nem requisições repetidas seguras; este exemplo intencionalmente não tem endpoints de blocos.
