Upload de arquivo em PHP: aceitar o arquivo sem aceitar o que vem dentro
Receba arquivos em PHP conferindo erro, tamanho e tipo real pelo conteúdo, gravando com nome gerado por você numa pasta onde nada é executado.
O formulário de foto de perfil funciona. O arquivo aparece na pasta uploads dentro do site. Um dia alguém envia avatar.php, ou uma imagem com código PHP escondido nos metadados, abre o endereço no navegador e o servidor executa. O upload deixou de ser um formulário e virou uma porta. A resposta curta: confira o código de erro, o tamanho e o tipo real pelo conteúdo, gere você o nome do arquivo e grave fora da pasta pública, ou numa pasta onde o servidor web nunca executa PHP. O nome e o tipo que o navegador mandou servem só para exibição.
Por que o upload ingênuo é perigoso
Tudo o que chega em $_FILES foi declarado por quem enviou, exceto o arquivo temporário em si. O campo name é o nome que o computador da pessoa informou, e pode conter caminho, espaço, acento ou ../. O campo type é o tipo MIME que o navegador escolheu, e uma requisição montada na mão manda image/jpeg para qualquer coisa. A extensão também é escolha de quem enviou.
O risco grande não é receber um arquivo ruim. É o servidor tratar esse arquivo como programa. Se ele cai numa pasta servida pelo site e o servidor web passa arquivos .php daquela pasta para o interpretador, quem enviou ganha execução de código. Mesmo conferindo o conteúdo, existem arquivos que são imagem válida e carregam PHP nos metadados. Por isso a defesa principal é de lugar: o arquivo enviado nunca pode ser executado.
Os limites que precisam combinar
Antes do seu código, três camadas decidem o tamanho máximo, e a menor delas ganha:
- Servidor web. No nginx,
client_max_body_size, que vem com 1 MB por padrão. Acima disso, o nginx responde 413 e o PHP nem fica sabendo. No Apache,LimitRequestBody. - PHP.
upload_max_filesizelimita cada arquivo,post_max_sizelimita a requisição inteira, emax_file_uploadslimita quantos arquivos vêm de uma vez.post_max_sizeprecisa ser maior queupload_max_filesize. - Sua regra. O limite do produto, conferido no código. Foto de perfil não precisa de 50 MB só porque o servidor aceita.
Um detalhe que confunde muita gente: se a requisição passa de post_max_size, o PHP descarta o corpo inteiro. $_POST e $_FILES chegam vazios, sem código de erro. O formulário parece não ter enviado nada. O código abaixo trata esse caso.
Como receber o arquivo com segurança
- Confira se o corpo foi descartado por tamanho.
- Leia o código de erro do upload e devolva mensagem específica.
- Aplique o seu limite de tamanho.
- Descubra o tipo pelo conteúdo, com
finfo, e compare com uma lista branca. - Gere um nome aleatório com a extensão que você escolheu para aquele tipo.
- Mova com
move_uploaded_filepara uma pasta fora da raiz do site. - Grave no banco o nome gerado, o nome original e quem enviou.
<?php
declare(strict_types=1);
const TAMANHO_MAXIMO = 5 * 1024 * 1024; // 5 MB
const TIPOS_PERMITIDOS = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
];
const PASTA_UPLOADS = '/var/app/uploads'; // fora da raiz pública do site
function falhar(int $status, string $mensagem): never
{
http_response_code($status);
exit($mensagem);
}
session_start();
$usuarioId = $_SESSION['usuario_id'] ?? falhar(401, 'Faça login para enviar arquivos.');
if (empty($_FILES) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
falhar(413, 'O arquivo é maior do que o servidor aceita.');
}
$arquivo = $_FILES['arquivo'] ?? null;
if (!is_array($arquivo) || !is_int($arquivo['error'])) {
falhar(400, 'Envie um único arquivo no campo "arquivo".');
}
if ($arquivo['error'] === UPLOAD_ERR_INI_SIZE || $arquivo['error'] === UPLOAD_ERR_FORM_SIZE) {
falhar(413, 'O arquivo passou do tamanho máximo.');
}
if ($arquivo['error'] === UPLOAD_ERR_NO_FILE) {
falhar(400, 'Nenhum arquivo foi enviado.');
}
if ($arquivo['error'] !== UPLOAD_ERR_OK) {
falhar(500, 'O envio falhou. Tente de novo.');
}
if ($arquivo['size'] > TAMANHO_MAXIMO) {
falhar(413, 'O limite é de 5 MB.');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipo = $finfo->file($arquivo['tmp_name']);
$extensao = TIPOS_PERMITIDOS[$tipo] ?? null;
if ($extensao === null) {
falhar(415, 'Formato não aceito. Envie JPG, PNG ou PDF.');
}
$nomeNoDisco = bin2hex(random_bytes(16)) . '.' . $extensao;
if (!move_uploaded_file($arquivo['tmp_name'], PASTA_UPLOADS . '/' . $nomeNoDisco)) {
falhar(500, 'Não foi possível salvar o arquivo.');
}
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
'INSERT INTO arquivos (usuario_id, nome_disco, nome_original, tipo, tamanho)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([
$usuarioId,
$nomeNoDisco,
mb_substr($arquivo['name'], 0, 200),
$tipo,
$arquivo['size'],
]);
O tipo never exige PHP 8.1 ou mais novo. O is_int no código de erro barra o caso em que o campo veio como lista, com arquivo[], que este exemplo não trata. Se você precisa de vários arquivos, percorra a lista aplicando as mesmas regras a cada um.
O move_uploaded_file só aceita arquivos que chegaram por upload nesta requisição. Isso impede que alguém faça o seu código mover um arquivo qualquer do servidor. A pasta de destino precisa ser gravável pelo usuário do PHP, e só ela. Não dê permissão de escrita no projeto inteiro para o upload funcionar.
Como entregar o arquivo depois
Se o arquivo está fora da raiz pública, o navegador não chega nele sozinho. Isso é bom: um script de download decide quem pode baixar. Ele busca o registro no banco pelo id, confere se a pessoa logada tem direito, e só então envia:
header('Content-Type: ' . $registro['tipo']);
header('X-Content-Type-Options: nosniff');
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($registro['nome_original']));
readfile(PASTA_UPLOADS . '/' . $registro['nome_disco']);
O nosniff pede ao navegador que não tente adivinhar outro tipo pelo conteúdo. O nome original vai codificado, para acento e aspas não quebrarem o cabeçalho. Se você mostra esse nome numa página, escape com htmlspecialchars, porque ele foi escrito por quem enviou.
Se por algum motivo os arquivos precisam ficar numa pasta pública, garanta no servidor web que nada ali vai para o PHP. No nginx, um bloco location ^~ /uploads/ impede que a regra de expressão regular dos arquivos .php case com esse caminho. Teste enviando um .php inofensivo e abrindo o endereço: ele tem de ser baixado ou bloqueado, nunca executado.
Erros comuns
- Confiar em
$_FILES['arquivo']['type']. É o que o cliente declarou. Um script passa comoimage/pngsem esforço. - Gravar com o nome original. Caminho com
../, nomes repetidos sobrescrevendo arquivos de outras pessoas e extensão.phpentram junto. - Salvar dentro da pasta pública com PHP ativo. Basta um arquivo passar pela validação para virar execução remota de código.
- Aceitar SVG como "imagem". SVG é XML e pode carregar script. Servido do seu domínio, vira ataque contra quem abre.
- Ignorar o caso de
post_max_size. O formulário volta vazio, sem erro, e a pessoa acha que o site está quebrado. - Permissão 0777 na pasta. Qualquer processo do servidor passa a escrever ali.
Checklist
- Os limites do servidor web, do PHP e do código estão alinhados e documentados.
- Arquivo grande demais recebe mensagem específica, não um "tente novamente".
- O tipo é verificado pelo conteúdo e comparado com uma lista branca.
- O nome no disco é gerado pelo sistema, com extensão escolhida pelo sistema.
- A pasta de uploads fica fora da raiz pública, ou não executa PHP de jeito nenhum.
- O download passa por código que confere permissão.
- Um
.phpenviado de propósito no ambiente de teste não executa.
Mande agora, no ambiente de teste, um arquivo com extensão trocada e outro maior que o limite. Os dois param antes de tocar a pasta definitiva? E, se um deles passou, quem consegue baixá-lo: qualquer pessoa com o link ou só quem o enviou?
Referência: http://www.linhadecodigo.com.br/artigo/3578/php-upload-de-arquivos.aspx