檔案上傳是 Web 開發高風險功能,需嚴格驗證檔案型別、大小、內容、命名,防止惡意檔案上傳導致伺服器被植入 Webshell。本文提供企業級安全標準的多檔案上傳類實作。
<?php
/**
* 安全多檔案上傳處理類
* 符合 OWASP 檔案上傳安全建議
*/
class SecureFileUpload {
// 錯誤代碼常數
const ERR_NONE = 0;
const ERR_INI_SIZE = 1; // 超過 php.ini upload_max_filesize
const ERR_FORM_SIZE = 2; // 超過表單 MAX_FILE_SIZE
const ERR_PARTIAL = 3; // 部分上傳
const ERR_NO_FILE = 4; // 無檔案上傳
const ERR_NO_TMP_DIR = 6; // 缺少暫存目錄
const ERR_CANT_WRITE = 7; // 寫入失敗
const ERR_EXTENSION = 8; // PHP 擴展阻擋
// 自訂錯誤代碼
const ERR_TYPE_MISMATCH = 100; // MIME 型別不符
const ERR_MAGIC_MISMATCH = 101; // 檔案頭魔數不符
const ERR_SIZE_EXCEED = 102; // 檔案過大
const ERR_TOTAL_SIZE_EXCEED = 103;// 總大小超限
const ERR_MOVE_FAILED = 104; // 移動失敗
const ERR_INVALID_NAME = 105; // 檔名非法
private $config = [
'upload_dir' => '', // 上傳目錄絕對路徑
'allowed_types' => [], // 允許的 MIME 型別
'magic_signatures' => [], // 檔案頭魔數對應
'max_file_size' => 5 * 1024 * 1024, // 5MB 單檔限制
'max_total_size' => 20 * 1024 * 1024, // 20MB 總計限制
'max_files' => 10, // 最大檔案數
'rename' => true, // 是否重新命名
'create_subdir' => true, // 按日期建立子目錄
];
private $results = [];
private $totalSize = 0;
public function __construct(array $config = []) {
$this->config = array_merge($this->config, $config);
// 預設允許的圖片型別與魔數
if (empty($this->config['allowed_types'])) {
$this->config['allowed_types'] = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
];
}
if (empty($this->config['magic_signatures'])) {
$this->config['magic_signatures'] = [
'image/jpeg' => [0xFF, 0xD8, 0xFF],
'image/png' => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
'image/gif' => [0x47, 0x49, 0x46, 0x38], // GIF87a/89a
'image/webp' => [0x52, 0x49, 0x46, 0x46], // RIFF
];
}
}
/**
* 處理上傳 (支援單檔/多檔)
* @param string $fieldName 表單欄位名
* @return array 上傳結果陣列
*/
public function handleUpload($fieldName) {
$this->results = [];
$this->totalSize = 0;
if (!isset($_FILES[$fieldName])) {
return $this->addResult('', self::ERR_NO_FILE, '表單欄位不存在');
}
$files = $this->normalizeFilesArray($_FILES[$fieldName]);
if (count($files) > $this->config['max_files']) {
return $this->addResult('', self::ERR_INVALID_NAME, "超過最大檔案數限制 ({$this->config['max_files']})");
}
// 預檢總大小
foreach ($files as $file) {
$this->totalSize += $file['size'];
}
if ($this->totalSize > $this->config['max_total_size']) {
return $this->addResult('', self::ERR_TOTAL_SIZE_EXCEED, "總檔案大小超過限制");
}
// 逐檔處理
foreach ($files as $file) {
$this->processSingleFile($file);
}
return $this->results;
}
private function normalizeFilesArray($files) {
// 統一格式:多檔上傳時 $_FILES 結構為巢狀陣列
if (isset($files['name']) && is_array($files['name'])) {
$normalized = [];
foreach ($files['name'] as $idx => $name) {
$normalized[] = [
'name' => $name,
'type' => $files['type'][$idx],
'tmp_name' => $files['tmp_name'][$idx],
'error' => $files['error'][$idx],
'size' => $files['size'][$idx],
];
}
return $normalized;
}
return [$files]; // 單檔
}
private function processSingleFile($file) {
$name = $file['name'];
// 1. 檢查 PHP 上傳錯誤代碼
if ($file['error'] !== UPLOAD_ERR_OK) {
$this->addResult($name, $file['error'], $this->getErrorMessage($file['error']));
return;
}
// 2. 檢查檔案大小
if ($file['size'] > $this->config['max_file_size']) {
$this->addResult($name, self::ERR_SIZE_EXCEED, "檔案大小超過限制 ({$this->formatSize($this->config['max_file_size'])})");
return;
}
// 3. 驗證 MIME 型別 (瀏覽器提供,不可信,需二次驗證)
$mimeType = $this->detectMimeType($file['tmp_name']);
if (!$mimeType || !in_array($mimeType, $this->config['allowed_types'], true)) {
$this->addResult($name, self::ERR_TYPE_MISMATCH, "檔案型別不允許: {$file['type']} (偵測為: {$mimeType})");
return;
}
// 4. 驗證檔案頭魔數 (核心安全檢查)
if (!$this->verifyMagicBytes($file['tmp_name'], $mimeType)) {
$this->addResult($name, self::ERR_MAGIC_MISMATCH, "檔案內容與型別不符,疑似偽裝檔案");
return;
}
// 5. 建立目標目錄
$targetDir = $this->getTargetDirectory();
if (!$targetDir) {
$this->addResult($name, self::ERR_CANT_WRITE, "無法建立上傳目錄");
return;
}
// 6. 產生安全檔名
$newName = $this->generateSafeFilename($name, $mimeType);
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newName;
// 7. 移動檔案
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
$this->addResult($name, self::ERR_MOVE_FAILED, "檔案移動失敗 (權限或磁碟空間不足)");
return;
}
// 8. 設定檔案權限 (僅限擁有者讀寫)
@chmod($targetPath, 0644);
// 成功
$this->addResult($name, self::ERR_NONE, '上傳成功', [
'original_name' => $name,
'saved_name' => $newName,
'path' => $targetPath,
'size' => $file['size'],
'mime' => $mimeType,
'url' => $this->getPublicUrl($newName),
]);
}
private function detectMimeType($tmpPath) {
// 使用 finfo (Fileinfo 擴展) 偵測真實 MIME
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmpPath);
finfo_close($finfo);
return $mime;
}
// 降級:使用 mime_content_type
if (function_exists('mime_content_type')) {
return mime_content_type($tmpPath);
}
return null;
}
private function verifyMagicBytes($tmpPath, $mimeType) {
$signatures = $this->config['magic_signatures'][$mimeType] ?? null;
if (!$signatures) return true; // 無定義則跳過
$handle = fopen($tmpPath, 'rb');
if (!$handle) return false;
$header = fread($handle, max(12, count($signatures)));
fclose($handle);
foreach ($signatures as $offset => $byte) {
if (!isset($header[$offset]) || ord($header[$offset]) !== $byte) {
return false;
}
}
return true;
}
private function getTargetDirectory() {
$baseDir = $this->config['upload_dir'] ?: sys_get_temp_dir() . '/uploads';
if ($this->config['create_subdir']) {
$subDir = date('Y/m/d');
$targetDir = $baseDir . DIRECTORY_SEPARATOR . $subDir;
} else {
$targetDir = $baseDir;
}
if (!is_dir($targetDir)) {
if (!mkdir($targetDir, 0755, true)) {
return false;
}
// 建立 .htaccess 禁止腳本執行 (Apache)
$htaccess = $targetDir . '/.htaccess';
if (!file_exists($htaccess)) {
file_put_contents($htaccess, "SetHandler default-handler\nOptions -ExecCGI\nphp_flag engine off\n");
}
// 建立 web.config (IIS)
$webConfig = $targetDir . '/web.config';
if (!file_exists($webConfig)) {
file_put_contents($webConfig, ' ');
}
}
return $targetDir;
}
private function generateSafeFilename($originalName, $mimeType) {
$ext = $this->getExtensionFromMime($mimeType);
$random = bin2hex(random_bytes(16)); // 32 字元十六進位
return $random . '.' . $ext;
}
private function getExtensionFromMime($mime) {
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
return $map[$mime] ?? 'bin';
}
private function getPublicUrl($filename) {
// 根據實際部署調整
return '/uploads/' . date('Y/m/d') . '/' . $filename;
}
private function formatSize($bytes) {
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
private function getErrorMessage($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 addResult($name, $code, $message, $data = []) {
$this->results[] = array_merge([
'field' => $name,
'code' => $code,
'success' => ($code === self::ERR_NONE),
'message' => $message,
], $data);
return $this->results;
}
}
// 使用範例
$uploader = new SecureFileUpload([
'upload_dir' => __DIR__ . '/../uploads',
'max_file_size' => 10 * 1024 * 1024, // 10MB
'allowed_types' => ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'],
'magic_signatures' => [
'image/jpeg' => [0xFF, 0xD8, 0xFF],
'image/png' => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
'application/pdf' => [0x25, 0x50, 0x44, 0x46], // %PDF
],
]);
$results = $uploader->handleUpload('files'); // 表單
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'files' => $results,
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
?>
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="10485760"> <!-- 10MB -->
<input type="file" name="files[]" multiple accept="image/*,.pdf">
<button type="submit">上傳</button>
</form>
<script>
// AJAX 上傳範例
document.querySelector('form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const res = await fetch('upload.php', { method: 'POST', body: formData });
const data = await res.json();
console.log(data);
});
</script>
# Nginx: 禁止上傳目錄執行 PHP
location ~ ^/uploads/.*\.php$ {
deny all;
return 403;
}
# 限制上傳大小 (需與 PHP 設定一致)
client_max_body_size 20M;
# Apache .htaccess (放在上傳目錄)
# SetHandler default-handler
# Options -ExecCGI
# php_flag engine off
# Order Allow,Deny Deny from all