雲端 Mac 上的 iOS XML 解析安全驗收:外部實體、命名空間與資源上限

雲端 Mac 上的 iOS XML 解析安全驗收:外部實體、命名空間與資源上限

XML 匯入在本機讀得通,放進無人值守的流水線卻可能卡住:檔案含有外部實體宣告、元素巢狀層級過深,或同名欄位來自不同命名空間。雲端 Mac 的任務不是替這些輸入「盡量修好」,而是穩定拒絕超出界限的文件,並留下可重現失敗的樣本。

先訂定輸入契約,再呼叫解析器

以 iOS 的設定匯入功能為例,先約定:只接受 UTF-8,不允許 DTD;單份輸入最多 256 KiB,元素深度最多 16 層,所有文字回呼的累計資料量最多 32 KiB。這些是本文使用的範例配額,不是 XML 標準或系統預設值,應依業務需求調整。

格式錯誤、配額超限與業務欄位錯誤應分開記錄。不能只因解析器回報成功,就接受根元素不明、單值欄位重複或缺少必填欄位的文件。

關閉外部實體解析,不等於禁止 DTD,也不代表已具備完整的資源隔離。格式限制、解析器設定與程序層級的逾時機制,都需要分別驗收。

在雲端 Mac 跑通最小安全檢查

將下列程式碼儲存為 xml-guard.swift。它只檢查語法與資源界限,不建立業務模型。腳本採取保守策略:即使 DTD 宣告標記出現在註解中,也一律拒絕,避免將簡單的文字檢查誤稱為完整的 XML 詞法分析。

import Foundation

final class GuardDelegate: NSObject, XMLParserDelegate {
    var depth = 0
    var textBytes = 0
    var exceeded = false

    func stop(_ parser: XMLParser) {
        exceeded = true
        parser.abortParsing()
    }

    func parser(
        _ parser: XMLParser,
        didStartElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?,
        attributes attributeDict: [String: String] = [:]
    ) {
        depth += 1
        if depth > 16 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        didEndElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?
    ) {
        depth -= 1
    }

    func parser(_ parser: XMLParser, foundCharacters string: String) {
        textBytes += string.utf8.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(_ parser: XMLParser, foundCDATA data: Data) {
        textBytes += data.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        resolveExternalEntityName name: String,
        systemID: String?
    ) -> Data? {
        nil
    }
}

func validate(_ path: String) throws -> Bool {
    let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
    defer { try? handle.close() }

    var data = Data()
    while true {
        guard let chunk = try handle.read(upToCount: 8192),
              !chunk.isEmpty else { break }
        data.append(chunk)
        if data.count > 256 * 1024 { return false }
    }

    guard let source = String(data: data, encoding: .utf8),
          !source.contains("\0"),
          !source.contains("<!DOCTYPE") else {
        return false
    }

    let delegate = GuardDelegate()
    let parser = XMLParser(data: data)
    parser.delegate = delegate
    parser.shouldProcessNamespaces = true
    parser.shouldResolveExternalEntities = false
    parser.externalEntityResolvingPolicy = .never

    let parsed = parser.parse()
    return parsed && !delegate.exceeded && delegate.depth == 0
}

guard CommandLine.arguments.count == 2 else { exit(2) }
do {
    exit(try validate(CommandLine.arguments[1]) ? 0 : 1)
} catch {
    exit(2)
}

在已選定工具鏈的終端機中編譯,再執行一個合法樣本:

xcrun swiftc xml-guard.swift -o xml-guard
printf '%s\n' '<settings><name>demo</name></settings>' > valid.xml
./xml-guard valid.xml

結束碼約定如下:0 表示通過,1 表示輸入遭拒,2 表示呼叫或檔案讀取失敗。流水線應據此分類,不要解析隨系統語言而變的錯誤訊息。入口應接受由任務自行暫存的一般檔案,不能將任意裝置路徑當成輸入。

用固定樣本涵蓋拒絕路徑

至少保留六類檔案:合法文件、標籤未閉合、含有 DTD、17 層巢狀結構、文字超額,以及輸入位元組超額。以下會產生兩個邊界樣本;執行環境需提供 Python 3。

python3 - <<'PY'
from pathlib import Path

Path("deep.xml").write_text(
    "<n>" * 17 + "</n>" * 17, encoding="utf-8"
)
Path("text-limit.xml").write_text(
    "<n>" + "x" * (32 * 1024 + 1) + "</n>", encoding="utf-8"
)
PY
./xml-guard deep.xml
./xml-guard text-limit.xml

兩次都應回傳 1。另外準備一組樣本,分別恰好等於配額與只多一個位元組,避免比較運算子寫錯。外部實體樣本必須在解析前遭到拒絕;若要單獨驗證解析器的外部資源設定,應使用受控的測試目標,並以斷言確認未發生實際存取,不能只因結果為空就推斷安全。

命名空間與文字回呼要依結構處理

啟用命名空間後,應以「命名空間 URI + 區域名稱」識別業務欄位,不能只比較前綴,也不能只比較 elementName。前綴可以改變;不同 URI 下的 name 則可能代表不同概念。測試應同時涵蓋兩種情況:更換前綴仍可接受,更換 URI 則必須拒絕。

foundCharacters 不保證一次提供完整欄位。實際解碼器應維護元素堆疊,逐次附加文字,並在元素結束時檢查長度、格式與欄位是否重複。CDATA 使用獨立回呼,不能漏算。也不要一律移除所有欄位的頭尾空白:顯示名稱與需要保留空白的內文,可能有不同的契約。

不提交不完整的結果

先將所有記錄放進任務專用的暫存模型。只有語法、配額、根元素與業務檢查全部通過後,才執行一次提交。刻意在文件結尾加上一個錯誤標籤,確認前面已讀出的記錄仍不會寫入資料庫。

將驗收納入建置,不依賴單次手動測試

在 OSXBuild 雲端 Mac 上,先執行命令列樣本,再將相同檔案放入 iOS 測試目標,呼叫應用程式實際使用的解碼器。兩者共用同一份契約,但桌面腳本通過,不代表模擬器中的實作已完成驗收。

回歸測試記錄只需保留樣本名稱、預期結束碼、實際結果與工具鏈版本,不要輸出完整的匯入內容。使用流水線逾時機制限制異常任務;回呼配額只能約束已交付的資料量,並不是解析器內部記憶體用量的絕對上限。

最後逐項確認:合法檔案能匯入、越界檔案穩定失敗、命名空間不會造成欄位混淆,且失敗時不留下半成品。如此一來,下次修改解析邏輯時,驗收依據仍是可重現的輸入與明確的斷言,而不是「這次沒有卡住」。

常見問題

停用外部實體解析後,還要拒絕 DTD 嗎?

兩者必須分開處理。停用外部實體解析並不代表拒絕內部 DTD。本文將輸入限定為不含 DTD 的 UTF-8 XML,在交給解析器前先做保守拒絕。

foundCharacters 每次都會回傳完整欄位嗎?

不會。同一欄位的文字可能分成多次回呼。應累積到元素結束才驗證,而且必須等整份文件與業務檢查通過,才提交結果。

為建置流程配備專屬雲端 Mac

獨享整台實體機,非虛擬機。依建置量選擇機型與租用期間,讓封存、簽署與快取都留在自己的節點上。

選擇雲端 Mac 方案

下一次封存,就在專屬雲端 Mac 上完成

依建置量選擇主機,依團隊所在地選擇節點。先確認方案,再開始移轉工具鏈。