cloud_upload 15. Upload Seguro de Arquivos - Validação e Storage

Upload de arquivos é uma funcionalidade comum mas extremamente perigosa se mal implementada. Atacantes podem enviar scripts maliciosos disfarçados de imagens, arquivos executáveis, ou até mesmo tentar sobrescrever arquivos críticos do sistema. Nesta seção, você vai aprender a implementar upload 100% seguro.

Vamos cobrir: validação de tipo MIME (não confie apenas na extensão), limite de tamanho, renomeação segura de arquivos, armazenamento fora do webroot, integração com AWS S3 para storage escalável, e sanitização de imagens para remover metadados perigosos.

error
CRÍTICO: Nunca confie em dados enviados pelo usuário! Valide TUDO: tipo, tamanho, nome do arquivo. Um único arquivo malicioso pode comprometer todo o servidor.

security Vetores de Ataque em Upload

bug_report Arquivo Malicioso

Atacante envia shell.php.jpg ou manipula MIME type para executar código no servidor.

folder Path Traversal

Nome de arquivo como ../../../etc/passwd para sobrescrever arquivos do sistema.

dns Denial of Service

Upload de arquivo gigante (10GB) para esgotar espaço em disco e derrubar o servidor.

code Backend PHP - Upload Seguro Local

// services/FileUploadService.php
uploadDir = $uploadDir ?? __DIR__ . '/../../storage/uploads';
        
        if (!is_dir($this->uploadDir)) {
            mkdir($this->uploadDir, 0755, true);
        }
    }
    
    /**
     * Upload seguro de arquivo
     */
    public function upload(array $file, int $userId): array {
        // 1. Validações básicas
        if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
            throw new \Exception('Arquivo inválido');
        }
        
        // 2. Verificar erros de upload
        if ($file['error'] !== UPLOAD_ERR_OK) {
            throw new \Exception($this->getUploadErrorMessage($file['error']));
        }
        
        // 3. Verificar tamanho
        if ($file['size'] > $this->maxFileSize) {
            throw new \Exception('Arquivo muito grande. Máximo: 5MB');
        }
        
        // 4. Verificar MIME type REAL (não apenas extensão)
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mimeType = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        
        if (!in_array($mimeType, $this->allowedMimeTypes)) {
            throw new \Exception('Tipo de arquivo não permitido');
        }
        
        // 5. Gerar nome seguro (evita path traversal)
        $extension = $this->getExtensionFromMime($mimeType);
        $safeName = $this->generateSafeName($userId, $extension);
        
        // 6. Caminho completo
        $targetPath = $this->uploadDir . '/' . $safeName;
        
        // 7. Mover arquivo
        if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
            throw new \Exception('Erro ao salvar arquivo');
        }
        
        // 8. Sanitizar imagem (remover EXIF perigoso)
        if (str_starts_with($mimeType, 'image/')) {
            $this->sanitizeImage($targetPath, $mimeType);
        }
        
        // 9. Retornar metadados
        return [
            'filename' => $safeName,
            'path' => $targetPath,
            'url' => '/storage/uploads/' . $safeName, // URL pública
            'mime_type' => $mimeType,
            'size' => $file['size']
        ];
    }
    
    /**
     * Gerar nome de arquivo seguro
     */
    private function generateSafeName(int $userId, string $extension): string {
        // Formato: user_{userId}_{timestamp}_{random}.{ext}
        return sprintf(
            'user_%d_%d_%s.%s',
            $userId,
            time(),
            bin2hex(random_bytes(8)),
            $extension
        );
    }
    
    /**
     * Mapear MIME type para extensão
     */
    private function getExtensionFromMime(string $mimeType): string {
        $map = [
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/gif' => 'gif',
            'image/webp' => 'webp',
            'application/pdf' => 'pdf'
        ];
        
        return $map[$mimeType] ?? 'bin';
    }
    
    /**
     * Sanitizar imagem (remover metadados EXIF)
     */
    private function sanitizeImage(string $path, string $mimeType): void {
        try {
            switch ($mimeType) {
                case 'image/jpeg':
                    $img = imagecreatefromjpeg($path);
                    imagejpeg($img, $path, 90);
                    imagedestroy($img);
                    break;
                    
                case 'image/png':
                    $img = imagecreatefrompng($path);
                    imagepng($img, $path, 9);
                    imagedestroy($img);
                    break;
            }
        } catch (\Exception $e) {
            error_log("Erro ao sanitizar imagem: " . $e->getMessage());
        }
    }
    
    /**
     * Traduzir códigos de erro de upload
     */
    private function getUploadErrorMessage(int $code): string {
        $errors = [
            UPLOAD_ERR_INI_SIZE => 'Arquivo excede limite do servidor',
            UPLOAD_ERR_FORM_SIZE => 'Arquivo excede limite do formulário',
            UPLOAD_ERR_PARTIAL => 'Upload incompleto',
            UPLOAD_ERR_NO_FILE => 'Nenhum arquivo enviado',
            UPLOAD_ERR_NO_TMP_DIR => 'Diretório temporário não encontrado',
            UPLOAD_ERR_CANT_WRITE => 'Erro ao escrever arquivo',
            UPLOAD_ERR_EXTENSION => 'Upload bloqueado por extensão PHP'
        ];
        
        return $errors[$code] ?? 'Erro desconhecido no upload';
    }
    
    /**
     * Deletar arquivo
     */
    public function delete(string $filename): bool {
        $path = $this->uploadDir . '/' . basename($filename);
        
        if (file_exists($path)) {
            return unlink($path);
        }
        
        return false;
    }
}

api API Endpoint de Upload

// api/upload/avatar.php
 'Não autenticado']);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'Método não permitido']);
    exit;
}

try {
    $userId = $_SESSION['user_id'];
    
    // Verificar se arquivo foi enviado
    if (!isset($_FILES['avatar'])) {
        http_response_code(400);
        echo json_encode(['error' => 'Nenhum arquivo enviado']);
        exit;
    }
    
    // Upload do arquivo
    $uploadService = new App\Services\FileUploadService();
    $result = $uploadService->upload($_FILES['avatar'], $userId);
    
    // Atualizar avatar do usuário no banco
    $db = getDBConnection();
    $stmt = $db->prepare("
        UPDATE users 
        SET avatar_url = ?
        WHERE id = ?
    ");
    $stmt->execute([$result['url'], $userId]);
    
    // Deletar avatar antigo (se existir)
    $stmt = $db->prepare("SELECT avatar_url FROM users WHERE id = ?");
    $stmt->execute([$userId]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);
    
    if ($user['avatar_url']) {
        $oldFilename = basename($user['avatar_url']);
        $uploadService->delete($oldFilename);
    }
    
    echo json_encode([
        'success' => true,
        'file' => [
            'url' => $result['url'],
            'size' => $result['size']
        ]
    ]);
    
} catch (Exception $e) {
    error_log("Erro no upload: " . $e->getMessage());
    http_response_code(400);
    echo json_encode([
        'error' => $e->getMessage()
    ]);
}

cloud Integração com AWS S3

info
Por que S3? Storage local não escala. Com S3, você tem: armazenamento ilimitado, CDN integrado (CloudFront), backups automáticos e custo muito baixo ($0.023/GB/mês).
// services/S3UploadService.php
bucket = $_ENV['AWS_S3_BUCKET'];
        
        $this->s3 = new S3Client([
            'version' => 'latest',
            'region' => $_ENV['AWS_REGION'],
            'credentials' => [
                'key' => $_ENV['AWS_ACCESS_KEY_ID'],
                'secret' => $_ENV['AWS_SECRET_ACCESS_KEY']
            ]
        ]);
    }
    
    /**
     * Upload para S3
     */
    public function upload(array $file, int $userId): array {
        // Validações (mesmo código anterior)
        $this->validate($file);
        
        // Gerar nome seguro
        $mimeType = $this->getMimeType($file['tmp_name']);
        $extension = $this->getExtensionFromMime($mimeType);
        $key = "uploads/user_{$userId}/" . uniqid() . ".{$extension}";
        
        // Upload para S3
        $result = $this->s3->putObject([
            'Bucket' => $this->bucket,
            'Key' => $key,
            'SourceFile' => $file['tmp_name'],
            'ContentType' => $mimeType,
            'ACL' => 'public-read', // Ou 'private' se quiser URLs assinadas
            'Metadata' => [
                'user-id' => (string) $userId,
                'uploaded-at' => date('Y-m-d H:i:s')
            ]
        ]);
        
        // URL pública do arquivo
        $url = $result['ObjectURL'];
        
        return [
            'key' => $key,
            'url' => $url,
            'mime_type' => $mimeType,
            'size' => $file['size']
        ];
    }
    
    /**
     * Deletar do S3
     */
    public function delete(string $key): bool {
        try {
            $this->s3->deleteObject([
                'Bucket' => $this->bucket,
                'Key' => $key
            ]);
            return true;
        } catch (\Exception $e) {
            error_log("Erro ao deletar do S3: " . $e->getMessage());
            return false;
        }
    }
    
    /**
     * Gerar URL assinada (acesso temporário)
     */
    public function getSignedUrl(string $key, int $expiresInSeconds = 3600): string {
        $cmd = $this->s3->getCommand('GetObject', [
            'Bucket' => $this->bucket,
            'Key' => $key
        ]);
        
        $request = $this->s3->createPresignedRequest($cmd, "+{$expiresInSeconds} seconds");
        
        return (string) $request->getUri();
    }
}

code Frontend React - Upload com Preview

// components/FileUpload.jsx
import { useState, useRef } from 'react';
import axios from 'axios';

const API_URL = import.meta.env.VITE_API_URL;

export const FileUpload = ({ onUploadSuccess }) => {
    const [file, setFile] = useState(null);
    const [preview, setPreview] = useState(null);
    const [uploading, setUploading] = useState(false);
    const [progress, setProgress] = useState(0);
    const [error, setError] = useState(null);
    const fileInputRef = useRef(null);

    const handleFileSelect = (e) => {
        const selectedFile = e.target.files[0];
        
        if (!selectedFile) return;
        
        // Validação no frontend (não substitui backend!)
        if (selectedFile.size > 5 * 1024 * 1024) {
            setError('Arquivo muito grande. Máximo: 5MB');
            return;
        }
        
        const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
        if (!validTypes.includes(selectedFile.type)) {
            setError('Tipo de arquivo não permitido');
            return;
        }
        
        setFile(selectedFile);
        setError(null);
        
        // Preview da imagem
        if (selectedFile.type.startsWith('image/')) {
            const reader = new FileReader();
            reader.onload = (e) => {
                setPreview(e.target.result);
            };
            reader.readAsDataURL(selectedFile);
        }
    };

    const handleUpload = async () => {
        if (!file) return;
        
        setUploading(true);
        setProgress(0);
        
        const formData = new FormData();
        formData.append('avatar', file);
        
        try {
            const response = await axios.post(
                `${API_URL}/upload/avatar`,
                formData,
                {
                    withCredentials: true,
                    headers: {
                        'Content-Type': 'multipart/form-data'
                    },
                    onUploadProgress: (progressEvent) => {
                        const percentCompleted = Math.round(
                            (progressEvent.loaded * 100) / progressEvent.total
                        );
                        setProgress(percentCompleted);
                    }
                }
            );
            
            onUploadSuccess && onUploadSuccess(response.data.file);
            
            // Resetar
            setFile(null);
            setPreview(null);
            setProgress(0);
            
        } catch (err) {
            console.error('Erro no upload:', err);
            setError(err.response?.data?.error || 'Erro ao fazer upload');
        } finally {
            setUploading(false);
        }
    };

    return (
        
{!file && (
fileInputRef.current.click()} > cloud_upload

Clique ou arraste uma imagem aqui

Máximo: 5MB | Formatos: JPG, PNG, GIF, WebP
)} {preview && (
Preview
)} {uploading && (
)} {error && (
error
{error}
)}
); };

check_circle Checklist de Segurança em Upload

✅ Sempre Faça

  • Validar MIME type real (não só extensão)
  • Limitar tamanho do arquivo
  • Renomear arquivo (remover nome original)
  • Armazenar fora do webroot (ou usar S3)
  • Sanitizar imagens (remover EXIF)
  • Usar is_uploaded_file() para verificar origem
  • Rate limit em uploads

❌ Nunca Faça

  • Confiar em extensão do arquivo
  • Usar nome de arquivo fornecido pelo usuário
  • Permitir upload ilimitado
  • Executar arquivos uploadados
  • Armazenar em pasta pública sem validação
  • Permitir sobrescrita de arquivos existentes
rocket_launch
Próximo Passo: Upload seguro implementado! Na próxima seção, vamos abordar proteção contra ataques (XSS, CSRF, SQL Injection, Rate Limiting).