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

PHP 解壓上傳 ZIP 檔案完整指南:ZipArchive、系統指令與安全防護

     編輯:PHP 相關知識

方案比較

方案優點缺點適用場景
ZipArchive (PHP 擴展)純 PHP、跨平台、物件導向、支援加密需安裝擴展、大檔案記憶體壓力現代專案首選、共享主機支援度高
系統指令 (unzip/7z)效能極佳、支援所有格式、管道串流需 shell 執行權限、命令注入風險、跨平台差異大檔案、高併發、自建伺服器
PEAR Archive_Zip純 PHP、無擴展需求已棄用、效能差、功能有限極舊環境無法安裝擴展

方案一:ZipArchive 完整實作 (推薦)

<?php
function extractZip(string $zipPath, string $destDir, array $options = []): array {
    $defaults = [
        'overwrite' => false,
        'maxFiles' => 1000,
        'maxTotalSize' => 100 * 1024 * 1024, // 100MB
        'allowedExtensions' => null, // null = 全部允許
        'stripPaths' => false, // true = 解壓時忽略內部目錄結構
        'password' => null,
    ];
    $opts = array_merge($defaults, $options);

    if (!class_exists('ZipArchive')) {
        throw new RuntimeException('ZipArchive 擴展未安裝');
    }
    if (!is_file($zipPath) || !is_readable($zipPath)) {
        throw new InvalidArgumentException('ZIP 檔案不存在或不可讀');
    }
    if (!is_dir($destDir)) {
        if (!mkdir($destDir, 0755, true)) {
            throw new RuntimeException("無法建立目標目錄:$destDir");
        }
    }

    $zip = new ZipArchive();
    $flags = $opts['password'] ? ZipArchive::OVERWRITE : 0; // 密碼需開啟檔案才能設定
    $res = $zip->open($zipPath, $flags);
    if ($res !== true) {
        throw new RuntimeException("無法開啟 ZIP:錯誤代碼 $res");
    }

    if ($opts['password']) {
        if (!$zip->setPassword($opts['password'])) {
            $zip->close();
            throw new RuntimeException('ZIP 密碼錯誤');
        }
    }

    $count = $zip->count();
    if ($count > $opts['maxFiles']) {
        $zip->close();
        throw new RuntimeException("檔案數量超過限制:$count > {$opts['maxFiles']}");
    }

    $totalSize = 0;
    $extracted = [];
    $errors = [];

    for ($i = 0; $i < $count; $i++) {
        $stat = $zip->statIndex($i);
        if (!$stat) continue;

        $name = $stat['name'];
        $size = $stat['size'];

        // 安全:路徑遍歷檢查
        $destPath = $opts['stripPaths'] 
            ? $destDir . '/' . basename($name)
            : $destDir . '/' . $name;
        $destPath = realpath($destDir) . '/' . ltrim(str_replace(realpath($destDir), '', $destPath), '/');
        
        // 確保在目標目錄內
        if (strpos($destPath, realpath($destDir)) !== 0) {
            $errors[] = "跳過路徑遍歷嘗試:$name";
            continue;
        }

        // 副檔名白名單
        if ($opts['allowedExtensions']) {
            $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
            if (!in_array($ext, array_map('strtolower', $opts['allowedExtensions']))) {
                $errors[] = "跳過不允許副檔名:$name";
                continue;
            }
        }

        // 總大小限制
        $totalSize += $size;
        if ($totalSize > $opts['maxTotalSize']) {
            $errors[] = "超過總大小限制,停止解壓";
            break;
        }

        // 覆蓋檢查
        if (!$opts['overwrite'] && file_exists($destPath)) {
            $errors[] = "檔案已存在:$name";
            continue;
        }

        // 建立目錄
        $dir = dirname($destPath);
        if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
            $errors[] = "無法建立目錄:$dir";
            continue;
        }

        // 解壓
        if ($zip->extractTo($destDir, [$name]) === false) {
            $errors[] = "解壓失敗:$name";
            continue;
        }

        // 如果 stripPaths,需移動檔案
        if ($opts['stripPaths'] && $name !== basename($name)) {
            $originalPath = $destDir . '/' . $name;
            if (file_exists($originalPath)) {
                rename($originalPath, $destPath);
            }
        }

        $extracted[] = [
            'name' => $name,
            'path' => $destPath,
            'size' => $size,
        ];
    }

    $zip->close();
    return ['extracted' => $extracted, 'errors' => $errors];
}

// 使用範例
try {
    $result = extractZip(
        $_FILES['zipfile']['tmp_name'],
        __DIR__ . '/uploads/extracted/' . bin2hex(random_bytes(8)),
        [
            'allowedExtensions' => ['php', 'html', 'css', 'js', 'json', 'txt', 'md', 'png', 'jpg', 'jpeg', 'gif', 'svg'],
            'maxFiles' => 500,
            'maxTotalSize' => 50 * 1024 * 1024,
        ]
    );
    echo "成功解壓 " . count($result['extracted']) . " 個檔案\n";
    if ($result['errors']) {
        echo "警告:" . implode("\n", $result['errors']) . "\n";
    }
} catch (Throwable $e) {
    echo "錯誤:" . $e->getMessage() . "\n";
}
?>

方案二:系統指令 (高效能、大檔案)

<?php
function extractZipCli(string $zipPath, string $destDir, array $options = []): array {
    // 安全:嚴格驗證路徑,禁止任何用戶輸入進入命令
    $zipPath = realpath($zipPath);
    $destDir = realpath($destDir);
    if (!$zipPath || !$destDir) throw new InvalidArgumentException('路徑無效');

    // 白名單允許的解壓工具
    $tools = ['unzip', '7z', '7za', '7zr'];
    $tool = null;
    foreach ($tools as $t) {
        if (@is_executable("/usr/bin/$t") || @is_executable("/usr/local/bin/$t") || @is_executable("/bin/$t")) {
            $tool = $t;
            break;
        }
    }
    if (!$tool) throw new RuntimeException('系統未安裝 unzip 或 7z');

    // 建構命令 (僅使用固定參數,零用戶輸入)
    $cmd = [];
    switch ($tool) {
        case 'unzip':
            $cmd = ["$tool", "-q", "-o", $zipPath, "-d", $destDir];
            break;
        case '7z': case '7za': case '7zr':
            $cmd = ["$tool", "x", $zipPath, "-o$destDir", "-y"];
            break;
    }

    // 執行
    $descriptors = [
        0 => ['pipe', 'r'],
        1 => ['pipe', 'w'],
        2 => ['pipe', 'w'],
    ];
    $proc = proc_open($cmd, $descriptors, $pipes);
    if (!is_resource($proc)) throw new RuntimeException('無法啟動解壓程序');

    $stdout = stream_get_contents($pipes[1]);
    $stderr = stream_get_contents($pipes[2]);
    foreach ($pipes as $p) fclose($p);
    $returnCode = proc_close($proc);

    if ($returnCode !== 0) {
        throw new RuntimeException("解壓失敗 (code $returnCode):$stderr");
    }

    // 掃描結果
    $extracted = [];
    $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($destDir, FilesystemIterator::SKIP_DOTS));
    foreach ($iterator as $file) {
        if ($file->isFile()) {
            $extracted[] = [
                'path' => $file->getPathname(),
                'size' => $file->getSize(),
            ];
        }
    }
    return ['extracted' => $extracted, 'raw_output' => $stdout];
}
?>

上傳與解壓整合流程

<?php
// upload_and_extract.php
$uploadDir = __DIR__ . '/uploads/zips';
$extractBase = __DIR__ . '/uploads/extracted';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['zipfile'])) {
    $file = $_FILES['zipfile'];
    
    // 基礎驗證
    if ($file['error'] !== UPLOAD_ERR_OK) die('上傳錯誤');
    if ($file['size'] > 200 * 1024 * 1024) die('檔案過大 (限制 200MB)');
    
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mime = finfo_file($finfo, $file['tmp_name']);
    finfo_close($finfo);
    if (!in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'])) {
        die('非 ZIP 檔案');
    }

    // 安全儲存
    $zipName = bin2hex(random_bytes(16)) . '.zip';
    $zipPath = $uploadDir . '/' . $zipName;
    if (!move_uploaded_file($file['tmp_name'], $zipPath)) die('儲存失敗');

    // 建立獨立解壓目錄
    $extractDir = $extractBase . '/' . bin2hex(random_bytes(8));
    mkdir($extractDir, 0755, true);

    try {
        // 根據檔案大小選擇方案
        if ($file['size'] > 50 * 1024 * 1024 && function_exists('proc_open')) {
            $result = extractZipCli($zipPath, $extractDir);
        } else {
            $result = extractZip($zipPath, $extractDir, [
                'allowedExtensions' => ['php', 'html', 'css', 'js', 'json', 'txt', 'md', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'pdf'],
            ]);
        }
        
        echo json_encode(['success' => true, 'count' => count($result['extracted']), 'dir' => $extractDir]);
    } catch (Throwable $e) {
        // 失敗時清理
        @unlink($zipPath);
        // 遞迴刪除解壓目錄
        function rrmdir($d) { foreach (scandir($d) as $f) { if ($f!='.'&&$f!='..') { $p="$d/$f"; is_dir($p)?rrmdir($p):unlink($p); } } rmdir($d); }
        @rrmdir($extractDir);
        http_response_code(500);
        echo json_encode(['success' => false, 'error' => $e->getMessage()]);
    }
}
?>

安全防護關鍵清單

  1. 路徑遍歷防護:`realpath()` 驗證解壓後路徑在目標目錄內,`ZipArchive::extractTo` 仍需二次檢查。
  2. 副檔名/MIME 白名單:禁止解壓 `.php`、`.phtml`、`.phar`、`.exe`、`.sh` 等可執行檔。
  3. 檔案數量/總大小限制:防止 ZIP 炸彈 (Zip Bomb) 耗盡磁碟/Inode。
  4. 命令注入防護:CLI 方案絕對禁止用戶輸入進入命令,使用固定參數陣列 `proc_open`。
  5. 權限最小化:解壓目錄設為 `0755`、檔案 `0644`、禁用執行位、Nginx/Apache 設定 `php_admin_flag engine off`。
  6. 暫存檔清理:成功/失敗皆需刪除上傳的 ZIP 與解壓暫存目錄。
  7. 病毒掃描整合:ClamAV `clamscan` 掃描上傳 ZIP 與解壓後檔案。

總結

PHP 解壓 ZIP 首選 ZipArchive,安全可控、跨平台、功能完整。大檔案 (>50MB) 或高併發場景可評估 系統指令 (7z/unzip),但必須實施嚴格的命令注入防護與資源限制。核心安全原則:**白名單驗證、路徑遍歷檢查、資源配額限制、最小權限執行、完整清理機制**。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved