File manager - Edit - /var/www/html/portal/app/Helpers/FileUploadHelper.php
Back
<?php namespace App\Helpers; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use InvalidArgumentException; /** * Helper seguro para validação e processamento de uploads de arquivos. * * Fornece validação robusta contra uploads maliciosos através de: * - Whitelist de extensões * - Validação de MIME type real * - Verificação de magic bytes */ class FileUploadHelper { /** * Extensões perigosas que devem ser SEMPRE bloqueadas, independente da whitelist */ private const DANGEROUS_EXTENSIONS = [ 'exe', 'bat', 'cmd', 'com', 'pif', 'scr', 'vbs', 'js', 'jar', 'msi', 'dll', 'sh', 'bash', 'zsh', 'csh', 'ksh', 'php', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', 'phtml', 'phps', 'phar', 'pht', 'inc', 'shtml', 'asp', 'aspx', 'jsp', 'jspx', 'cgi', 'pl', 'py', 'rb', 'ps1', 'psm1', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'run', 'bin', 'action', 'htaccess' ]; /** * Whitelists pré-definidas por tipo de arquivo */ public const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']; public const ALLOWED_IMAGE_MIME_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/x-ms-bmp' ]; public const ALLOWED_DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'odt', 'ods']; public const ALLOWED_DOCUMENT_MIME_TYPES = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet' ]; public const ALLOWED_GENERAL_EXTENSIONS = [ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip', 'rar', // Imagens também permitidas para uploads gerais (ex.: repositório) 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', // Vídeos (ex.: repositório) - mp4 pode ser detectado como mov/qt pelo MIME type video/quicktime 'mp4', 'mov', 'qt' ]; public const ALLOWED_GENERAL_MIME_TYPES = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', 'application/zip', 'application/x-rar-compressed', 'application/x-rar', // Mimes de imagem também permitidos para uploads gerais 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/x-ms-bmp', // Vídeos (ex.: repositório) 'video/mp4', 'video/quicktime' // MP4 com container QuickTime ]; /** * Magic bytes (assinaturas de arquivo) para validação */ private const MAGIC_BYTES = [ 'jpg' => ["\xFF\xD8\xFF"], 'jpeg' => ["\xFF\xD8\xFF"], 'png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"], 'gif' => ["\x47\x49\x46\x38\x37\x61", "\x47\x49\x46\x38\x39\x61"], 'webp' => ["\x52\x49\x46\x46"], 'bmp' => ["\x42\x4D"], 'pdf' => ["\x25\x50\x44\x46"], 'zip' => ["\x50\x4B\x03\x04", "\x50\x4B\x05\x06", "\x50\x4B\x07\x08"], 'rar' => ["\x52\x61\x72\x21\x1A\x07", "\x52\x61\x72\x21\x1A\x07\x00"], 'mp4' => ["\x00\x00\x00\x18\x66\x74\x79\x70", "\x00\x00\x00\x20\x66\x74\x79\x70", "\x00\x00\x00\x1C\x66\x74\x79\x70"], // MP4 começa com ftyp em diferentes offsets 'mov' => ["\x00\x00\x00\x14\x66\x74\x79\x70", "\x00\x00\x00\x18\x66\x74\x79\x70", "\x00\x00\x00\x20\x66\x74\x79\x70"], // MOV/QuickTime também usa ftyp 'qt' => ["\x00\x00\x00\x14\x66\x74\x79\x70", "\x00\x00\x00\x18\x66\x74\x79\x70", "\x00\x00\x00\x20\x66\x74\x79\x70"], // QuickTime ]; /** * Magic bytes de arquivos executáveis perigosos que devem ser SEMPRE bloqueados */ private const DANGEROUS_MAGIC_BYTES = [ // PE (Portable Executable) - Windows executáveis (.exe, .dll, .sys) "\x4D\x5A", // MZ header // ELF - Linux executáveis "\x7F\x45\x4C\x46", // ELF header // Mach-O - macOS executáveis "\xFE\xED\xFA", // Mach-O 32-bit "\xCE\xFA\xED\xFE", // Mach-O 32-bit (little endian) "\xCF\xFA\xED\xFE", // Mach-O 64-bit (little endian) "\xFE\xED\xFA\xCE", // Mach-O 32-bit (big endian) "\xFE\xED\xFA\xCF", // Mach-O 64-bit (big endian) ]; /** * Valida extensão do arquivo contra whitelist * * @param UploadedFile $file * @param array $allowedExtensions * @return bool */ public static function validateFileExtension(UploadedFile $file, array $allowedExtensions): bool { $extension = self::getSafeExtension($file, $allowedExtensions); return $extension !== null; } /** * Valida MIME type real do arquivo * * @param UploadedFile $file * @param array $allowedMimeTypes * @return bool */ public static function validateMimeType(UploadedFile $file, array $allowedMimeTypes): bool { $mimeType = $file->getMimeType(); if (!$mimeType) { return false; } // Normaliza MIME type para comparação (remove parâmetros como charset) $mimeType = strtolower(explode(';', $mimeType)[0]); foreach ($allowedMimeTypes as $allowed) { if (strtolower($allowed) === $mimeType) { return true; } } return false; } /** * Detecta conteúdo perigoso (executáveis ou PHP embutido) pelos magic bytes, * independentemente da extensão/whitelist. * * Indicado para fluxos com whitelist ampla (Office, ZIP, etc.) onde a * validação estrita de magic bytes por extensão geraria falsos negativos, * mas ainda se deseja bloquear polyglots/webshells disfarçados. * * @param UploadedFile $file * @return bool true se o conteúdo for perigoso (deve ser rejeitado) */ public static function containsDangerousContent(UploadedFile $file): bool { try { $path = $file->getRealPath(); if ($path === false || $path === '') { return true; } $handle = fopen($path, 'rb'); if (!$handle) { return true; } $firstBytes = fread($handle, 256); fclose($handle); if ($firstBytes === false || $firstBytes === '') { return false; } foreach (self::DANGEROUS_MAGIC_BYTES as $dangerousSignature) { if (substr($firstBytes, 0, strlen($dangerousSignature)) === $dangerousSignature) { self::logSecurityViolation($file, 'Conteúdo executável detectado nos magic bytes'); return true; } } if (stripos($firstBytes, '<?php') !== false || strpos($firstBytes, '<?=') !== false) { self::logSecurityViolation($file, 'Conteúdo PHP detectado no arquivo'); return true; } return false; } catch (\Exception $e) { Log::error('Erro ao inspecionar conteúdo do arquivo: ' . $e->getMessage()); return true; } } /** * Valida magic bytes do arquivo * * @param UploadedFile $file * @param array $allowedExtensions * @return bool */ public static function validateMagicBytes(UploadedFile $file, array $allowedExtensions): bool { try { $extension = self::getSafeExtension($file, $allowedExtensions); if (!$extension) { return false; } $handle = fopen($file->getRealPath(), 'rb'); if (!$handle) { return false; } // Lê os primeiros bytes (até 32 bytes para cobrir assinaturas de MP4 e outros formatos) $firstBytes = fread($handle, 32); fclose($handle); if (!$firstBytes) { return false; } // BLOQUEIO CRÍTICO: Verifica primeiro se é um arquivo executável perigoso foreach (self::DANGEROUS_MAGIC_BYTES as $dangerousSignature) { if (substr($firstBytes, 0, strlen($dangerousSignature)) === $dangerousSignature) { self::logSecurityViolation($file, 'Arquivo executável detectado nos magic bytes'); return false; } } // Verificação adicional: detecta arquivos PHP disfarçados if (strpos($firstBytes, '<?php') !== false || strpos($firstBytes, '<?=') !== false) { self::logSecurityViolation($file, 'Arquivo PHP detectado nos magic bytes'); return false; } // Verifica se temos magic bytes definidos para esta extensão if (!isset(self::MAGIC_BYTES[$extension])) { // Se não temos magic bytes definidos, aceita (para tipos como docx, xlsx que são ZIPs) // Mas ainda valida que não seja executável return true; } $signatures = self::MAGIC_BYTES[$extension]; // Verifica se algum dos magic bytes corresponde foreach ($signatures as $signature) { // Para MP4/MOV/QT, verifica se contém "ftyp" que é a marca característica if (in_array($extension, ['mp4', 'mov', 'qt'])) { // MP4/MOV/QT começam com um box size (4 bytes) seguido de "ftyp" // Verifica se "ftyp" está presente nos primeiros 32 bytes if (strpos($firstBytes, "\x66\x74\x79\x70") !== false) { return true; } } else { // Para outros formatos, verifica no início if (substr($firstBytes, 0, strlen($signature)) === $signature) { return true; } } } // Se não correspondeu a nenhum magic byte esperado, rejeita self::logSecurityViolation($file, 'Magic bytes não correspondem à extensão esperada'); return false; } catch (\Exception $e) { Log::error('Erro ao validar magic bytes: ' . $e->getMessage()); return false; } } /** * Obtém extensão segura do arquivo usando guessExtension() do Laravel * * @param UploadedFile $file * @param array $allowedExtensions * @return string|null Retorna extensão se válida, null caso contrário */ public static function getSafeExtension(UploadedFile $file, array $allowedExtensions): ?string { // Normaliza extensões permitidas para lowercase $allowedExtensions = array_map('strtolower', $allowedExtensions); $dangerousExtensions = array_map('strtolower', self::DANGEROUS_EXTENSIONS); // Obtém nome original completo do arquivo para detectar extensões duplas $originalName = strtolower($file->getClientOriginalName()); // Extrai todas as extensões do nome original (ex: file.exe.png -> ['exe', 'png']) $nameParts = explode('.', $originalName); if (count($nameParts) > 1) { $allExtensions = array_slice($nameParts, 1); // Remove o nome do arquivo, mantém apenas extensões } else { $allExtensions = []; } // BLOQUEIO CRÍTICO: Verifica se alguma extensão perigosa está presente no nome original foreach ($allExtensions as $ext) { $ext = trim($ext); if (in_array($ext, $dangerousExtensions)) { self::logSecurityViolation($file, "Extensão perigosa detectada no nome do arquivo: {$ext}"); return null; } } // Usa guessExtension() do Laravel que é mais seguro que getClientOriginalExtension() $guessedExt = $file->guessExtension(); $extension = $guessedExt ? strtolower($guessedExt) : null; // Se guessExtension() retornar null, tenta usar getClientOriginalExtension() como fallback // mas sempre valida contra whitelist if (!$extension) { $originalExt = $file->getClientOriginalExtension(); $originalExtension = $originalExt ? strtolower(trim($originalExt)) : ''; // Remove qualquer caractere perigoso (null bytes, path traversal, etc.) $originalExtension = preg_replace('/[^a-z0-9]/', '', $originalExtension); // Verifica se a extensão original é perigosa if (in_array($originalExtension, $dangerousExtensions)) { self::logSecurityViolation($file, "Extensão perigosa detectada: {$originalExtension}"); return null; } if (in_array($originalExtension, $allowedExtensions)) { $extension = $originalExtension; } } // Remove extensões duplicadas (ex: file.php.jpg -> jpg) if ($extension && strpos($extension, '.') !== false) { $parts = explode('.', $extension); $extension = end($parts); } // Verifica novamente se a extensão final é perigosa if ($extension && in_array($extension, $dangerousExtensions)) { self::logSecurityViolation($file, "Extensão perigosa detectada após processamento: {$extension}"); return null; } // Valida contra whitelist if ($extension && in_array($extension, $allowedExtensions)) { return $extension; } return null; } /** * Gera nome de arquivo seguro * * @param UploadedFile $file * @param array $allowedExtensions * @return string * @throws InvalidArgumentException Se o arquivo não for válido */ public static function generateSafeFileName(UploadedFile $file, array $allowedExtensions): string { $extension = self::getSafeExtension($file, $allowedExtensions); if (!$extension) { throw new InvalidArgumentException('Extensão de arquivo não permitida'); } // Prioriza a extensão original do arquivo se for válida $originalExtension = strtolower($file->getClientOriginalExtension()); if (in_array($originalExtension, array_map('strtolower', $allowedExtensions))) { $extension = $originalExtension; } // Gera nome único usando uniqid e adiciona extensão segura return uniqid() . '.' . $extension; } /** * Sanitiza nome original do arquivo para armazenamento seguro no banco * Remove caracteres perigosos e valida contra extensões perigosas * * @param UploadedFile $file * @return string Nome sanitizado ou string vazia se for perigoso */ public static function sanitizeOriginalFileName(UploadedFile $file): string { $originalName = $file->getClientOriginalName(); // Extrai todas as extensões do nome original $nameParts = explode('.', strtolower($originalName)); if (count($nameParts) > 1) { $allExtensions = array_slice($nameParts, 1); // Verifica se alguma extensão é perigosa foreach ($allExtensions as $ext) { $ext = trim($ext); if (in_array($ext, array_map('strtolower', self::DANGEROUS_EXTENSIONS))) { // Se contém extensão perigosa, retorna apenas o nome base sem extensão $baseName = $nameParts[0]; return preg_replace('/[^a-zA-Z0-9_-]/', '_', $baseName); } } } // Remove caracteres perigosos mas mantém estrutura básica $sanitized = preg_replace('/[^a-zA-Z0-9._-]/', '_', $originalName); // Remove path traversal attempts $sanitized = str_replace(['../', '..\\', '/', '\\'], '', $sanitized); return $sanitized; } /** * Valida arquivo completo (extensão, MIME type e magic bytes) * * @param UploadedFile $file * @param array $allowedExtensions * @param array $allowedMimeTypes * @return bool */ public static function validateFile( UploadedFile $file, array $allowedExtensions, array $allowedMimeTypes ): bool { // Valida extensão if (!self::validateFileExtension($file, $allowedExtensions)) { self::logSecurityViolation($file, 'Extensão não permitida'); return false; } // Valida MIME type if (!self::validateMimeType($file, $allowedMimeTypes)) { self::logSecurityViolation($file, 'MIME type não permitido'); return false; } // Valida magic bytes if (!self::validateMagicBytes($file, $allowedExtensions)) { self::logSecurityViolation($file, 'Magic bytes não correspondem'); return false; } return true; } /** * Processa upload seguro de arquivo * * @param UploadedFile $file * @param string $folder * @param array $allowedExtensions * @param array $allowedMimeTypes * @return string|null Caminho do arquivo ou null se falhar */ public static function processSafeUpload( UploadedFile $file, string $folder, array $allowedExtensions, array $allowedMimeTypes ): ?string { // Valida arquivo completo if (!self::validateFile($file, $allowedExtensions, $allowedMimeTypes)) { return null; } try { $fileName = self::generateSafeFileName($file, $allowedExtensions); $ds = DIRECTORY_SEPARATOR; $storagePath = "public{$ds}{$folder}"; // Verifica se o diretório existe, cria se não existir $fullPath = storage_path("app{$ds}{$storagePath}"); if (!is_dir($fullPath)) { if (!mkdir($fullPath, 0755, true)) { Log::error("Não foi possível criar o diretório: {$fullPath}"); return null; } } $file->storeAs($storagePath, $fileName); return "pmar{$ds}assets{$ds}img{$ds}{$folder}{$ds}" . $fileName; } catch (\Exception $e) { Log::error('Erro ao processar upload: ' . $e->getMessage(), [ 'file' => $file->getClientOriginalName(), 'size' => $file->getSize(), 'mime' => $file->getMimeType(), 'folder' => $folder, 'trace' => $e->getTraceAsString() ]); return null; } } /** * Processa upload seguro de imagem * * @param UploadedFile $file * @param string $folder * @return string|null */ public static function processSafeImageUpload(UploadedFile $file, string $folder): ?string { return self::processSafeUpload( $file, $folder, self::ALLOWED_IMAGE_EXTENSIONS, self::ALLOWED_IMAGE_MIME_TYPES ); } /** * Processa upload seguro de documento * * @param UploadedFile $file * @param string $folder * @return string|null */ public static function processSafeDocumentUpload(UploadedFile $file, string $folder): ?string { return self::processSafeUpload( $file, $folder, self::ALLOWED_DOCUMENT_EXTENSIONS, self::ALLOWED_DOCUMENT_MIME_TYPES ); } /** * Processa upload seguro de documento e retorna path para uso com Storage::url() (link público). * Armazena em storage/app/public/{$folder} para ser servido via link simbólico do Laravel. * * @param UploadedFile $file * @param string $folder Pasta sob storage/app/public (ex.: editais-ptmar) * @return string|null Path relativo (ex.: editais-ptmar/arquivo.pdf) ou null se falhar */ public static function processSafeDocumentUploadStorageLink(UploadedFile $file, string $folder): ?string { if (!self::validateFile($file, self::ALLOWED_DOCUMENT_EXTENSIONS, self::ALLOWED_DOCUMENT_MIME_TYPES)) { return null; } try { $fileName = self::generateSafeFileName($file, self::ALLOWED_DOCUMENT_EXTENSIONS); $path = Storage::disk('public')->putFileAs($folder, $file, $fileName); return $path ?: null; } catch (\Exception $e) { Log::error('Erro ao processar upload (storage link): ' . $e->getMessage(), [ 'file' => $file->getClientOriginalName(), 'folder' => $folder, 'trace' => $e->getTraceAsString(), ]); return null; } } /** * Processa upload seguro de arquivo geral * * @param UploadedFile $file * @param string $folder * @return string|null */ public static function processSafeGeneralUpload(UploadedFile $file, string $folder): ?string { return self::processSafeUpload( $file, $folder, self::ALLOWED_GENERAL_EXTENSIONS, self::ALLOWED_GENERAL_MIME_TYPES ); } /** * Upload geral em storage/app/public/{directory} (disco public), retornando o caminho público * com barras normais (ex.: licitacoes_procedimentos/arquivo.pdf) para uso com asset() ou Storage. */ public static function processSafeGeneralUploadPublicDisk(UploadedFile $file, string $directoryWithinPublicDisk): ?string { if (!self::validateFile($file, self::ALLOWED_GENERAL_EXTENSIONS, self::ALLOWED_GENERAL_MIME_TYPES)) { return null; } try { $fileName = self::generateSafeFileName($file, self::ALLOWED_GENERAL_EXTENSIONS); $dir = trim(str_replace('\\', '/', $directoryWithinPublicDisk), '/'); $path = Storage::disk('public')->putFileAs($dir, $file, $fileName); return $path ?: null; } catch (\Exception $e) { Log::error('Erro ao processar upload (disco public): ' . $e->getMessage(), [ 'file' => $file->getClientOriginalName(), 'directory' => $directoryWithinPublicDisk, 'trace' => $e->getTraceAsString(), ]); return null; } } /** * Registra tentativa de upload malicioso * * @param UploadedFile $file * @param string $reason * @return void */ private static function logSecurityViolation(UploadedFile $file, string $reason): void { $userId = null; $ip = null; $url = null; try { $auth = auth(); if ($auth && method_exists($auth, 'check') && $auth->check()) { $user = $auth->user(); $userId = $user && isset($user->id) ? $user->id : null; } } catch (\Exception $e) { // Usuário não autenticado ou contexto não HTTP } try { if (app()->bound('request')) { $request = request(); $ip = $request->ip(); $url = $request->fullUrl(); } } catch (\Exception $e) { // Contexto não HTTP (ex: CLI, testes) } $logData = [ 'reason' => $reason, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'ip' => $ip, 'user_id' => $userId, 'url' => $url, ]; Log::warning('Tentativa de upload malicioso detectada', $logData); } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.28 | Generation time: 0.02 |
proxy
|
phpinfo
|
Settings