Verifique uploads na API com números mágicos no Node.js
Um upload chamado avatar.png não é necessariamente um PNG. Este passo a passo executa um endpoint
local em Node.js que identifica uploads PNG e JPEG pelos seus bytes, rejeita outros tipos detectados
e impõe limites de bytes para o arquivo e para a requisição. Uma resposta bem-sucedida significa que
a assinatura correspondeu à lista de permissões, não que a imagem seja válida ou segura.
Por que validar a extensão do arquivo não basta
O nome do arquivo e o Content-Type da parte do arquivo são fornecidos pelo cliente. Renomear um
arquivo ou declarar image/png não muda o seu conteúdo. O endpoint abaixo ignora ambos
deliberadamente ao decidir se o tipo detectado é permitido; ele nunca usa um nome de arquivo do
cliente como caminho de armazenamento. A orientação da OWASP sobre uploads
explica por que tipos MIME informados pelo cliente não podem ser uma fronteira de segurança.
Entenda números mágicos e assinaturas de arquivo
Números mágicos são sequências de bytes reconhecíveis que sugerem o formato de um arquivo. Um PNG
começa com 89 50 4E 47 0D 0A 1A 0A; um JPEG começa com FF D8 FF. Uma biblioteca de detecção
conhece mais detalhes de formato do que uma verificação curta de prefixo escrita à mão, mas ainda
assim não decodifica a imagem completa.
A documentação do file-type
descreve a detecção como uma indicação de melhor esforço, não como prova de validade do arquivo.
Alguns arquivos danificados não produzem correspondência ou lançam uma exceção; outros ainda têm uma
assinatura reconhecível. Não chame o resultado de valid nem o use como veredito de malware.
Implemente a validação por números mágicos no Node.js
Use Bash no Linux, cURL, Node.js 24.15.0 ou uma versão mais recente da linha 24 LTS, e Corepack com Yarn 4.12.0 disponível. O exemplo foi testado com Node.js 24.15.0 e 26.8.1. Para o deploy, use uma versão LTS atual e com patches aplicados, em vez de tratar o mínimo testado como recomendação de atualização de segurança.
Comece em um diretório com permissão de escrita. Isto cria um novo projeto magic-upload sem alterar o
diretório de trabalho do seu shell. O script se recusa a usar um diretório de projeto existente e
para antes da instalação se a criação ou a navegação falhar. O yarn.lock local estabelece um
projeto Yarn separado; o linker node-modules permite que o comando node simples resolva suas
dependências, inclusive dentro de um projeto pai com Yarn Plug’n’Play.
(
mkdir magic-upload &&
cd magic-upload &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
YARN_NODE_LINKER=node-modules corepack yarn add --exact \
express@4.22.2 file-type@22.0.0 multer@2.4.0
)
O Multer 2.4.0 fornece a opção streamHandler usada abaixo e inclui uma
correção de segurança na limpeza de uploads.
Mantenha as dependências de upload com patches em dia ao adaptar o exemplo.
Valide uploads com limite de tamanho usando Express.js
Salve o programa completo como magic-upload/server.mts. A
remoção nativa de tipos do Node executa este arquivo .mts
como um módulo ES, sem compilador nem loader de TypeScript. Ela não verifica os tipos do programa
nem lê um tsconfig.json em um diretório superior.
O primeiro middleware usa express.raw
para ler o corpo completo da requisição com um limite de 5 MiB + 16 KiB, antes do parsing
multipart. Isso cobre delimitadores, cabeçalhos das partes e bytes finais do corpo, além do conteúdo
do arquivo, inclusive em requisições sem Content-Length. Em seguida, o Multer impõe um limite separado
de 5 MiB para o arquivo e aceita apenas uma parte de arquivo chamada file, sem campos de
texto. Seu streamHandler entrega o corpo já lido
ao parser multipart, em vez de ler novamente o stream da requisição, que já foi esgotado.
import express, { type ErrorRequestHandler, type Request, type Response } from 'express'
import { fileTypeFromBuffer } from 'file-type'
import multer from 'multer'
const app = express()
const maxFileBytes = 5 * 1024 * 1024
const maxRequestBytes = maxFileBytes + 16 * 1024
const allowedTypes = new Set(['image/png', 'image/jpeg'])
async function identifyUpload(request: Request, response: Response): Promise<void> {
if (!request.file || request.file.size === 0) {
response.status(400).json({ error: 'Send one nonempty file in the file field' })
return
}
const type = await fileTypeFromBuffer(request.file.buffer)
if (type === undefined || !allowedTypes.has(type.mime)) {
response.status(415).json({ error: 'Only detected PNG or JPEG files are allowed' })
return
}
response.json({ detectedMime: type.mime, size: request.file.size })
}
app.post(
'/upload',
express.raw({ type: 'multipart/form-data', limit: maxRequestBytes, inflate: false }),
(request, response, next) => {
if (!Buffer.isBuffer(request.body)) {
response.status(415).json({ error: 'Send multipart/form-data' })
return
}
const body = request.body
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: maxFileBytes, files: 1, fields: 0, parts: 1 },
streamHandler: (_request, parser) => parser.end(body),
})
upload.single('file')(request, response, (error: unknown) => {
if (error) return next(error)
void identifyUpload(request, response).catch(() => {
response.status(400).json({ error: 'Unable to identify the file' })
})
})
},
)
const rejectUpload: ErrorRequestHandler = (error: unknown, _request, response, _next) => {
const tooLarge =
(error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') ||
(error instanceof Error && 'type' in error && error.type === 'entity.too.large')
response.status(tooLarge ? 413 : 400).json({
error: tooLarge ? 'Upload exceeds byte limit' : 'Malformed upload',
})
}
app.use(rejectUpload)
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer between 0 and 65535')
}
const server = app.listen(port, '127.0.0.1')
server.on('listening', () => {
const address = server.address()
if (address !== null && typeof address !== 'string') {
console.log(`Listening on http://127.0.0.1:${address.port}`)
}
})
server.on('error', () => {
console.error('Could not start the upload server; check PORT and whether it is in use')
process.exitCode = 1
})
No mesmo diretório em que você executou a configuração, inicie o servidor em primeiro plano:
node magic-upload/server.mts
Aguarde a URL de escuta antes de enviar requisições. Se a porta 3000 estiver ocupada, defina
PORT com outra porta disponível ao executar o mesmo comando e ajuste a URL da requisição de
acordo. Pare o servidor com Ctrl+C; isso devolve você ao seu shell. O endpoint se vincula ao
loopback, mantém os uploads em memória e não grava nenhum arquivo enviado em disco.
Teste sua implementação
Em um segundo terminal, volte ao diretório que contém magic-upload. Crie um PNG de um pixel com um
nome de arquivo .bin enganoso. A gravação em modo somente criação se recusa a sobrescrever
um sample.bin existente; remova esse arquivo de demonstração deliberadamente se quiser recriá-lo.
node --input-type=module -e '
import { writeFileSync } from "node:fs"
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQI12P4z8DwHwAFAAH/cpxSZwAAAABJRU5ErkJggg=="
writeFileSync("magic-upload/sample.bin", Buffer.from(png, "base64"), { flag: "wx" })
'
Faça o upload dele com um tipo MIME deliberadamente incorreto na parte do arquivo:
curl -fsS -F 'file=@magic-upload/sample.bin;type=text/plain' \
http://127.0.0.1:3000/upload
A resposta se baseia nos bytes recebidos do arquivo, não em .bin nem em text/plain:
{"detectedMime":"image/png","size":70}
Agora afirme que um texto comum é um PNG. Este comando imprime o status HTTP sem tratar a rejeição esperada como uma falha do cURL:
printf '%s' 'not an image' | curl -sS -o /dev/null -w '%{http_code}\n' \
-F 'file=@-;filename=avatar.png;type=image/png' http://127.0.0.1:3000/upload
O resultado esperado é 415. Teste também estes comportamentos ao adaptar o endpoint:
- Um JPEG válido com um nome de arquivo sem relação com o conteúdo retorna
image/jpege a contagem real de bytes do arquivo. - Um arquivo ausente ou vazio retorna
400. Um corpo multipart corrompido ou um campo inesperado também retorna400. - Assinaturas desconhecidas e formatos reconhecidos, mas não permitidos, como GIF ou PDF, retornam
415. - Conteúdos de arquivo de até 5 MiB, inclusive, passam na verificação de tamanho; um byte a mais
retorna
413. - Um corpo de requisição de até 5 MiB + 16 KiB, inclusive, passa na verificação de tamanho da
requisição; um byte a mais retorna
413, mesmo que os bytes extras venham depois do delimitador multipart final. As verificações de tipo e de formulário continuam valendo abaixo de qualquer um dos limites.
Trate casos extremos e arquivos poliglotas
Um prefixo FF D8 FF de três bytes pode ser identificado como JPEG sem conter uma imagem
completa. Uma imagem com danos mais adiante em seu conteúdo também pode passar. O endpoint informa
deliberadamente detectedMime, não uma decodificação bem-sucedida nem um veredito de segurança. Um
arquivo poliglota pode ser interpretado como mais de um formato; procurar sequências de bytes
suspeitas dentro dele não é um método de detecção confiável.
Depois da identificação da assinatura, aplique um decodificador mantido e específico para o formato, limites de dimensões e de pixels, e varredura de segurança ou reconstrução do conteúdo adequadas ao seu modelo de ameaças. Execute parsers de dados não confiáveis de forma isolada, com limites de execução impostos. O exemplo local não faz nada disso e não rejeita toda imagem malformada.
Mantenha explícitos os limites de memória e de segurança
Esta é uma demonstração para uploads pequenos, não um pipeline de streaming para arquivos grandes. O
corpo bruto e o buffer de arquivo do Multer coexistem, com overhead adicional do parser e de
alocação. Um limite de tamanho de arquivo não limita a memória total do processo, os uploads
simultâneos nem o tempo de detecção. Arquivos grandes exigem outro caminho e outra política de
armazenamento; nem um prefixo de tamanho fixo nem uma correspondência de assinatura bem-sucedida
comprovam que o restante de um upload foi verificado. Este passo a passo usa Node.js do início ao
fim; ele não exige Python nem libmagic.
Combine a validação por números mágicos com outros controles
Antes de expor um endpoint de upload, adicione autenticação, autorização, limites de taxa e de
concorrência, e prazos para as requisições na fronteira adequada da aplicação ou do proxy. Mantenha
as dependências com patches em dia. Se você persistir uploads, gere os nomes de armazenamento,
coloque os arquivos em quarentena antes do processamento e evite servir conteúdo ativo a partir da
origem da sua aplicação. Esses são controles separados, não propriedades de file-type; consulte a
checklist completa de uploads da OWASP.
Solucione problemas comuns
- Nenhuma URL de escuta: verifique o diagnóstico de inicialização,
PORT, e se o endereço está ocupado. Não envie um upload de teste para um serviço não relacionado que já esteja usando essa porta. - Pacote não encontrado: conclua a instalação dentro de
magic-uploade use o nome de arquivo.mtsdocumentado. Não substitua imports ESM porrequire()do CommonJS. 400inesperado: envie exatamente um arquivo não vazio chamadofilee nenhum campo de formulário adicional. Uma exceção de identificação também é reportada dessa forma, sem expor seu erro interno.413inesperado: verifique o corpo multipart completo, além do tamanho do arquivo. Cabeçalhos das partes e delimitadores consomem a margem adicional de 16 KiB da requisição.
Mantenha a distinção entre “identificado” e “validado” ao conectar este endpoint a armazenamento ou processamento. Para um fluxo de upload gerenciado, conheça nosso serviço de uploads de arquivos; a política sobre o que sua aplicação aceita continua sendo responsabilidade sua.
