<h2>Upload de Arquivos com PHP: Validação e Segurança</h2>
<p>Upload de arquivos é uma funcionalidade comum em aplicações web, mas também representa um dos maiores vetores de ataque se implementado incorretamente. Neste artigo, abordaremos desde o formulário HTML até técnicas avançadas de validação, focando sempre em segurança. Vou compartilhar a experiência que acumulei em anos trabalhando com essa feature crítica.</p>
<h2>Fundamentos: HTML e Configuração do PHP</h2>
<h3>O Formulário HTML Correto</h3>
<p>O formulário deve usar <code>enctype="multipart/form-data"</code> para transmitir arquivos corretamente. Sem isso, o servidor não recebe os dados do upload.</p>
<pre><code class="language-html"><form method="POST" enctype="multipart/form-data">
<input type="file" name="arquivo" accept=".pdf,.jpg,.png" required>
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<button type="submit">Enviar</button>
</form></code></pre>
<p>O atributo <code>accept</code> é apenas uma validação no cliente — nunca confie nela. O <code>MAX_FILE_SIZE</code> é processado pelo PHP, mas também adicione validação no servidor.</p>
<h3>Configurações PHP Essenciais</h3>
<p>Certifique-se de que seu <code>php.ini</code> está configurado corretamente:</p>
<pre><code class="language-ini">upload_max_filesize = 10M
post_max_size = 10M
upload_tmp_dir = /tmp/php_uploads
file_uploads = On</code></pre>
<p>Mantenha <code>upload_tmp_dir</code> em uma partição separada e com permissões restritas (755 ou 750). Nunca deixe como padrão do sistema.</p>
<h2>Validação Segura no Servidor</h2>
<h3>Validação de Tipo de Arquivo</h3>
<p>A validação mais comum — verificar a extensão — <strong>é frágil e perigosa</strong>. Um usuário mal-intencionado pode renomear um <code>.php</code> para <code>.jpg</code> e contornar essa verificação. Use a função <code>mime_content_type()</code> ou melhor ainda, a extensão <code>fileinfo</code>:</p>
<pre><code class="language-php"><?php
$arquivo = $_FILES['arquivo'] ?? null;
if (!$arquivo) {
die('Nenhum arquivo enviado.');
}
// Validar tamanho
$maxSize = 5 1024 1024; // 5MB
if ($arquivo['size'] > $maxSize) {
die('Arquivo muito grande.');
}
// Validar tipo MIME com fileinfo
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $arquivo['tmp_name']);
finfo_close($finfo);
$mimePermitidos = ['image/jpeg', 'image/png', 'application/pdf'];
if (!in_array($mimeType, $mimePermitidos, true)) {
die('Tipo de arquivo não permitido.');
}</code></pre>
<h3>Validação de Conteúdo</h3>
<p>Para imagens, use <code>getimagesize()</code> para garantir que é realmente uma imagem válida:</p>
<pre><code class="language-php"><?php
if ($mimeType === 'image/jpeg' || $mimeType === 'image/png') {
$imageInfo = @getimagesize($arquivo['tmp_name']);
if ($imageInfo === false) {
die('Imagem inválida ou corrompida.');
}
if ($imageInfo[0] > 4000 || $imageInfo[1] > 4000) {
die('Dimensões da imagem excedem o limite.');
}
}</code></pre>
<p>Para PDFs, valide o cabeçalho do arquivo:</p>
<pre><code class="language-php"><?php
if ($mimeType === 'application/pdf') {
$handle = fopen($arquivo['tmp_name'], 'rb');
$header = fread($handle, 4);
fclose($handle);
if ($header !== "%PDF") {
die('Arquivo PDF inválido.');
}
}</code></pre>
<h2>Armazenamento Seguro</h2>
<h3>Diretório Protegido Fora da Raiz Web</h3>
<p>Nunca armazene uploads na raiz pública (<code>/public_html</code>). Crie um diretório fora dela:</p>
<pre><code>/var/www/
├── html/ (raiz web pública)
├── uploads/ (armazenamento seguro - NÃO acessível via HTTP)
└── ...</code></pre>
<p>Configure as permissões:</p>
<pre><code class="language-bash">mkdir -p /var/www/uploads
chmod 750 /var/www/uploads
chown www-data:www-data /var/www/uploads</code></pre>
<h3>Renomeação Segura do Arquivo</h3>
<p>Nunca use o nome original do arquivo. Gere um nome único e seguro:</p>
<pre><code class="language-php"><?php
$nomeOriginal = basename($arquivo['name']);
$extensao = strtolower(pathinfo($nomeOriginal, PATHINFO_EXTENSION));
// Gerar nome único
$novoNome = bin2hex(random_bytes(16)) . '.' . $extensao;
$caminhoDestino = '/var/www/uploads/' . $novoNome;
if (!move_uploaded_file($arquivo['tmp_name'], $caminhoDestino)) {
die('Erro ao mover arquivo.');
}
// Armazenar no banco de dados
// INSERT INTO arquivos (nome_original, nome_armazenado, mime_type, usuario_id, data_upload)
// VALUES (?, ?, ?, ?, NOW())</code></pre>
<h3>Servir Arquivos com Segurança</h3>
<p>Crie um script download.php que valide permissões:</p>
<pre><code class="language-php"><?php
session_start();
$arquivoId = $_GET['id'] ?? null;
if (!$arquivoId) die('ID inválido.');
// Buscar arquivo no banco e verificar permissão do usuário
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$stmt = $pdo->prepare('SELECT nome_armazenado, mime_type FROM arquivos
WHERE id = ? AND usuario_id = ?');
$stmt->execute([$arquivoId, $_SESSION['usuario_id']]);
$arquivo = $stmt->fetch();
if (!$arquivo) die('Arquivo não encontrado.');
$caminhoArquivo = '/var/www/uploads/' . $arquivo['nome_armazenado'];
if (!file_exists($caminhoArquivo)) die('Arquivo não existe.');
header('Content-Type: ' . $arquivo['mime_type']);
header('Content-Disposition: attachment; filename="documento.pdf"');
header('Content-Length: ' . filesize($caminhoArquivo));
readfile($caminhoArquivo);</code></pre>
<h2>Defesas Adicionais</h2>
<h3>Verificar Erros de Upload</h3>
<pre><code class="language-php"><?php
if ($arquivo['error'] !== UPLOAD_ERR_OK) {
$erros = [
UPLOAD_ERR_INI_SIZE => 'Arquivo excede limite ini_set',
UPLOAD_ERR_FORM_SIZE => 'Arquivo excede MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => 'Upload interrompido',
UPLOAD_ERR_NO_FILE => 'Nenhum arquivo',
UPLOAD_ERR_NO_TMP_DIR => 'Diretório temporário ausente',
UPLOAD_ERR_CANT_WRITE => 'Erro ao escrever arquivo',
UPLOAD_ERR_EXTENSION => 'Upload bloqueado por extensão'
];
die('Erro: ' . ($erros[$arquivo['error']] ?? 'Desconhecido'));
}</code></pre>
<h3>Desabilitar Execução no Diretório de Uploads</h3>
<p>Se usar Apache, crie <code>.htaccess</code> no diretório de uploads:</p>
<pre><code class="language-apache"><FilesMatch "\.php$">
Deny from all
</FilesMatch>
AddType text/plain .php .phtml .php3 .php4 .php5 .php7 .phar</code></pre>
<p>Para Nginx, configure no bloco <code>location</code>:</p>
<pre><code class="language-nginx">location /uploads {
location ~ \.php$ {
return 403;
}
}</code></pre>
<h2>Conclusão</h2>
<p>Upload seguro exige <strong>defesa em profundidade</strong>. Resumo dos três pilares:</p>
<ol>
<li><strong>Validação rigorosa</strong>: Use <code>fileinfo</code> e <code>getimagesize()</code>, nunca confie apenas em extensão ou MIME-Type do cliente.</li>
</ol>
<ol>
<li><strong>Armazenamento protegido</strong>: Coloque uploads fora da raiz web, renomeie arquivos com valores aleatórios e implemente controle de acesso no código.</li>
</ol>
<ol>
<li><strong>Defesas periféricas</strong>: Configure PHP corretamente, desabilite execução de scripts no diretório de uploads e valide erros de upload.</li>
</ol>
<p>A segurança é um processo contínuo. Mantenha seu conhecimento atualizado acompanhando os CVEs relacionados a upload de arquivos.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://www.php.net/manual/en/features.file-upload.php" target="_blank" rel="noopener noreferrer">PHP: Handling File Uploads</a></li>
<li><a href="https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload" target="_blank" rel="noopener noreferrer">OWASP: Unrestricted File Upload</a></li>
<li><a href="https://www.php.net/manual/en/book.fileinfo.php" target="_blank" rel="noopener noreferrer">PHP: fileinfo extension</a></li>
<li><a href="https://cwe.mitre.org/data/definitions/434.html" target="_blank" rel="noopener noreferrer">CWE-434: Unrestricted Upload of File with Dangerous Type</a></li>
<li><a href="https://portswigger.net/web-security/file-upload" target="_blank" rel="noopener noreferrer">PortSwigger: File Upload Vulnerabilities</a></li>
</ul>