move_uploaded_file() 雖然能一行完成移動,但直接使用無異於裸奔。生產環境必須處理:文件類型偽裝、大小超限、惡意腳本執行、文件名衝突、目錄遍歷 等風險。本文提供一個可直接落地的安全上傳範例。
<?php
/**
* 安全文件上傳處理器
* @param string $inputName 表單 file 控件 name 屬性
* @param array $config 配置陣列
* @return array|false 成功返回文件信息,失敗返回 false
*/
function secure_upload(string $inputName, array $config = []): array|false {
// 1. 合並預設配置
$defaults = [
'allowedTypes' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx'],
'maxSize' => 2 * 1024 * 1024, // 2MB
'uploadDir' => __DIR__ . '/uploads/', // 絕對路徑
'prefix' => 'upload_', // 文件名前綴
];
$cfg = array_merge($defaults, $config);
// 2. 基礎檢查
if (empty($_FILES[$inputName]) || $_FILES[$inputName]['error'] !== UPLOAD_ERR_OK) {
return false; // 無文件或上傳出錯
}
$file = $_FILES[$inputName];
// 3. 文件大小驗證(雙重保障:客戶端 MAX_FILE_SIZE + 服務端)
if ($file['size'] > $cfg['maxSize']) {
throw new Exception("文件大小超過限制 ({$cfg['maxSize']} 字節)");
}
// 4. 文件類型驗證:僅信任副檔名不夠,需結合 MIME 類型與魔術字節
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $cfg['allowedTypes'], true)) {
throw new Exception("不允許的文件類型: {$ext}");
}
// 進階:檢測真實 MIME 類型(需啟用 fileinfo 擴展)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowedMimes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (!isset($allowedMimes[$ext]) || $allowedMimes[$ext] !== $mime) {
throw new Exception("文件內容與副檔名不符,疑似偽裝文件");
}
// 5. 確保上傳目錄存在且不可執行腳本
if (!is_dir($cfg['uploadDir'])) {
mkdir($cfg['uploadDir'], 0755, true);
// 寫入 .htaccess 禁止腳本執行(Apache)
file_put_contents($cfg['uploadDir'] . '.htaccess', "SetHandler default-handler\nOptions -ExecCGI");
}
// 6. 生成唯一安全文件名:前綴 + 時間戳 + 隨機字符 + 副檔名
$safeName = $cfg['prefix'] . time() . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$destPath = $cfg['uploadDir'] . $safeName;
// 7. 移動文件(原子操作,跨分區安全)
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
throw new Exception("文件移動失敗,請檢查目錄權限");
}
// 8. 設置文件權限:僅可讀寫,不可執行
chmod($destPath, 0644);
// 9. 返回標準化信息
return [
'original_name' => $file['name'],
'saved_name' => $safeName,
'path' => $destPath,
'url' => str_replace($_SERVER['DOCUMENT_ROOT'], '', $destPath), // 相對 Web 根目錄 URL
'size' => $file['size'],
'mime' => $mime,
'ext' => $ext,
];
}
// ===== 使用示例 =====
try {
$result = secure_upload('resumefile', [
'allowedTypes' => ['jpg', 'png', 'gif', 'doc', 'docx', 'pdf'],
'maxSize' => 1024 * 1024, // 1MB
'uploadDir' => __DIR__ . '/upload/resume/',
]);
if ($result) {
// $result['url'] 可直接存入數據庫
echo "上傳成功: {$result['url']}";
}
} catch (Exception $e) {
// 統一錯誤處理:記錄日誌、返回友好提示
error_log("上傳失敗: " . $e->getMessage());
echo "上傳失敗: " . $e->getMessage();
}
| 風險點 | 防禦措施 | 代碼體現 |
|---|---|---|
| 副檔名偽裝 (.php.jpg) | 白名單驗證副檔名 + MIME 類型雙重校驗 | 步驟 4 |
| 大文件撐爆磁盤 | 服務端強制限制 $maxSize | 步驟 3 |
| 文件名衝突覆蓋 | 時間戳 + 隨機字節生成唯一名 | 步驟 6 |
| 上傳目錄執行腳本 | .htaccess 禁用執行權限 + chmod 0644 | 步驟 5、7 |
| 路徑遍歷攻擊 | basename() + 固定上傳目錄,不使用用戶輸入路徑 | 步驟 6 |
| MIME 偽造 | finfo 魔術字節檢測真實類型 | 步驟 4 |
安全文件上傳的核心原則是 「不信任任何客戶端數據」:副檔名可偽造、MIME 可偽造、文件名可攜帶路徑。通過 白名單機制、魔術字節驗證、隨機重命名、執行權限隔離 四層防線,可有效阻斷 99% 以上的上傳型攻擊。