Upload de imagem seguro: decodifique, regrave e sirva de onde nada executa
Receba fotos com limite de tamanho e de pixels, confira o formato pelo conteúdo, regrave os pixels com nome gerado por você e sirva de uma pasta que não executa código.
O formulário de avatar aceitava "imagens". O servidor conferia a extensão, guardava o arquivo como chegou em public/uploads/ e mantinha o nome original. Alguém enviou perfil.php.jpg com código escondido depois dos bytes de uma foto válida, e outra pessoa enviou um SVG com script. Um virou porta para executar código, o outro rodava JavaScript no domínio do site para quem abrisse o link. A resposta curta: não guarde o arquivo que chegou, guarde uma imagem nova feita a partir dos pixels dele. Limite tamanho e dimensões antes de processar, identifique o formato pelo conteúdo, decodifique, regrave num formato que você escolheu, com nome gerado por você, e sirva de um lugar que nunca executa nada.
Regravar descarta o que não é imagem: comentários, dados anexados depois do fim do arquivo, metadados com GPS, scripts embutidos. O que sai do processo é só aquilo que você pediu ao codificador.
Por que conferir a extensão não basta
A extensão e o tipo MIME que o navegador manda são declarações de quem enviou. Ambos podem ser qualquer coisa. Conferir os primeiros bytes do arquivo, a chamada assinatura mágica, é melhor, mas um arquivo pode começar como JPEG válido e carregar outra coisa no meio ou no fim. Esses arquivos que são válidos em dois formatos ao mesmo tempo existem e passam por verificações que olham só o começo.
O perigo real vem de dois lugares. O primeiro é o servidor web: se o arquivo fica dentro da raiz pública e o servidor executa scripts naquela pasta, um nome como foto.php, ou uma configuração frouxa que executa qualquer arquivo com .php no nome, transforma upload em execução remota. O segundo é o navegador: se o arquivo for servido como HTML ou SVG no seu domínio, o script dentro dele roda com acesso aos cookies e à sessão de quem abriu.
Há um terceiro risco, menos óbvio: a bomba de descompressão. Um PNG de 2 MB pode declarar 20.000 por 20.000 pixels. Decodificado, são 400 milhões de pixels, mais de 1,5 GB de memória com quatro bytes por pixel. Um punhado desses derruba o servidor.
Como receber a imagem e regravar
- Limite o tamanho do arquivo na entrada, antes de qualquer processamento.
- Limite o número de pixels que o decodificador aceita.
- Leia o formato pelo conteúdo e aceite só uma lista curta: JPEG, PNG, WebP. SVG fica de fora.
- Decodifique e regrave num formato fixo, já no tamanho que você vai usar.
- Gere o nome com um identificador aleatório e a extensão do formato que você gravou.
- Grave fora da raiz web e sirva por uma rota com tipo de conteúdo fixo.
- Mantenha a biblioteca de imagem atualizada. O decodificador é código que lê dado hostil.
Exemplo em Node.js com multer e sharp
const express = require('express');
const multer = require('multer');
const sharp = require('sharp');
const crypto = require('crypto');
const path = require('path');
const PASTA = '/var/lib/loja/avatares';
const FORMATOS = ['jpeg', 'png', 'webp'];
const app = express();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 }
});
app.post('/avatar', upload.single('foto'), async (req, res) => {
if (!req.file) return res.status(400).json({ erro: 'Envie uma imagem.' });
try {
const imagem = sharp(req.file.buffer, { limitInputPixels: 40000000 });
const info = await imagem.metadata();
if (!FORMATOS.includes(info.format)) {
return res.status(415).json({ erro: 'Use JPEG, PNG ou WebP.' });
}
const nome = crypto.randomUUID() + '.webp';
await imagem
.rotate()
.resize(512, 512, { fit: 'cover' })
.webp({ quality: 82 })
.toFile(path.join(PASTA, nome));
res.status(201).json({ url: '/avatares/' + nome });
} catch (err) {
res.status(422).json({ erro: 'Não conseguimos ler essa imagem.' });
}
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
return res.status(413).json({ erro: 'Arquivo grande demais. O limite é 5 MB.' });
}
next(err);
});
O limits.fileSize do multer corta o envio ao passar de 5 MB, e o erro vai para o tratador no fim. O limitInputPixels faz o sharp recusar imagens acima de 40 milhões de pixels, cerca de 6.300 por 6.300, antes de alocar memória para elas. O metadata() lê o formato pelos bytes do arquivo, não pelo nome.
O rotate() sem argumentos aplica a orientação gravada pela câmera, para a foto não aparecer deitada. Isso importa porque o sharp, por padrão, não copia metadados para a saída: a orientação, o modelo do celular e as coordenadas de GPS ficam para trás. O motivo de se preocupar com isso está em foto do celular pode trazer o endereço da casa. Se você precisa guardar a imagem em tamanho maior, o raciocínio de redimensionar antes de guardar está em redimensionar imagem no servidor.
Servindo de um lugar que não executa
app.use('/avatares', express.static(PASTA, {
index: false,
setHeaders: (res) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('Content-Security-Policy', "default-src 'none'");
}
}));
A pasta fica fora do diretório público do projeto. O express.static serve só arquivos estáticos, com o tipo deduzido da extensão, e todo arquivo ali termina em .webp porque foi você quem deu o nome. O nosniff impede o navegador de adivinhar outro tipo, e a política de conteúdo bloqueia qualquer script se algo inesperado for parar lá.
Em PHP com Apache, a forma de desligar a execução numa pasta depende de como o PHP está ligado ao servidor, e uma regra errada passa despercebida. O caminho mais robusto é o mesmo: gravar fora da raiz web e entregar por um script que define o tipo. Os detalhes do lado PHP, incluindo os limites do php.ini, estão em upload de arquivo em PHP. Em armazenamento de objetos, como S3 ou Blob Storage, defina o Content-Type ao gravar e sirva de um domínio separado do da aplicação.
Erros comuns
- Confiar na extensão ou no MIME enviado. São texto escrito por quem enviou e não dizem nada sobre o conteúdo.
- Guardar com o nome original. Abre caminho para nomes com
../, extensões duplas e sobrescrita de arquivos de outras pessoas. - Aceitar SVG como imagem comum. SVG é XML e pode carregar script; servido do seu domínio, roda com a sessão de quem abriu.
- Gravar dentro da raiz pública. Qualquer brecha na configuração do servidor vira execução de código.
- Processar sem limite de pixels. Um arquivo pequeno consome gigabytes de memória ao ser decodificado.
- Publicar o original com metadados. GPS e dados do aparelho vão junto para qualquer pessoa que baixar.
Checklist
- Existe limite de tamanho aplicado antes do processamento.
- O decodificador tem limite de pixels.
- O formato é lido do conteúdo e comparado com uma lista curta.
- O arquivo salvo é uma imagem regravada, não o original.
- O nome é gerado pelo servidor, com extensão do formato gravado.
- Os arquivos ficam fora da raiz web ou num domínio separado.
- As respostas saem com tipo fixo e
X-Content-Type-Options: nosniff. - A biblioteca de imagem está na versão atual.
Pegue a URL de uma imagem enviada por usuário no seu site e troque a extensão no final por .php ou .html: o servidor responde 404 ou tenta entregar alguma coisa?
Referência: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html