雲端 Mac 上的 iOS ZIP 解壓縮驗證:路徑與配額
使用者匯入一個 ZIP,應用程式顯示成功,檔案卻被寫到匯入目錄之外。問題通常不在壓縮演算法,而是程式直接將項目名稱串接成輸出路徑。把這套流程移到雲端 Mac 後,應先測試路徑規則,再接上專案使用的解壓縮程式庫;不要拿真實使用者資料製作惡意測試樣本,也不要讓測試寫入原始碼目錄。
先定義匯入邊界
這次只接受一般檔案與目錄,拒絕符號連結及未知類型。每次匯入都建立獨立的暫存目錄,通過完整驗收後才移至業務資料目錄;只要任一項目失敗,就刪除整份暫存資料,不保留「已經成功的部分」。
設定三項工程配額:最多一千個項目、單一檔案最多二十 MB、解壓縮後的總資料量最多一百 MB。這些只是範例策略,不是系統限制,團隊應依業務需求調整。預檢使用 ZIP 目錄資訊中的宣告值,實際寫入時再累計真實位元組數,兩道檢查不能互相取代。
沙盒不是解壓縮程式的安全邊界。即使無法寫入應用程式容器之外,惡意項目仍可能覆寫容器內的資料庫或設定。
撰寫可執行的路徑預檢
將下列程式碼儲存為 zip-path-check.swift,並在雲端 Mac 執行 xcrun swift zip-path-check.swift。它只驗證項目名稱,不負責讀取 ZIP。專案必須將解壓縮程式庫提供的項目名稱與類型交由同一套策略檢查,不能因為指令執行成功,就認定整個匯入流程已經安全。
import Foundation
enum PathError: Error {
case invalid
}
func safeComponents(_ name: String) throws -> [String] {
guard !name.isEmpty,
!name.hasPrefix("/"),
!name.contains("\\"),
!name.contains(":"),
!name.unicodeScalars.contains(where: {
CharacterSet.controlCharacters.contains($0)
}) else {
throw PathError.invalid
}
let body = name.hasSuffix("/") ? String(name.dropLast()) : name
let parts = body.split(
separator: "/",
omittingEmptySubsequences: false
).map(String.init)
guard !parts.isEmpty,
parts.allSatisfy({
!$0.isEmpty && $0 != "." && $0 != ".."
}) else {
throw PathError.invalid
}
return parts
}
let cases: [(String, Bool)] = [
("docs/readme.txt", true),
("docs/", true),
("report..txt", true),
("../outside.txt", false),
("docs/../../outside.txt", false),
("/tmp/outside.txt", false),
("docs//readme.txt", false),
("docs/./readme.txt", false),
("docs\\..\\outside.txt", false),
("C:/outside.txt", false),
("docs/\u{0000}bad.txt", false)
]
for (name, expected) in cases {
let accepted = (try? safeComponents(name)) != nil
precondition(accepted == expected, "Path policy mismatch")
}
print("Path checks passed")
這是一套刻意收緊的匯入規則:只接受以斜線分隔的相對路徑,目錄允許保留一個結尾斜線。冒號與控制字元也一律拒絕,以免不同檔案系統產生不同解讀。若業務確實需要這些字元,應新增明確的測試案例,而不是直接放寬所有檢查。
檢查連結與重複目標
路徑通過檢查後,仍須確認項目類型。不要先解壓縮出連結,再沿著連結寫入下一個項目,也不要靠副檔名判斷類型。應讀取解壓縮程式庫提供的類型與屬性;無法可靠辨識的類型,直接拒絕。
依路徑組成部分,為每個目標路徑建立索引。除了完全重複的路徑,也要攔截「先寫入檔案 docs,再寫入 docs/a.txt」這類父子路徑衝突。項目順序相反時也必須失敗,否則執行結果會隨壓縮檔內的排序改變。
大小寫與 Unicode 等價名稱需要額外的策略。若匯入資料不需要區分這些名稱,可以將各路徑組成部分做 Unicode 正規組合與大小寫折疊,再用於衝突偵測,但實際名稱仍保留原樣。不要依賴雲端 Mac 目前磁碟區的大小寫處理方式,推斷 iOS 的寫入結果;至少準備大小寫衝突與組合字元兩組測試案例,並在目標模擬器中再次驗證。
用實際寫入驗證配額
介接層必須在寫入前取得解壓縮後的資料區塊,先檢查累計大小,再寫入檔案。比較時應判斷「剩餘配額是否足夠」,避免直接相加造成整數溢位。若程式庫只能將資料一次全部解壓縮到記憶體,無法在過程中限制用量,就應更換介面,或另外對輸入施加嚴格限制。
為每份測試壓縮檔固定預期結果:正常目錄通過、越界路徑拒絕、重複目標拒絕、超過任一配額拒絕。再加入損毀的壓縮資料,確認讀取錯誤不會被當成正常結束。有效壓縮檔也要檢查程式庫回報的完整性驗證結果,不能只看最終檔案是否存在。
寫入失敗後,應以斷言確認暫存目錄已刪除、業務資料目錄未改變,以及事先放在匯入目錄旁的哨兵檔案內容未改變。這比只確認函式拋出錯誤更有價值:錯誤可能是在越界檔案已經寫出之後才發生。
納入回歸測試關卡並維持邊界
在流水線中先執行路徑檢查腳本,再執行呼叫實際解壓縮程式庫的應用程式測試。記錄樣本編號、拒絕原因與實際寫入的位元組數,不記錄使用者檔案內容。測試目錄以隨機名稱建立,結束後清除;禁止將系統暫存目錄本身當作解壓縮根目錄。
預檢與寫入之間仍可能發生目錄遭替換的情況。應使用應用程式私有、其他工作無法修改的暫存目錄,並在寫入階段拒絕沿著連結存取目標;若共用的桌面腳本允許其他程序修改目錄,僅靠字串前綴檢查並不能消除這項風險。
最後確認三件事:失敗不會留下不完整的匯入資料,成功只會產生預期檔案,配額依實際位元組數執行。路徑函式只是第一道關卡,針對真實壓縮檔執行寫入斷言,才是驗收的終點。
常見問題
拒絕含有兩個點的檔名就足夠嗎?
不夠。應依路徑元件拒絕獨立的 ..,同時拒絕絕對路徑與不接受的分隔符號;還要檢查符號連結、重複目標及寫入時的目錄邊界。report..txt 這類一般檔名不應被誤判。
可以只靠 ZIP 目錄宣告的大小防止解壓縮炸彈嗎?
不可以。宣告大小可用於預檢,但寫入時仍須累計實際解壓縮位元組數,並限制單一項目、總容量與項目數。任何上限觸發都應中止並清除暫存目錄。
將建置佇列交給專屬雲端 Mac
選擇實體節點、機型與計費週期,讓 Xcode 建置和自動化工作在專屬的 macOS 環境中執行。實際可用狀態請以控制台即時回報為準。