云端 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 上出炉

按构建量选机器,按团队位置选节点。先核对方案,再开始交接工具链。