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.
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
// 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 && (
)}
{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