首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 多檔案上傳類完整實作:型別驗證、安全過濾與錯誤處理

     編輯:PHP 相關知識

PHP 多檔案上傳類完整實作

檔案上傳是 Web 開發高風險功能,需嚴格驗證檔案型別、大小、內容、命名,防止惡意檔案上傳導致伺服器被植入 Webshell。本文提供企業級安全標準的多檔案上傳類實作。

安全上傳類核心功能

  • MIME 型別白名單 + 檔案頭魔數雙重驗證
  • 檔案大小限制 (單檔/總計)
  • 隨機化檔名防止覆蓋與路徑遍歷
  • 上傳目錄權限隔離、禁止腳本執行
  • 完整錯誤代碼與訊息對應
  • 支援單檔/多檔、陣列式表單欄位

完整類別實作

<?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/Apache 安全配置建議


# 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 

安全檢查清單

  • ☐ 關閉上傳目錄的腳本執行權限
  • ☐ 使用隨機檔名,不保留使用者原始檔名
  • ☐ 雙重驗證:MIME + 魔數檢查
  • ☐ 限制單檔/總大小/檔案數
  • ☐ 上傳目錄在 Web 根目錄外,或配置無腳本執行權限
  • ☐ 病毒掃描整合 (ClamAV 等)
  • ☐ 審計日誌記錄:上傳者、時間、檔案資訊、結果
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved