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
GIF87aouGIF89a. - 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
- Ignore a extensão enviada e o
typede$_FILESpara decidir qualquer coisa. - Descubra o tipo com
finfono arquivo temporário. - Compare com um mapa fechado de tipos aceitos, que já diz a extensão correspondente.
- Grave com nome gerado por você e a extensão que veio do mapa.
- 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
.pngpassa e fica hospedado no seu domínio. - Confiar em
$_FILES['type']. O valor é escrito pelo cliente e qualquer script enviaimage/pngpara um arquivo executável. - Manter a extensão original.
foto.php.jpgoufoto.phtmlpodem 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
finfoou 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-Typeda 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?
Referência: http://codigofonte.uol.com.br/codigos/como-definir-os-mime-types-validos-de-um-tipo-de-arquivo