檔案上傳是 Web 系統高風險功能,必須嚴格驗證類型、大小、檔名、儲存路徑,並防範惡意腳本執行。本文提供生產級上傳處理流程,包含前端表單、後端驗證邏輯、安全儲存策略、錯誤碼對照與進階防護清單。
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="2000000"> <!-- 客戶端提示,不可信 -->
<label>姓名:<input type="text" name="name" required></label>
<label>圖片:<input type="file" name="file" id="file" accept="image/jpeg,image/png,image/gif" required></label>
<button type="submit">上傳</button>
</form>
<?php
declare(strict_types=1);
// 1. 設定常數
const UPLOAD_DIR = __DIR__ . '/uploads/';
const ALLOWED_TYPES = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_FILENAME_LEN = 255;
// 2. 確保上傳目錄存在且不可執行
if (!is_dir(UPLOAD_DIR)) {
mkdir(UPLOAD_DIR, 0755, true);
// 關鍵:建立 .htaccess / web.config 禁止腳本執行
file_put_contents(UPLOAD_DIR . '.htaccess', "SetHandler default-handler\nOptions -ExecCGI\nphp_flag engine off");
}
$errors = [];
$savedPath = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 3. 基本錯誤碼檢查
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$errors[] = '上傳失敗:' . uploadErrorMessage($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE);
} else {
$file = $_FILES['file'];
// 4. 伺服器端二次驗證 (不可信任前端)
// 4.1 大小
if ($file['size'] > MAX_FILE_SIZE) {
$errors[] = '檔案過大,限制 ' . (MAX_FILE_SIZE/1024/1024) . 'MB';
}
// 4.2 MIME 類型 (使用 finfo 而非 $_FILES['type'] 瀏覽器可偽造)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!isset(ALLOWED_TYPES[$mime])) {
$errors[] = '不支援的檔案類型:' . $mime;
}
// 4.3 檔名安全化
$origName = basename($file['name']);
if (strlen($origName) > MAX_FILENAME_LEN) {
$errors[] = '檔名過長';
}
$ext = ALLOWED_TYPES[$mime];
$safeName = bin2hex(random_bytes(16)) . '.' . $ext; // 隨機檔名,避免覆蓋/注入
$destPath = UPLOAD_DIR . $safeName;
// 5. 移動檔案 (atomic 操作,避免 TOCTOU)
if (empty($errors)) {
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
$errors[] = '伺服器儲存失敗,請稍後再試';
} else {
// 6. 權限收斂 (僅讀)
chmod($destPath, 0644);
$savedPath = $destPath;
}
}
}
}
// 7. 回應 (AJAX 可回 JSON,傳統表單可重導向)
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => empty($errors),
'errors' => $errors,
'file' => $savedPath ? ['path' => $savedPath, 'url' => '/uploads/' . basename($savedPath)] : null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
/**
* 上傳錯誤碼轉中文訊息
*/
function uploadErrorMessage(int $code): string {
return match ($code) {
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 => '被 PHP 擴充套件中止',
default => '未知錯誤 (代碼: ' . $code . ')',
};
}
?>
| 風險 | 防護措施 | 實作位置 |
|---|---|---|
| 惡意腳本上傳 (.php/.phtml/.php5) | MIME 白名單 + 副檔名白名單 + 隨機重命名 + 目錄禁用執行權限 | 驗證邏輯、.htaccess |
| 檔名路徑遍歷 (../../etc/passwd) | basename() + 隨機檔名 + realpath() 檢查路徑前綴 | 檔名處理 |
| 大檔案耗盡磁碟/記憶體 | upload_max_filesize, post_max_size, MAX_FILE_SIZE 三層限制 | php.ini、表單、程式碼 |
| MIME 偽造 (圖片夾帶 PHP 代碼) | finfo 偵測真實類型 + getimagesize() 驗證圖片結構 | 驗證邏輯 |
| 併發上傳檔名衝突 | 隨機檔名 (random_bytes) 或 tempnam() | 檔名生成 |
| 上傳目錄可直接瀏覽/下載 | Nginx location /uploads { internal; } 或 PHP 代理下載 | Web Server 設定 |
// 上傳成功後,可同步/異步生成縮圖、水印、WebP 轉換 // 同步範例 (GD) $img = imagecreatefromstring(file_get_contents($savedPath)); // ... 縮放/水印 ... imagejpeg($img, $savedPath . '.thumb.jpg', 80); imagedestroy($img); // 生產環境建議:丟入佇列 (Redis/RabbitMQ) 由 Worker 異步處理,避免阻塞回應
file_uploads = On upload_max_filesize = 10M post_max_size = 12M ; 必須 > upload_max_filesize max_file_uploads = 20 ; 單次請求最大檔案數 max_input_time = 60 ; 大檔案上傳需更長時間 memory_limit = 128M ; 圖片處理需足夠記憶體
嚴格遵循「不信任任何客戶端資料、最小權限儲存、分層驗證」原則,才能構建安全可靠的檔案上傳功能。