Upload de arquivos com segurança

1. Introdução aos Riscos de Segurança no Upload de Arquivos

O upload de arquivos é uma das funcionalidades mais críticas em aplicações PHP, pois expõe diretamente o servidor a ataques. As principais vulnerabilidades incluem:

  • Execução remota de código: Um atacante envia um arquivo PHP disfarçado de imagem, obtendo acesso ao servidor
  • Path traversal: Exploração de nomes de arquivo maliciosos como ../../../etc/passwd
  • Negação de serviço (DoS): Upload de arquivos extremamente grandes ou infinitos

As consequências podem ser devastadoras: desde a instalação de shells reversos até o vazamento completo do banco de dados. Diferentemente de frameworks que abstraem essas preocupações, o PHP puro exige que o desenvolvedor implemente cada camada de proteção manualmente.

2. Configuração do Ambiente e Validação Inicial

Antes de qualquer código, configure o php.ini com limites realistas:

upload_max_filesize = 10M
post_max_size = 12M
max_file_uploads = 5

A validação começa com o código de erro em $_FILES:

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    die('Método não permitido');
}

if ($_FILES['arquivo']['error'] !== UPLOAD_ERR_OK) {
    $mensagens = [
        UPLOAD_ERR_INI_SIZE => 'Arquivo excede o limite do php.ini',
        UPLOAD_ERR_FORM_SIZE => 'Arquivo excede o limite do formulário',
        UPLOAD_ERR_PARTIAL => 'Upload parcial',
        UPLOAD_ERR_NO_FILE => 'Nenhum arquivo enviado'
    ];
    die($mensagens[$_FILES['arquivo']['error']] ?? 'Erro desconhecido');
}

Sempre utilize is_uploaded_file() e move_uploaded_file():

$arquivo = $_FILES['arquivo']['tmp_name'];
if (!is_uploaded_file($arquivo)) {
    die('Arquivo não foi enviado via HTTP POST');
}

$destino = '/var/uploads/' . gerarNomeSeguro();
if (!move_uploaded_file($arquivo, $destino)) {
    die('Falha ao mover arquivo');
}

3. Validação do Tipo de Arquivo (MIME e Extensão)

Nunca confie em $_FILES['arquivo']['type'] — este valor é enviado pelo cliente e pode ser falsificado. Use finfo para detectar o MIME real:

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['arquivo']['tmp_name']);

$mimesPermitidos = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'application/pdf'
];

if (!in_array($mime, $mimesPermitidos)) {
    die('Tipo de arquivo não permitido');
}

Implemente uma whitelist de extensões (nunca blacklist):

$extensao = strtolower(pathinfo($_FILES['arquivo']['name'], PATHINFO_EXTENSION));
$extensoesPermitidas = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];

if (!in_array($extensao, $extensoesPermitidas)) {
    die('Extensão não permitida');
}

// Verificação dupla: extensão + MIME + magic bytes
$magicBytes = [
    'jpg' => "\xFF\xD8\xFF",
    'png' => "\x89\x50\x4E\x47",
    'gif' => "\x47\x49\x46",
    'pdf' => "\x25\x50\x44\x46"
];

$handle = fopen($_FILES['arquivo']['tmp_name'], 'rb');
$bytes = fread($handle, 3);
fclose($handle);

if (strpos($bytes, $magicBytes[$extensao]) !== 0) {
    die('Assinatura de arquivo inválida');
}

4. Sanitização do Nome do Arquivo e Prevenção de Path Traversal

Nunca use o nome original do arquivo. Remova caracteres perigosos e gere identificadores únicos:

function gerarNomeSeguro(): string {
    $extensao = strtolower(pathinfo($_FILES['arquivo']['name'], PATHINFO_EXTENSION));
    $nome = hash('sha256', uniqid(mt_rand(), true) . $_FILES['arquivo']['tmp_name']);
    return $nome . '.' . $extensao;
}

// Sanitização adicional do nome original (apenas para logging)
$nomeOriginal = preg_replace(
    '/[^a-zA-Z0-9._-]/', 
    '_', 
    basename($_FILES['arquivo']['name'])
);

Configure o diretório de destino sem permissão de execução:

mkdir -p /var/uploads
chmod 0755 /var/uploads

Use realpath() para garantir que o destino está dentro do diretório permitido:

$diretorioBase = realpath('/var/uploads');
$destino = $diretorioBase . '/' . gerarNomeSeguro();

if (strpos($destino, $diretorioBase) !== 0) {
    die('Tentativa de path traversal detectada');
}

5. Limitação de Tamanho e Prevenção de Negação de Serviço (DoS)

Além das configurações do php.ini, valide o tamanho no servidor:

$tamanhoMaximo = 10 * 1024 * 1024; // 10 MB
if ($_FILES['arquivo']['size'] > $tamanhoMaximo) {
    die('Arquivo muito grande');
}

// Controle de cota por sessão
session_start();
if (!isset($_SESSION['upload_total'])) {
    $_SESSION['upload_total'] = 0;
}

$cotaMaxima = 50 * 1024 * 1024; // 50 MB por sessão
if ($_SESSION['upload_total'] + $_FILES['arquivo']['size'] > $cotaMaxima) {
    die('Cota de upload excedida');
}
$_SESSION['upload_total'] += $_FILES['arquivo']['size'];

Para imagens, verifique dimensões reais:

if (strpos($mime, 'image/') === 0) {
    $dimensoes = getimagesize($_FILES['arquivo']['tmp_name']);
    if (!$dimensoes) {
        die('Arquivo de imagem inválido ou corrompido');
    }

    $larguraMaxima = 4000;
    $alturaMaxima = 4000;
    if ($dimensoes[0] > $larguraMaxima || $dimensoes[1] > $alturaMaxima) {
        die('Dimensões de imagem excedem o limite');
    }
}

// Timeout para uploads grandes
set_time_limit(30);

6. Armazenamento Seguro e Tratamento de Imagens

Armazene arquivos fora da raiz pública do servidor. Crie um script intermediário para servir arquivos:

// download.php
$arquivo = basename($_GET['file']);
$caminho = '/var/uploads/' . $arquivo;

if (!file_exists($caminho)) {
    http_response_code(404);
    die('Arquivo não encontrado');
}

header('Content-Type: ' . mime_content_type($caminho));
header('Content-Disposition: attachment; filename="' . $arquivo . '"');
header('Content-Length: ' . filesize($caminho));
readfile($caminho);

Regenere imagens para remover metadados maliciosos:

function regenerarImagem(string $origem, string $destino, string $mime): bool {
    switch ($mime) {
        case 'image/jpeg':
            $img = imagecreatefromjpeg($origem);
            $salvou = imagejpeg($img, $destino, 85);
            break;
        case 'image/png':
            $img = imagecreatefrompng($origem);
            $salvou = imagepng($img, $destino, 6);
            break;
        case 'image/gif':
            $img = imagecreatefromgif($origem);
            $salvou = imagegif($img, $destino);
            break;
        default:
            return false;
    }
    imagedestroy($img);
    return $salvou;
}

Desative execução de scripts no diretório de uploads (Apache - .htaccess):

Options -ExecCGI -Indexes
RemoveHandler .php .phtml .php3 .php4 .php5
RemoveType .php .phtml .php3 .php4 .php5
php_flag engine off

7. Boas Práticas Adicionais e Logging

Implemente logging detalhado de todas as tentativas:

function logUpload(string $status, array $dados): void {
    $log = sprintf(
        "[%s] IP: %s | Status: %s | Arquivo: %s | Tamanho: %d | MIME: %s\n",
        date('Y-m-d H:i:s'),
        $_SERVER['REMOTE_ADDR'],
        $status,
        $dados['nome_original'] ?? 'N/A',
        $dados['tamanho'] ?? 0,
        $dados['mime'] ?? 'N/A'
    );
    file_put_contents('/var/log/uploads.log', $log, FILE_APPEND | LOCK_EX);
}

Implemente rate limiting simples:

session_start();
$limite = 5; // uploads por minuto
$janela = 60; // segundos

if (!isset($_SESSION['upload_timestamps'])) {
    $_SESSION['upload_timestamps'] = [];
}

$_SESSION['upload_timestamps'] = array_filter(
    $_SESSION['upload_timestamps'],
    fn($t) => $t > (time() - $janela)
);

if (count($_SESSION['upload_timestamps']) >= $limite) {
    die('Muitos uploads. Tente novamente em ' . ($_SESSION['upload_timestamps'][0] + $janela - time()) . ' segundos');
}

$_SESSION['upload_timestamps'][] = time();

Teste sua implementação com arquivos maliciosos como shell.php.jpg:

// O sistema deve rejeitar porque:
// 1. A extensão .jpg está na whitelist
// 2. O MIME real será text/plain ou application/x-php
// 3. Os magic bytes não corresponderão a JPEG
// 4. A regeneração da imagem falhará (não é uma imagem real)

Referências