Programação Por Equipe DevBras

Capa do artigo: Upload de imagem seguro: decodifique, regrave e sirva de onde nada executa

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

  1. Limite o tamanho do arquivo na entrada, antes de qualquer processamento.
  2. Limite o número de pixels que o decodificador aceita.
  3. Leia o formato pelo conteúdo e aceite só uma lista curta: JPEG, PNG, WebP. SVG fica de fora.
  4. Decodifique e regrave num formato fixo, já no tamanho que você vai usar.
  5. Gere o nome com um identificador aleatório e a extensão do formato que você gravou.
  6. Grave fora da raiz web e sirva por uma rota com tipo de conteúdo fixo.
  7. 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?

Avalie este artigo

Referência: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html