API segura de upload de imagens com Node.js, Express e Multer
Um upload só deve ficar disponível para download depois que seus bytes passarem pelas suas verificações. Este exemplo recebe um JPEG ou PNG, mantém o arquivo em quarentena enquanto o ClamAV o escaneia, decodifica-o com o Sharp e então disponibiliza o arquivo original por meio de uma rota de download do Express. Arquivos rejeitados ficam fora dessa rota.
O resultado é uma API local de upload de imagens para desenvolvedores que estão testando um pipeline
de upload no lado do servidor. Ela escuta apenas em 127.0.0.1, aceita arquivos de até 5 MiB e preserva os bytes
aceitos, incluindo os metadados da imagem. Ela não tem contas de usuário nem autorização por arquivo.
Mantenha-a local até que sua aplicação forneça esses controles.
Configurando o ambiente Node.js
Use Linux com Node.js 26.8.1, Yarn 4.12.0 via Corepack, Docker e cURL. Estas são as versões e a plataforma usadas aqui. Reserve 4 GiB de memória para o contêiner do ClamAV, além da memória necessária para o Node.js. O daemon do Docker precisa rodar nesta máquina para poder montar o diretório de quarentena.
Crie um novo projeto. A cadeia && é interrompida se o diretório já existir ou se qualquer etapa de
configuração falhar; escolha outro nome de projeto em vez de remover um diretório existente. Mantenha
o lockfile gerado.
mkdir image-upload-api &&
cd image-upload-api &&
printf '%s\n' '{"name":"image-upload-api","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
corepack yarn config set nodeLinker node-modules &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 sharp@0.35.4 express-rate-limit@8.7.0 &&
corepack yarn add --dev --exact @types/express@5.0.6 @types/multer@2.2.0 @types/node@26.6.2
Use o Multer 2.4.0 ou uma versão corrigida posterior. As versões de 2.2.0 a 2.3.0 podem deixar arquivos órfãos quando um cliente se desconecta antes que um callback de armazenamento assíncrono atribua o caminho. O comunicado do mantenedor identifica a 2.4.0 como a correção. O tratamento de erros da aplicação, por si só, não corrige essa condição de corrida da biblioteca.
Varredura de vírus em arquivos enviados
A partir do novo diretório do projeto, inicie um scanner privado. Esta imagem fixada do ClamAV 1.5.4
inclui um banco de dados de assinaturas. O contêiner não tem acesso à rede nem portas publicadas;
apenas a pasta de quarentena é montada, em modo somente leitura. A aplicação invoca clamdscan dentro
dele via Docker.
mkdir -m 700 quarantine accepted &&
docker run --detach --rm --name image-upload-clamav \
--memory 4g --network none --env CLAMAV_NO_FRESHCLAMD=true \
--mount "type=bind,source=$PWD/quarantine,target=/scan,readonly" \
clamav/clamav@sha256:0e31ce089574268aefa0b543767d66b70240ab51ed49eec53e07f18d5629d817
Aguarde o daemon carregar o banco de dados antes de iniciar a API:
docker exec image-upload-clamav clamdscan --ping 120:1 &&
docker exec image-upload-clamav clamdscan --version
A imagem fixada informou o banco de dados 28129, datado de 20 de setembro de 2026, com quatro dias de idade no momento do teste. Desativar o FreshClam torna isto uma demonstração offline, e não um serviço de varredura atualizado continuamente. Um veredito limpo significa que essas assinaturas não detectaram malware; ele não certifica que um arquivo seja inofensivo. Em um serviço implantado, mantenha as assinaturas atualizadas e monitore a saúde do scanner. O guia oficial do Docker explica as atualizações do banco de dados e os requisitos de memória.
Criando a estrutura básica do endpoint da API
Salve o programa completo a seguir como app.ts no diretório do projeto. O Node executa este arquivo
TypeScript diretamente. Inicie-o a partir desse mesmo diretório para que os caminhos do host
correspondam à montagem do scanner.
O scanner aceita apenas um resultado limpo, explícito e bem-sucedido para o arquivo que está sendo
verificado. Um contêiner ausente, um erro do daemon, um tempo esgotado ou uma resposta inesperada
rejeita o upload. --fdpass permite que clamdscan abra o arquivo privado e passe o descritor dele ao daemon
pelo socket Unix do daemon; consulte a
documentação de varredura do ClamAV.
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { link, mkdir, rm, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import express, { type ErrorRequestHandler } from 'express'
import { rateLimit } from 'express-rate-limit'
import multer from 'multer'
import sharp from 'sharp'
process.umask(0o077)
const quarantine = resolve('quarantine')
const accepted = resolve('accepted')
const container = process.env.CLAMAV_CONTAINER ?? 'image-upload-clamav'
const port = Number(process.env.PORT ?? 3000)
const app = express()
let activeUploads = 0
class UploadError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function scanFile(filename: string): Promise<void> {
const path = `/scan/${filename}`
return new Promise((resolveScan, reject) => {
execFile('docker', ['exec', container, 'clamdscan', '--fdpass', '--no-summary', path],
{ timeout: 30_000, maxBuffer: 64 * 1024 }, (error, stdout) => {
const result = stdout.trim()
if (!error && result === `${path}: OK`) return resolveScan()
if (error?.code === 1 && result.startsWith(`${path}: `) && result.endsWith(' FOUND')) {
return reject(new UploadError(422, 'Malware detected.'))
}
reject(new UploadError(503, 'Scanner unavailable or scan inconclusive.'))
})
})
}
const receive = multer({
storage: multer.diskStorage({
destination: quarantine,
filename(_req, _file, callback) {
randomBytes(16, (error, bytes) => {
if (error) return callback(error, '')
callback(null, bytes.toString('hex'))
})
},
}),
limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 0, parts: 2 },
fileFilter(_req, file, callback) {
if (file.mimetype !== 'image/jpeg' && file.mimetype !== 'image/png') {
return callback(new UploadError(415, 'Send a JPEG or PNG image.'))
}
callback(null, true)
},
}).single('image')
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Upload limit reached. Try again after 15 minutes.' },
})
app.disable('x-powered-by')
app.use((_req, res, next) => {
res.set({ 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' })
next()
})
app.post('/upload', uploadLimiter, async (req, res) => {
if (activeUploads >= 2) throw new UploadError(503, 'Two uploads are already processing.')
activeUploads += 1
// An absolute deadline also covers a client that keeps sending tiny chunks.
const deadline = setTimeout(() => res.destroy(), 60_000)
let publishedPath: string | undefined
let committed = false
try {
await new Promise<void>((done, reject) => {
receive(req, res, (error: unknown) => error ? reject(error) : done())
})
if (!req.file) throw new UploadError(400, 'Use the multipart file field named image.')
await scanFile(req.file.filename)
const decoder = sharp(req.file.path, { limitInputPixels: 12_000_000, failOn: 'warning' })
const metadata = await decoder.metadata().catch(() => {
throw new UploadError(415, 'Image headers are invalid or exceed 12 megapixels.')
})
if (metadata.format !== 'jpeg' && metadata.format !== 'png') {
throw new UploadError(415, 'Only JPEG and PNG files are accepted.')
}
const mime = metadata.format === 'jpeg' ? 'image/jpeg' : 'image/png'
if (mime !== req.file.mimetype) throw new UploadError(415, 'Image bytes and MIME type differ.')
await decoder.raw().toBuffer().catch(() => {
throw new UploadError(415, 'Image pixels could not be decoded.')
})
if (req.aborted || res.destroyed) return
const filename = `${req.file.filename}.${metadata.format === 'jpeg' ? 'jpg' : 'png'}`
const destination = resolve(accepted, filename)
// A hard link publishes the complete file atomically and refuses an existing name.
await link(req.file.path, destination)
publishedPath = destination
if (req.aborted || res.destroyed) return
await rm(req.file.path)
res.status(201).json({ filename, size: req.file.size, url: `/images/${filename}` })
committed = true
} finally {
clearTimeout(deadline)
try {
// Multer may already have removed the file and cleared its path after a later part fails.
if (req.file?.path) await rm(req.file.path, { force: true })
if (publishedPath && !committed) await rm(publishedPath, { force: true })
} finally {
activeUploads -= 1
}
}
})
app.get('/images/:filename', (req, res, next) => {
const { filename } = req.params
if (!/^[a-f0-9]{32}\.(jpg|png)$/.test(filename)) {
throw new UploadError(404, 'Image not found.')
}
res.download(resolve(accepted, filename), filename, (error) => {
if (!error) return
if (res.headersSent) return next(error)
next(new UploadError(404, 'Image not found.'))
})
})
const handleError: ErrorRequestHandler = (error: unknown, _req, res, next) => {
if (res.headersSent) return next(error)
if (res.destroyed) return
const status = error instanceof UploadError ? error.status
: error instanceof multer.MulterError ? (error.code === 'LIMIT_FILE_SIZE' ? 413 : 400)
: 500
const message = error instanceof UploadError ? error.message
: status === 413 ? 'File exceeds 5 MiB.' : 'Upload could not be processed.'
console.error('Request failed', { status })
res.status(status).json({ error: message })
}
app.use(handleError)
async function main(): Promise<void> {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be an integer from 1 to 65535.')
}
await mkdir(accepted, { recursive: true, mode: 0o700 })
const probe = `probe-${randomBytes(16).toString('hex')}`
await writeFile(resolve(quarantine, probe), 'Scanner readiness check\n', { flag: 'wx' })
try {
await scanFile(probe)
} finally {
await rm(resolve(quarantine, probe), { force: true })
}
app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) {
console.error('Cannot bind API port; choose an unused PORT.')
process.exitCode = 1
return
}
console.log(`Ready at http://127.0.0.1:${port}`)
})
}
main().catch((error: unknown) => {
console.error(error instanceof UploadError ? error.message
: 'Startup failed. Check PORT, directories, and the scanner mount.')
process.exitCode = 1
})
Verifique os bytes antes de publicá-los
O Multer cuida do envelope multipart e do limite de 5 MiB. O filtro de MIME dele é uma verificação
inicial de um rótulo fornecido pelo cliente. Em seguida, o Sharp compara o formato detectado com
esse rótulo e decodifica os pixels; um arquivo de texto chamado photo.png não passa. O nome original do
arquivo nunca se torna um caminho de armazenamento. Os downloads recebem uma extensão derivada do
formato detectado.
O metadata() do Sharp lê os cabeçalhos sem decodificar os dados de
pixel, e é por isso que o exemplo também chama raw().toBuffer(). O Sharp decodifica a imagem padrão; isso não
valida todos os quadros de animação de um arquivo APNG. O buffer decodificado é descartado, então o
arquivo armazenado permanece idêntico ao upload, byte a byte. O
limite de 12 milhões de pixels e a decodificação estrita reduzem a
exposição de recursos. Eles não são uma sandbox para o decodificador nativo. Mantenha o Sharp e suas
bibliotecas nativas atualizados com as correções. GIF, SVG e outros formatos estão fora das entradas
aceitas por este exemplo.
Dois uploads podem estar em recebimento, varredura ou decodificação ao mesmo tempo. Os uploads
adicionais recebem 503; o prazo de 60 segundos encerra a conexão do cliente; o processamento nativo
já em andamento termina antes que a vaga dele seja liberada. O limitador por IP permite dez
tentativas a cada 15 minutos, incluindo tentativas rejeitadas. Os contadores em memória dele são
zerados junto com o processo e não são compartilhados entre servidores. Nenhum dos dois limites
fornece uma cota por conta nem restringe quantos arquivos aceitos se acumulam ao longo do tempo.
Inicie a API e interprete as falhas
Inicie o servidor em primeiro plano depois que o scanner estiver pronto:
node app.ts
Aguarde Ready at http://127.0.0.1:3000. A inicialização executa uma varredura real no diretório de quarentena montado
antes de abrir a porta. Se a porta 3000 estiver ocupada, escolha outra com PORT=3007 node app.ts e use essa porta
nos comandos cURL. O Express 5 repassa erros de bind para o
callback app.listen; nesse caso, este programa encerra com status de
falha em vez de exibir uma URL de pronto.
| Status | Significado |
|---|---|
201 | Escaneado e decodificado; os bytes originais estão disponíveis na URL retornada. |
400 | Arquivo ausente, campo inesperado ou um limite multipart do Multer diferente do tamanho do arquivo. |
413 | O arquivo excede 5 MiB. |
415 | Tipo MIME não suportado, bytes incompatíveis, imagem inválida ou falha no limite de pixels. |
422 | O ClamAV detectou malware. |
429 | Tentativas de upload demais a partir deste IP. |
503 | Falha do scanner ou dois uploads já em processamento. |
500 | Falha inesperada de parsing, do sistema de arquivos ou do servidor. |
Em requisições rejeitadas comuns, o arquivo é removido da quarentena. O Multer corrigido também lida com gravações abortadas, incluindo o callback assíncrono de nome de arquivo. Um travamento do processo ou um desligamento forçado ainda pode deixar arquivos na quarentena; inspecione e remova esses arquivos somente enquanto a API estiver parada. Depois que o servidor efetiva um arquivo aceito, ele permanece armazenado mesmo que o cliente perca a resposta.
Testando a API com cURL
Abra outro terminal no diretório do projeto. Crie um PNG minúsculo e válido sem precisar baixar uma
imagem de exemplo. Este comando se recusa a sobrescrever um sample.png existente:
node --input-type=module -e '
import { writeFile } from "node:fs/promises";
import sharp from "sharp";
const image = await sharp({ create: { width: 2, height: 2, channels: 3, background: "red" } }).png().toBuffer();
await writeFile("sample.png", image, { flag: "wx" });
'
Faça o upload dele e depois baixe a URL indicada na resposta JSON. A configuração noclobber do shell
faz com que este bloco se recuse a sobrescrever arquivos de resultado já existentes. Use novos nomes
de arquivo em outra execução. cmp não imprime nada e encerra com sucesso quando os bytes baixados
correspondem ao original.
(
set -euC
curl --fail-with-body --silent --show-error \
-F 'image=@sample.png;type=image/png' http://127.0.0.1:3000/upload > upload.json
image_url=$(node --input-type=module -e '
import { readFile } from "node:fs/promises";
const result = JSON.parse(await readFile("upload.json", "utf8"));
if (!/^\/images\/[a-f0-9]{32}\.(jpg|png)$/.test(result.url)) throw new Error("Invalid upload response");
console.log(result.url);
')
curl --fail --silent --show-error "http://127.0.0.1:3000$image_url" > downloaded.png
cmp sample.png downloaded.png
)
Para uma verificação simples de rejeição, envie o manifesto do projeto alegando que ele é um PNG:
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
-F 'image=@package.json;filename=photo.png;type=image/png' http://127.0.0.1:3000/upload
Espere 415, nenhum novo arquivo aceito e um diretório de quarentena vazio após a requisição. Se você
parar o scanner com docker stop image-upload-clamav enquanto a API estiver em execução, uma imagem válida recebe
503 em vez disso. Reinicie o scanner com o comando docker run anterior, mantendo os diretórios existentes do
projeto no lugar, e aguarde até que ele esteja pronto antes de tentar novamente.
Decida o que manter e quem pode baixar
Os dois diretórios ficam fora de qualquer raiz web estática, no mesmo sistema de arquivos, para que a publicação possa usar um hard link. Novos arquivos são privados para a conta do sistema operacional que executa o Node. Os arquivos aceitos têm nomes aleatórios e nunca são sobrescritos; enviar a mesma imagem novamente cria outro arquivo. Pare a API com Ctrl+C e pare o scanner dela ao terminar. Os dois diretórios permanecem no disco para que você os inspecione ou remova deliberadamente.
Os logs de erro contêm códigos de status sem os nomes de arquivo do cliente nem a saída do scanner.
A rota de download serve anexos com nosniff e no-store. Ela não remove EXIF, dados de localização,
bytes finais nem outro conteúdo embutido. O sucesso na varredura e na decodificação é uma afirmação
mais restrita do que a sanitização. Se você precisar de uma imagem pública normalizada, adicione uma
etapa separada de recodificação e verifique a política de metadados e de formato dessa etapa antes
de expor a saída dela.
Antes de conectar este pipeline a uma aplicação pública ou a um armazenamento de objetos privado, adicione autenticação, autorização por arquivo, cotas de armazenamento e uma política de retenção. URLs aleatórias e CORS não fornecem verificações de propriedade. O adaptador de comandos Docker é conveniente para uma demonstração local, mas dá ao processo do Node acesso ao daemon do Docker; em um serviço implantado, use uma integração de scanner dedicada com privilégios mais restritos. Este tutorial não configura nem verifica um caminho de armazenamento em nuvem.
