文件上傳是 Web 開發高頻功能,亦是安全漏洞高發區。本文從 $_FILES 超全局變量結構入手,構建一個**生產級上傳處理流程**:類型驗證、大小限制、錯誤碼處理、臨時文件移動、文件名衝突解決、目錄權限、防偽造上傳、MINE 偽裝繞過防御,並提供可直接落地的封裝類代碼。
$_FILES 結構詳解$_FILES['myfile'] = [
'name' => 'original.jpg', // 客戶端原始文件名 (不可信)
'type' => 'image/jpeg', // 客戶端 MIME (不可信,易偽造)
'tmp_name' => '/tmp/phpA1B2C3', // 服務器臨時存儲路徑 (關鍵)
'error' => 0, // 錯誤碼 (0=成功)
'size' => 123456 // 文件大小 (字節)
];
⚠️ **只有 tmp_name 和 size 是服務器端可信數據**,其他均來自客戶端請求,**嚴禁直接用於安全決策**。
// 配置常量
const ALLOWED_TYPES = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const UPLOAD_DIR = __DIR__ . '/uploads/';
// 1. 檢查是否有文件上傳
if (!isset($_FILES['myfile']) || $_FILES['myfile']['error'] === UPLOAD_ERR_NO_FILE) {
exit('請選擇文件');
}
// 2. 統一錯誤碼處理
$errorMsg = [
UPLOAD_ERR_INI_SIZE => '文件超過 php.ini upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => '文件超過表單 MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => '文件部分上傳',
UPLOAD_ERR_NO_FILE => '未選擇文件',
UPLOAD_ERR_NO_TMP_DIR => '臨時目錄缺失',
UPLOAD_ERR_CANT_WRITE => '寫入磁盤失敗',
UPLOAD_ERR_EXTENSION => '擴展阻止上傳',
];
if ($_FILES['myfile']['error'] !== UPLOAD_ERR_OK) {
exit($errorMsg[$_FILES['myfile']['error']] ?? '未知上傳錯誤');
}
// 3. 服務端大小二次校驗 (防繞過前端限制)
if ($_FILES['myfile']['size'] > MAX_SIZE) {
exit('文件過大');
}
// 4. 真實 MIME 類型檢測 (核心安全點)
// 必須用 finfo (Fileinfo 擴展) 解析臨時文件實際內容
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($_FILES['myfile']['tmp_name']);
if (!in_array($realMime, ALLOWED_TYPES, true)) {
exit('文件類型不允許');
}
// 5. 文件名安全處理
$ext = pathinfo($_FILES['myfile']['name'], PATHINFO_EXTENSION);
$ext = strtolower($ext);
$allowedExt = ['gif', 'jpg', 'jpeg', 'png', 'webp'];
if (!in_array($ext, $allowedExt)) {
exit('副檔名不允許');
}
// 生成唯一安全文件名:時間戳 + 隨機字符串 + 原副檔名
$safeName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$destPath = UPLOAD_DIR . $safeName;
// 6. 確保上傳目錄存在且可寫
if (!is_dir(UPLOAD_DIR)) {
mkdir(UPLOAD_DIR, 0755, true);
}
// 7. 移動臨時文件 (原子操作,跨分區自動複製+刪除)
if (!move_uploaded_file($_FILES['myfile']['tmp_name'], $destPath)) {
exit('文件保存失敗');
}
// 8. 可選:設置文件權限 (僅所有者讀寫)
chmod($destPath, 0644);
echo json_encode(['success' => true, 'filename' => $safeName, 'path' => $destPath]);
| 風險 | 防御措施 |
|---|---|
| 腳本上傳 (webshell) | 1. 禁用上傳目錄執行權限 (Nginx: location /uploads { php_flag engine off; } Apache: .htaccess SetHandler default-handler)2. 文件名不保留原名,去除特殊字符 3. 二次渲染/轉碼圖片 (GD/Imagick imagecreatefromstring -> imagejpeg) 殺毒 |
| MIME 偽裝 | **必須用 finfo 解析 tmp_name 實際內容**,不可信 $_FILES['type'] |
| 大文件 DoS | php.ini: upload_max_filesize, post_max_size, max_file_uploads; Nginx: client_max_body_size |
| 文件名覆蓋/遍歷 | 隨機重命名、basename()、路徑規範化 realpath() |
| 並發衝突 | 唯一名生成 (時間+隨機/UUID)、或分佈式鎖 |
| 目錄權限過高 | 目錄 0755、文件 0644、禁止目錄列表、禁止腳本執行 |
$_FILES['error']) 對照表UPLOAD_ERR_OK = 0 // 成功
UPLOAD_ERR_INI_SIZE = 1 // 超過 upload_max_filesize
UPLOAD_ERR_FORM_SIZE = 2 // 超過 MAX_FILE_SIZE
UPLOAD_ERR_PARTIAL = 3 // 部分上傳 (網絡中斷)
UPLOAD_ERR_NO_FILE = 4 // 無文件
UPLOAD_ERR_NO_TMP_DIR = 6 // 臨時目錄不存在
UPLOAD_ERR_CANT_WRITE = 7 // 寫入失敗 (權限/磁盤滿)
UPLOAD_ERR_EXTENSION = 8 // PHP 擴展阻止 (如 suhosin)
class FileUploader {
private $allowedMimes = [];
private $allowedExts = [];
private $maxSize;
private $uploadDir;
private $finfo;
public function __construct(array $config = []) {
$this->allowedMimes = $config['mimes'] ?? ['image/jpeg','image/png','image/gif','image/webp'];
$this->allowedExts = $config['exts'] ?? ['jpg','jpeg','png','gif','webp'];
$this->maxSize = $config['max_size'] ?? 2 * 1024 * 1024;
$this->uploadDir = rtrim($config['dir'] ?? __DIR__.'/uploads', '/') . '/';
$this->finfo = new finfo(FILEINFO_MIME_TYPE);
if (!is_dir($this->uploadDir)) mkdir($this->uploadDir, 0755, true);
}
public function upload(string $fieldName): array {
if (empty($_FILES[$fieldName]) || $_FILES[$fieldName]['error'] === UPLOAD_ERR_NO_FILE) {
return ['success' => false, 'msg' => '未選擇文件'];
}
$file = $_FILES[$fieldName];
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'msg' => '上傳錯誤: ' . $file['error']];
}
if ($file['size'] > $this->maxSize) {
return ['success' => false, 'msg' => '文件過大'];
}
$realMime = $this->finfo->file($file['tmp_name']);
if (!in_array($realMime, $this->allowedMimes, true)) {
return ['success' => false, 'msg' => '類型不允許: ' . $realMime];
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExts, true)) {
return ['success' => false, 'msg' => '副檔名不允許'];
}
$safeName = $this->generateSafeName($ext);
$dest = $this->uploadDir . $safeName;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
return ['success' => false, 'msg' => '移動失敗'];
}
@chmod($dest, 0644);
return ['success' => true, 'filename' => $safeName, 'path' => $dest, 'mime' => $realMime, 'size' => $file['size']];
}
private function generateSafeName(string $ext): string {
return date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
}
}
// 使用示例
$uploader = new FileUploader(['max_size' => 5*1024*1024, 'dir' => '/var/www/uploads']);
$result = $uploader->upload('avatar');
header('Content-Type: application/json');
echo json_encode($result);
文件上傳核心原則:**不信任客戶端任何元數據、服務端二次驗證實際內容、隨機化存儲路徑、隔離執行環境、最小權限原則**。掌握 finfo、move_uploaded_file、錯誤碼處理、目錄權限配置、Nginx/Apache 執行禁用,即可構建安全可靠的上傳服務。