Programação Por Equipe DevBras

Capa do artigo: MIME type: a extensão não diz o que tem dentro, nem na entrada nem na saída

MIME type: a extensão não diz o que tem dentro, nem na entrada nem na saída

Descubra o tipo real pelo conteúdo com finfo, gere a extensão a partir dele e configure o servidor para entregar o arquivo com o tipo certo, nosniff e download forçado.

Alguém sobe avatar.png no fórum. O arquivo é, na verdade, uma página HTML com um script. O upload aceita porque a extensão é .png. Se o servidor entrega esse arquivo com um tipo errado ou genérico e sem um cabeçalho que proíba adivinhação, o navegador de quem abre o link direto pode deduzir o tipo pelo conteúdo e executar o script no domínio do fórum. A resposta curta: na entrada, descubra o tipo pelo conteúdo com finfo e gere a extensão a partir dele; na saída, entregue o arquivo com o Content-Type que você gravou, com X-Content-Type-Options: nosniff e, para tudo que não precisa abrir na página, com Content-Disposition: attachment.

O exemplo é esse fórum de uma comunidade de ciclismo: avatares em JPG e PNG e anexos em PDF. Vamos seguir um arquivo do upload até o momento em que outro usuário o abre.

Por que extensão e MIME são coisas diferentes

A extensão é parte do nome, e o nome é escolha de quem enviou. Renomear programa.exe para foto.jpg não muda nenhum byte do arquivo. O campo $_FILES['arquivo']['type'] do PHP também vem do navegador de quem enviou, e um script de ataque escreve ali o que quiser.

O MIME type, como image/png, é a declaração que o servidor faz ao entregar o arquivo, no cabeçalho Content-Type. O navegador decide o que fazer com base nele: mostrar imagem, renderizar página, tocar vídeo, baixar. Se a declaração está errada, ou se o navegador desconfia dela e tenta adivinhar (o chamado sniffing), um arquivo pode ser tratado como algo que você nunca autorizou.

A assinatura nos primeiros bytes

Muitos formatos começam com uma sequência fixa, os chamados magic bytes:

  • PNG: 89 50 4E 47 0D 0A 1A 0A.
  • JPEG: FF D8 FF.
  • PDF: 25 50 44 46 2D, que é o texto %PDF-.
  • GIF: o texto GIF87a ou GIF89a.
  • ZIP: 50 4B 03 04, o que também vale para DOCX e XLSX, que são ZIPs por dentro.

A extensão fileinfo do PHP e o comando file do Linux usam essas assinaturas e outras regras para dizer o tipo real. Elas não são infalíveis, porque um arquivo pode ser válido em dois formatos ao mesmo tempo, mas eliminam o caso trivial do arquivo renomeado.

Como tratar o tipo na entrada

  1. Ignore a extensão enviada e o type de $_FILES para decidir qualquer coisa.
  2. Descubra o tipo com finfo no arquivo temporário.
  3. Compare com um mapa fechado de tipos aceitos, que já diz a extensão correspondente.
  4. Grave com nome gerado por você e a extensão que veio do mapa.
  5. Guarde o tipo detectado no banco, junto com o registro do arquivo.
<?php
const TIPOS_ACEITOS = [
    'image/jpeg'      => 'jpg',
    'image/png'       => 'png',
    'application/pdf' => 'pdf',
];

$tmp = $_FILES['arquivo']['tmp_name'] ?? '';
if (!is_uploaded_file($tmp)) {
    http_response_code(400);
    exit('Envio inválido.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipo = $finfo->file($tmp);

if (!isset(TIPOS_ACEITOS[$tipo])) {
    http_response_code(415);
    exit('Tipo de arquivo não aceito.');
}

$nome = bin2hex(random_bytes(16)) . '.' . TIPOS_ACEITOS[$tipo];
move_uploaded_file($tmp, '/var/app/storage/anexos/' . $nome);

$pdo->prepare('INSERT INTO anexos (arquivo, tipo, nome_original) VALUES (?, ?, ?)')
    ->execute([$nome, $tipo, $_FILES['arquivo']['name']]);

O código 415 significa "tipo de mídia não suportado", o status mais honesto para essa recusa. Repare que SVG ficou fora do mapa: ele é XML e pode conter script, então não é uma imagem inofensiva como PNG. A conferência na entrada é detalhada, com mais casos, em tipo de arquivo permitido não é a extensão, e para imagens o passo extra de regravar está em arquivo enviado vira imagem de novo.

Como entregar o arquivo com o tipo certo

A saída é onde muitos sistemas que validaram bem a entrada ainda falham. Se os anexos são servidos direto pelo Nginx ou Apache, o tipo vem da extensão, pelo arquivo mime.types. Como a extensão agora é gerada por você a partir do conteúdo, ela é confiável. Falta impedir o navegador de adivinhar e impedir que algo ali seja executado.

location ^~ /anexos/ {
    alias /var/app/storage/anexos/;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Content-Security-Policy "default-src 'none'" always;
}

O ^~ faz o Nginx parar nesse bloco e não procurar o bloco de regex que manda .php para o PHP-FPM. Assim, mesmo que um PHP tivesse escapado da validação, seria entregue como arquivo, nunca executado. O nosniff obriga o navegador a respeitar o Content-Type. A política de segurança de conteúdo restrita impede que um arquivo aberto diretamente carregue scripts. Atenção a um detalhe do Nginx: um add_header dentro do location anula todos os add_header herdados do server, então repita ali os cabeçalhos globais que você quer manter, como o HSTS.

Quando o arquivo passa pelo PHP, por exemplo porque só membros podem baixar, use o tipo que você gravou no banco, nunca o que o nome sugere:

<?php
// baixar-anexo.php (após conferir sessão e permissão)
$stmt = $pdo->prepare('SELECT arquivo, tipo FROM anexos WHERE id = ?');
$stmt->execute([(int) ($_GET['id'] ?? 0)]);
$anexo = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$anexo) {
    http_response_code(404);
    exit;
}

$caminho = '/var/app/storage/anexos/' . $anexo['arquivo'];
$inline = in_array($anexo['tipo'], ['image/jpeg', 'image/png'], true);

header('Content-Type: ' . $anexo['tipo']);
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment'));
header('Content-Length: ' . filesize($caminho));
readfile($caminho);

Imagens abrem na página; PDFs e qualquer outro tipo são baixados. O download forçado evita que um documento abra dentro do contexto do seu domínio.

Como conferir os dois lados

file --mime-type -b avatar.png
curl -sI https://forum.exemplo.com.br/anexos/3f9a1c.png

O primeiro comando mostra o tipo real do arquivo local. O segundo mostra os cabeçalhos que o servidor manda; procure Content-Type e X-Content-Type-Options. Vídeo que não toca por tipo errado na resposta é um caso tão frequente que tem guia próprio: vídeo que não dá play.

Erros comuns

  • Validar pela extensão do nome enviado. Um HTML renomeado para .png passa e fica hospedado no seu domínio.
  • Confiar em $_FILES['type']. O valor é escrito pelo cliente e qualquer script envia image/png para um arquivo executável.
  • Manter a extensão original. foto.php.jpg ou foto.phtml podem ser executados por uma configuração de servidor descuidada.
  • Esquecer o nosniff. O navegador pode ignorar o tipo declarado e tratar o conteúdo como página.
  • Aceitar SVG como imagem comum. Ele pode carregar script, que roda quando o arquivo é aberto diretamente.
  • Servir tudo com inline. Documentos de terceiros abrem dentro do seu domínio, em vez de irem para a pasta de downloads.

Checklist

  • O tipo é detectado pelo conteúdo, com finfo ou equivalente.
  • Existe um mapa fechado de tipos aceitos, e a extensão gravada vem dele.
  • O nome no disco é gerado pelo sistema.
  • O tipo detectado fica gravado e é usado no Content-Type da saída.
  • Toda resposta de arquivo enviado leva X-Content-Type-Options: nosniff.
  • Arquivos que não precisam abrir na página saem com Content-Disposition: attachment.
  • A pasta de anexos nunca passa pelo interpretador de PHP.

Crie agora um arquivo de texto com <script>alert(1)</script>, salve como teste.png e envie para o seu sistema. Ele foi aceito? E, se foi, que Content-Type o servidor manda quando outra pessoa abre o link?

Avalie este artigo

Referência: http://codigofonte.uol.com.br/codigos/como-definir-os-mime-types-validos-de-um-tipo-de-arquivo