檔案上傳是 Web 開發高風險功能,需嚴格驗證檔案類型、大小、內容、儲存路徑。本文提供一個結構化的多檔案上傳類別,並詳細說明安全防護要點。
<?php
/**
* 多檔案上傳處理類
* 功能:類型驗證、真實格式檢測、隨機重命名、大小限制、錯誤處理
*/
class MultiFileUpload {
// ========== 配置常數 ==========
const UPLOAD_DIR = __DIR__ . '/../uploads/'; // 儲存目錄(需可寫入)
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB 單檔上限
const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB 總上限
const ALLOWED_MIME = [ // 允許的 MIME 類型
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain',
];
const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'txt']; // 允許副檔名
const MAGIC_BYTES = [ // 檔案簽名(Magic Bytes)驗證
'jpg' => [0xFF, 0xD8, 0xFF],
'png' => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
'gif' => [0x47, 0x49, 0x46, 0x38],
'pdf' => [0x25, 0x50, 0x44, 0x46], // %PDF
];
// ========== 狀態屬性 ==========
private $files = []; // $_FILES 結構化資料
private $results = []; // 處理結果
private $totalSize = 0; // 總大小統計
private $errors = []; // 錯誤收集
// ========== 公開介面 ==========
public function __construct() {
// 確保上傳目錄存在
if (!is_dir(self::UPLOAD_DIR)) {
mkdir(self::UPLOAD_DIR, 0755, true);
}
}
/**
* 處理上傳
* @param string $fieldName 表單檔案欄位名(預設 'files')
* @return array 處理結果陣列
*/
public function handleUpload($fieldName = 'files') {
$this->normalizeFiles($fieldName);
$this->validateGlobalConstraints();
foreach ($this->files as $file) {
$this->processSingleFile($file);
}
return $this->results;
}
// ========== 內部處理邏輯 ==========
private function normalizeFiles($fieldName) {
$rawFiles = $_FILES[$fieldName] ?? [];
if (empty($rawFiles['name'])) return;
// 處理多檔案陣列結構(name、type、tmp_name、error、size 為平行陣列)
$count = count($rawFiles['name']);
for ($i = 0; $i < $count; $i++) {
if ($rawFiles['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
$this->files[] = [
'name' => $rawFiles['name'][$i],
'type' => $rawFiles['type'][$i],
'tmp_name' => $rawFiles['tmp_name'][$i],
'error' => $rawFiles['error'][$i],
'size' => $rawFiles['size'][$i],
'ext' => strtolower(pathinfo($rawFiles['name'][$i], PATHINFO_EXTENSION)),
];
}
}
private function validateGlobalConstraints() {
$this->totalSize = array_sum(array_column($this->files, 'size'));
if ($this->totalSize > self::MAX_TOTAL_SIZE) {
throw new Exception("總上傳大小超過限制 (" . $this->formatBytes(self::MAX_TOTAL_SIZE) . ")");
}
}
private function processSingleFile($file) {
$result = [
'original_name' => $file['name'],
'size' => $file['size'],
'size_formatted' => $this->formatBytes($file['size']),
'success' => false,
'message' => '',
'saved_path' => '',
'saved_name' => '',
];
// 1. PHP 上傳錯誤碼檢查
if ($file['error'] !== UPLOAD_ERR_OK) {
$result['message'] = $this->getUploadErrorMessage($file['error']);
$this->results[] = $result;
return;
}
// 2. 檔案大小檢查
if ($file['size'] > self::MAX_FILE_SIZE) {
$result['message'] = "檔案過大,上限 " . $this->formatBytes(self::MAX_FILE_SIZE);
$this->results[] = $result;
return;
}
// 3. 副檔名檢查
if (!in_array($file['ext'], self::ALLOWED_EXT)) {
$result['message'] = "不允許的副檔名: {$file['ext']}";
$this->results[] = $result;
return;
}
// 4. MIME 類型檢查(瀏覽器提供,不可全信,但作為第一道防線)
if (!in_array($file['type'], self::ALLOWED_MIME)) {
$result['message'] = "不允許的 MIME 類型: {$file['type']}";
$this->results[] = $result;
return;
}
// 5. 真實檔案格式驗證(Magic Bytes / 檔案簽名)
if (!$this->verifyFileSignature($file['tmp_name'], $file['ext'])) {
$result['message'] = "檔案內容與副檔名不符,可能偽裝檔案";
$this->results[] = $result;
return;
}
// 6. 圖片額外驗證(使用 getimagesize 確認真實圖片)
if (in_array($file['ext'], ['jpg','jpeg','png','gif','webp'])) {
$imgInfo = @getimagesize($file['tmp_name']);
if (!$imgInfo) {
$result['message'] = "無效的圖片檔案";
$this->results[] = $result;
return;
}
// 可選:限制圖片尺寸
// if ($imgInfo[0] > 5000 || $imgInfo[1] > 5000) { ... }
}
// 7. 生成安全檔名(隨機 + 保留副檔名)
$newName = bin2hex(random_bytes(16)) . '.' . $file['ext'];
$destPath = self::UPLOAD_DIR . $newName;
// 8. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
$result['message'] = "檔案儲存失敗(權限或磁碟空間問題)";
$this->results[] = $result;
return;
}
// 9. 設定權限(僅擁有者可讀寫)
@chmod($destPath, 0644);
// 成功
$result['success'] = true;
$result['message'] = '上傳成功';
$result['saved_path'] = $destPath;
$result['saved_name'] = $newName;
$this->results[] = $result;
}
// ========== 工具方法 ==========
private function verifyFileSignature($tmpPath, $ext) {
$signatures = self::MAGIC_BYTES[$ext] ?? null;
if (!$signatures) return true; // 無簽名定義則跳過
$handle = fopen($tmpPath, 'rb');
$header = fread($handle, max(8, count($signatures)));
fclose($handle);
foreach ($signatures as $offset => $byte) {
if (ord($header[$offset]) !== $byte) {
return false;
}
}
return true;
}
private function getUploadErrorMessage($code) {
$messages = [
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 擴充功能阻止',
];
return $messages[$code] ?? "未知上傳錯誤代碼: {$code}";
}
private function formatBytes($bytes, $precision = 2) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$value = $bytes / (1024 ** $pow);
return round($value, $precision) . ' ' . $units[$pow];
}
}
?>
<!-- HTML 表單(注意 enctype)-->
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880"> <!-- 5MB,瀏覽器端提示用 -->
<input type="file" name="files[]" multiple accept="image/*,.pdf,.txt">
<button type="submit">上傳</button>
</form>
<?php
// upload.php
require 'MultiFileUpload.php';
$uploader = new MultiFileUpload();
try {
$results = $uploader->handleUpload('files');
// 輸出 JSON 供前端處理
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'data' => $results,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
]);
}
?>
| 防護層級 | 措施 | 說明 |
|---|---|---|
| 1. 客戶端 | accept、MAX_FILE_SIZE | 僅用戶體驗提示,不可信任 |
| 2. 伺服器端 - 基礎 | $_FILES['error']、size、副檔名、MIME | 基本過濾,易繞過 |
| 3. 伺服器端 - 核心 | Magic Bytes 檔案簽名驗證 | 確認檔案真實格式,防偽裝 |
| 4. 伺服器端 - 進階 | getimagesize()、解析器驗證(PDF 解析等) | 深度內容驗證 |
| 5. 儲存安全 | 隨機檔名、存放 Web 根目錄外、權限 0644、禁止執行 | 防止直接存取、腳本執行 |
| 6. 傳輸安全 | HTTPS、CSRF Token、驗證登入狀態 | 防竄改、防偽造請求 |
; php.ini file_uploads = On upload_max_filesize = 10M post_max_size = 20M max_file_uploads = 20 max_execution_time = 60 memory_limit = 128M ; upload_tmp_dir = /tmp/secure_uploads ; 建議指定專用暫存目錄
# Nginx 限制上傳大小
client_max_body_size 20M;
# 上傳目錄禁止腳本執行
location /uploads/ {
location ~ \.(php|php5|phtml|phar)$ {
deny all;
return 404;
}
}
檔案上傳看似簡單,實則是安全攻防前線。建立分層驗證、最小權限儲存、完整審計日誌的上傳體系,是每個後端工程師的必修課。