Blog

Upload de arquivo seguro: checklist real.

Upload é a funcionalidade que mais transforma um sistema comum num sistema comprometido. Você está aceitando conteúdo arbitrário de estranhos e guardando no seu servidor.

A verificação que a maioria implementa é comparar a extensão do arquivo. Isso não protege de nada, porque a extensão é escolhida por quem envia.

Abaixo, a lista que eu uso, na ordem em que as verificações importam.

1. Não confie no nome nem no tipo declarado

file.originalname e file.mimetype vêm do cliente. Os dois são texto livre.

Gere o nome você:

const stored = `${randomUUID()}${safeExtension(detected.ext)}`;

Guarde o nome original só como metadado de exibição, e escape na hora de mostrar. Nome de arquivo é um vetor de XSS que passa despercebido porque ninguém pensa nele como entrada de usuário.

E cuidado com travessia de caminho: ../../etc/passwd como nome de arquivo é o ataque mais antigo do mundo e ainda funciona quando alguém concatena o nome recebido num caminho.

2. Detecte o tipo pelo conteúdo

Os primeiros bytes do arquivo dizem o que ele é de verdade.

import { fileTypeFromBuffer } from "file-type";

const detected = await fileTypeFromBuffer(buffer);
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);

if (!detected || !ALLOWED.has(detected.mime)) {
  throw new BadRequestException("Tipo de arquivo não suportado.");
}

Lista de permissão, nunca lista de bloqueio. Bloquear .exe e .php deixa passar .phtml, .svg, .html e mais uma dúzia que você não lembrou.

O SVG merece menção especial: é imagem pra quem envia e documento com JavaScript pra quem abre. Se você aceita SVG, precisa sanitizar o conteúdo ou servir de um domínio separado.

3. Limite tamanho antes de ler

Validar depois de carregar em memória já perdeu.

FileInterceptor("file", {
  limits: { fileSize: 10 * 1024 * 1024, files: 1 },
})

E limite também no servidor web, na frente da aplicação. client_max_body_size no nginx impede que a requisição chegue.

Atenção a arquivo compactado. Um zip de 1MB pode virar 10GB descompactado, o que enche o disco e derruba o servidor. Se você descompacta algo enviado por usuário, precisa de limite de tamanho descompactado e de número de arquivos.

4. Guarde fora da raiz do site

O arquivo enviado nunca deve ficar num diretório servido diretamente pelo servidor web. É assim que um upload vira execução de código.

Object storage é a resposta padrão, e não porque é mais moderno: porque o storage não executa nada. Um PHP lá é um arquivo de texto.

Se precisar mesmo salvar em disco, salve fora da raiz e sirva através da aplicação, com controle de acesso.

5. Sirva com cabeçalho defensivo

Servir arquivo do usuário no mesmo domínio da aplicação significa que um HTML enviado roda com acesso aos cookies da sua sessão.

Duas defesas, e vale ter as duas:

Domínio separado para conteúdo de usuário. A origem diferente isola do resto.

Cabeçalhos que impedem interpretação:

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="documento.pdf"
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; sandbox

nosniff impede que o navegador adivinhe o tipo e execute como HTML um arquivo declarado como imagem.

6. Reprocesse a imagem

Para imagem, a defesa mais eficaz é não guardar o arquivo original.

const clean = await sharp(buffer)
  .rotate()                       // aplica orientação do EXIF
  .resize(2000, 2000, { fit: "inside", withoutEnlargement: true })
  .jpeg({ quality: 85 })
  .toBuffer();                    // metadados ficam pra trás

Isso descarta EXIF, que carrega coordenada de GPS e modelo de câmera, e destrói qualquer payload escondido nos metadados. De quebra, normaliza tamanho e economiza banda.

Se o EXIF for necessário pro produto, extraia os campos que interessam explicitamente e descarte o resto.

Arquivo privado servido por URL permanente é arquivo público com URL comprida.

const url = await s3.getSignedUrl("getObject", {
  Bucket: BUCKET,
  Key: key,
  Expires: 300,
});

Cinco minutos costuma bastar. E antes de gerar a URL, verifique se aquele usuário pode acessar aquele arquivo, o que é a mesma verificação de IDOR que se esquece em todo endpoint de anexo. UUID no nome não é controle de acesso.

O que fica de fora

Duas coisas que eu não coloco na lista básica e que valem dependendo do contexto.

Antivírus. Faz sentido quando o arquivo vai ser baixado por outros usuários, o que muda o modelo de ameaça. ClamAV via serviço separado resolve, com custo de latência.

Quarentena. Guardar em área isolada até o processamento terminar, e só então mover pra área acessível. Vale quando o processamento é pesado ou quando falha parcial deixaria arquivo inconsistente disponível.

O teste que vale mais que a lista

Suba um arquivo .jpg cujo conteúdo é HTML com script. Depois abra a URL que o sistema devolveu.

Se o navegador executar o script, você tem uma falha, e ela vale por várias das verificações acima. É um teste de dois minutos que quase ninguém faz.

← Voltar pro blog