Tratamento de Uploads de Imagens no Servidor
Segurança de upload de imagens em Node: valide assinaturas em memória, limite o tamanho, reencode com sharp e sirva arquivos com segurança.
A validação de upload de arquivos no lado do servidor começa assim que a requisição multipart chega e, a partir daí, a única coisa em que vale a pena confiar são os bytes. Tanto o Content-Type quanto o nome do arquivo vêm do navegador, então trate-os como alegações, não como fatos. Se você chegou até aqui vindo dos artigos sobre o lado do navegador em criação de miniaturas de imagens antes do upload e conversão de imagens para Base64 com Canvas, esta é a ponta receptora daquele pipeline.
As duas metades do pipeline cumprem funções diferentes. O redimensionamento e a compressão no navegador são uma cortesia ao usuário; a validação no servidor é o que impede um corpo de 2 GB ou um script disfarçado com a extensão .jpg. Este artigo percorre cinco controles de que um endpoint de imagens em Node precisa: verificação de assinatura, limites de tamanho em camadas, nomes de arquivo gerados, reencodificação e entrega segura.
Principais Conclusões
- O cabeçalho Content-Type e o nome do arquivo em um upload multipart são ambos definidos pelo cliente, portanto um servidor que verifica qualquer um deles está validando a alegação do atacante sobre o arquivo, e não o arquivo em si.
- Uma verificação de assinatura deve ler os bytes iniciais do arquivo em memória, antes que qualquer coisa toque o disco, e compará-los com os formatos que aquele endpoint específico aceita.
- Limites de tamanho pertencem primeiro ao proxy, em segundo lugar à opção
limitsdo Multer e, por último, ao código da aplicação; ofileSizedo Multer tem como padrão Infinity, então um limite não definido é a ausência de limite. - A reencodificação com o sharp é o que torna a falsificação de assinatura irrelevante: a saída é um arquivo novo, e metadados EXIF, incluindo coordenadas GPS de fotos de celular, não sobrevivem a ela.
- Sirva as imagens armazenadas com um Content-Type vindo do seu próprio registro de validação,
X-Content-Type-Options: nosniffe um caminho de armazenamento fora da raiz web.
Por Que as Verificações de Tipo MIME e Extensão Falham?
Verificar file.mimetype ou a extensão do nome do arquivo não valida o arquivo, porque o cliente fornece ambos os valores. Dois artigos anteriores deste blog deram esse conselho: Multer NPM: File Upload in Node.js mostra um fileFilter que aceita qualquer arquivo cujo mimetype apareça em uma lista permitida, e Safe User Input Handling in Node.js orienta os leitores a validar tipos MIME no nível do parser. Vale a pena manter ambas as verificações como rejeições antecipadas e baratas, mas nenhuma delas é um controle de segurança.
A falsificação ocupa uma linha:
curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload
O Multer copia esse valor de type diretamente para req.file.mimetype. Seu filtro vê image/jpeg; o corpo é um shell script. O OWASP File Upload Cheat Sheet é explícito ao afirmar que a validação deve se basear no conteúdo do arquivo, não em metadados fornecidos pelo cliente.
A Validação de Upload de Arquivos Começa pela Assinatura do Arquivo
Leia os bytes iniciais do buffer, em memória, antes que qualquer coisa seja escrita em disco, e compare-os com os formatos que aquele endpoint específico aceita. As duas metades dessa frase importam. É comum que tutoriais façam a validação depois que o arquivo já aterrissou em um diretório de uploads, o que significa que um payload superdimensionado ou hostil já causou seu estrago antes de a verificação rodar. E é comum que aprovem um arquivo se ele corresponder a qualquer assinatura conhecida, de modo que um PDF passa tranquilamente por um endpoint de avatar. Um endpoint de avatar que reconhece PDFs tem um bug de validação, não um recurso.
As assinaturas em si: JPEG começa com FF D8 FF, PNG começa com a sequência completa de 8 bytes definida na especificação PNG, e WebP exige RIFF no offset 0 mais WEBP nos bytes 8 a 11, conforme a especificação do contêiner WebP. Os bytes 4 a 7 são o tamanho do chunk RIFF, e é por isso que uma verificação de WebP precisa ignorá-los, e por isso que uma leitura ingênua de 4 bytes não trata corretamente nem PNG nem WebP.
const SIGNATURES = {
"image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
"image/png": (b) =>
b.length >= 8 &&
b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
"image/webp": (b) =>
b.length >= 12 &&
b.subarray(0, 4).toString("ascii") === "RIFF" &&
b.subarray(8, 12).toString("ascii") === "WEBP",
};
// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}
Uma ressalva: assinaturas podem ser forjadas prefixando os bytes corretos a qualquer payload, em segundos. O pacote file-type diz exatamente isso em seu próprio README, onde a correspondência de magic numbers é descrita como uma pista sobre o formato, e não como prova dele. Trate a verificação de assinatura como um filtro rápido e barato. A fronteira de segurança vem duas seções adiante.
Onde Devem Ficar os Limites de Tamanho de Upload?
Limites de tamanho pertencem primeiro ao proxy, em segundo lugar ao framework e, por último, ao código da aplicação, porque uma verificação que roda depois que o corpo já foi bufferizado rejeita o upload apenas depois que o payload completo já custou memória e banda. No nginx, client_max_body_size tem como padrão 1 MB e responde a requisições superdimensionadas com um 413 antes que seu processo as veja:
client_max_body_size 5m;
A camada de framework é o Multer (2.x), onde limits.fileSize tem como padrão Infinity, então um limite não definido é a ausência de limite:
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
Quando o teto é atingido, o Multer lança um erro LIMIT_FILE_SIZE. No Fastify, o @fastify/multipart adota a postura oposta e define fileSize como 1 MiB por padrão, de modo que o comportamento seguro é o padrão, e não algo a ser habilitado. Session replays de fluxos de upload tornam visível o custo de pular a camada de proxy como um defeito de UX: a barra de progresso chega a 100 por cento e só então aparece a rejeição, porque todo o payload precisou chegar antes que a verificação no nível da aplicação pudesse rodar.
Descarte o Nome de Arquivo do Cliente
Nunca deixe o nome de arquivo do cliente tocar o seu sistema de arquivos. Gere o seu próprio a partir de crypto.randomUUID() mais a extensão determinada pela sua própria validação:
const storedName = `${crypto.randomUUID()}.jpg`;
O raciocínio, das sequências de traversal até o motivo pelo qual path.normalize não é uma defesa, está coberto em Preventing Path Traversal Attacks in Node.js; a abordagem de nome gerado torna toda essa classe de ataque inalcançável.
Reencode a Imagem; Não Armazene os Bytes Originais
A reencodificação é o controle que torna a falsificação de assinatura irrelevante. O sharp (0.35.x) decodifica os pixels e escreve um arquivo totalmente novo, de modo que o que quer que tenha sido prefixado, anexado ou escondido dentro do original nunca sobrevive. Ela também fecha um vazamento de privacidade: fotos de celular rotineiramente carregam coordenadas GPS em seus dados EXIF, e armazenar os bytes originais significa republicar a localização do seu usuário. A documentação de saída do sharp explicita o padrão: nada dos metadados da entrada chega à saída, a menos que você os peça de volta com keepExif() ou withMetadata(). A flag de orientação vai junto com todo o resto, então faça a auto-orientação primeiro com .autoOrient() ou as fotos de celular sairão de lado:
let clean;
try {
clean = await sharp(req.file.buffer)
.autoOrient() // apply EXIF orientation before metadata is stripped
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}
Um buffer que passou na verificação de assinatura mas falha ao ser decodificado estava mentindo sobre seu formato. Isso é a verificação funcionando.
Sirva o Que Você Validou, Não o Que Você Recebeu
Ao servir imagens armazenadas, defina o Content-Type a partir do seu registro de validação, nunca a partir de algo que o cliente enviou, adicione X-Content-Type-Options: nosniff para que o navegador não possa questioná-lo, e armazene os arquivos fora da raiz web para que nada que tenha sido enviado seja diretamente executável ou renderizável:
app.get("/images/:id", async (req, res) => {
const record = await getImageRecord(req.params.id); // contentType saved at validation time
if (!record) return res.sendStatus(404);
res.setHeader("Content-Type", record.contentType);
res.setHeader("X-Content-Type-Options", "nosniff");
res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});
Alguns tópicos adjacentes, cada um em uma linha:
- A varredura antivírus (ClamAV ou um equivalente em nuvem) importa quando você aceita documentos arbitrários, não imagens reencodificadas.
- Se algum dia você aceitar arquivos compactados, verifique o tamanho descomprimido antes de extrair; zip bombs são arquivos pequenos que se expandem enormemente.
- SVG é um documento XML que pode carregar script, então exclua-o inteiramente de endpoints de imagem.
- Arquiteturas com URLs pré-assinadas fazem o upload direto para o object storage, o que move todo esse pipeline para uma etapa de processamento pós-upload, em vez de eliminá-lo.
Conclusão
Os cinco controles formam um único pipeline: rejeite por tamanho no proxy, verifique a assinatura em memória contra a allowlist daquele endpoint, reencode com o sharp, armazene sob um nome que você gerou e sirva com cabeçalhos que você controla.
| Controle | Onde roda | O que impede |
|---|---|---|
| Limite de tamanho | Proxy primeiro, limits do Multer em segundo, código da aplicação por último | Corpos superdimensionados consumindo memória e banda |
| Verificação de assinatura | Em memória, antes que qualquer coisa toque o disco | Bytes que não correspondem à allowlist daquele endpoint |
| Reencodificação com sharp | Após a validação, antes do armazenamento | Assinaturas forjadas, payloads ocultos, dados GPS em EXIF |
| Nome de arquivo gerado | No momento do armazenamento | Path traversal através do nome de arquivo do cliente |
| Cabeçalhos de entrega validados | Em toda leitura | MIME sniffing e execução a partir da raiz web |
A verificação de assinatura filtra de forma barata; a reencodificação é a fronteira que se sustenta mesmo quando a assinatura foi forjada. Comece verificando a sua própria configuração do Multer: se limits.fileSize não estiver definido, aquele endpoint atualmente aceita arquivos de tamanho ilimitado.
Perguntas Frequentes
O pacote npm file-type substitui uma verificação de assinatura escrita à mão?
Ele substitui a comparação de bytes, não o modelo de segurança. O file-type lê os mesmos magic numbers, e seu README é direto sobre o que isso lhe garante: uma correspondência é uma pista, e não resolve nem se o arquivo realmente é daquele tipo, nem se está bem formado. Você ainda precisa de uma allowlist específica por endpoint por cima, porque ele reconhece centenas de formatos, e a reencodificação continua sendo a fronteira real. Note que o pacote é somente ESM, então projetos CommonJS precisam de um import dinâmico ou da alternativa load-esm.
O limite fileSize do Multer impede que o cliente envie o restante do arquivo?
Não de forma confiável. Quando limits.fileSize é atingido, o Multer para de bufferizar e lança um erro LIMIT_FILE_SIZE, o que protege seu processo de uso ilimitado de memória, mas nada na documentação garante que a transferência de rede seja cancelada, e o erro pode surgir somente depois que todos os bytes chegaram. Um teto no nível do proxy, como o client_max_body_size do nginx, é o que de fato protege a banda, e é por isso que o limite pertence primeiro ao proxy.
Como validar uploads quando os clientes usam URLs pré-assinadas para enviar diretamente ao object storage?
A validação se desloca para uma etapa pós-upload, em vez de desaparecer. O cliente faz o upload para um bucket ou prefixo de quarentena de onde nada é servido, e então um worker em segundo plano ou uma função disparada pelo storage baixa o objeto, executa a mesma verificação de assinatura e a reencodificação com sharp, e escreve a saída limpa no local público sob um nome gerado. Objetos que falham na validação são excluídos, e o local de quarentena nunca é exposto aos navegadores.
A reencodificação com o sharp altera as cores da imagem?
Pode alterar em imagens de gamut amplo. Sem intervenção, o sharp entrega uma saída sRGB sem perfil anexado, já que a remoção de metadados leva junto o perfil ICC incorporado, então fotos criadas em Display P3 ou Adobe RGB podem sofrer um leve desvio. Para preservar a cor sem reintroduzir dados EXIF, chame keepIccProfile() antes de codificar. O keepMetadata() também mantém o perfil, mas traz de volta tudo, incluindo as coordenadas GPS das quais a remoção protegia seus usuários.