云端 Mac 上的 iOS ZIP 解压安全验收:路径与配额

云端 Mac 上的 iOS ZIP 解压安全验收:路径与配额

用户导入一个 ZIP,应用提示成功,却把文件写到了导入目录之外。问题通常不在压缩算法,而在条目名被直接拼接成输出路径。把这条链路搬到云端 Mac 后,先测试路径规则,再接入项目的解压器;不要拿真实用户资料做恶意样本,也不要让测试写进源码目录。

先定义导入边界

本次只接受普通文件和目录,拒绝符号链接及未知类型。每次导入创建独立暂存目录,完整验收后才移动到业务目录;任一条目失败,就删除整份暂存数据,不保留“已经成功的部分”。

设定三项工程配额:最多一千个条目、单文件最多二十兆字节、总解压量最多一百兆字节。这些是示例策略,不是系统限制,团队应按业务调整。预检使用目录声明值,实际写入再累计真实字节数,两道检查不能互相替代。

沙盒不是解压器的安全边界。即使无法写出应用容器,恶意条目仍可能覆盖容器内的数据库或配置。

写一个可执行的路径预检

保存下面代码为 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 等价名称需要额外策略。若导入资料不要求区分这些名称,可以将组件做规范组合映射与大小写折叠后用于冲突检测,但实际名称保持原样。不要依赖云端 Mac 当前卷的大小写行为推断 iOS 写入结果;至少准备大小写碰撞和组合字符两组样本,在目标模拟器内复验。

用真实写入验证配额

适配层必须拿到写入之前的解压数据块,先判断累计大小,再写入文件。用“剩余额度是否足够”比较,避免直接相加造成整数溢出。若库只能整块解压到内存,无法中途限制,应更换接口或对输入另做严格约束。

为每份测试压缩包固定预期结果:正常目录通过,越界路径拒绝,重复目标拒绝,超过任一配额拒绝。再加入损坏压缩数据,确认读取错误不会被当成正常结束。有效包也要检查库报告的完整性校验结果,不能只看最终文件是否存在。

写入失败后,断言暂存目录已删除、业务目录未变化、导入目录旁预先放置的哨兵文件内容未变化。这比仅断言函数抛错更有价值:错误可能发生在越界文件已经写出之后。

接入回归门禁并保留边界

在流水线先运行路径脚本,再运行调用真实解压器的应用测试。记录样本编号、拒绝原因和实际写入字节数,不记录用户文件内容。测试目录随机创建,结束清理;禁止把系统临时目录本身作为解压根目录。

预检和写入之间仍可能发生目录替换。使用应用私有、其他任务无法改写的暂存目录,并在写入阶段拒绝跟随链接;共享桌面脚本若允许其他进程修改目录,字符串前缀检查并不能消除这个风险。

最后确认三件事:失败不会留下半份导入数据,成功只产生预期文件,配额由实际字节数执行。路径函数是第一道门,真实压缩包的写入断言才是验收终点。

常见问题

拒绝包含两个点的文件名就够了吗?

不够。应按路径组件拒绝独立的 ..,同时拒绝绝对路径和不接受的分隔符;还要检查符号链接、重复目标,以及实际写入时的目录边界。report..txt 这样的普通文件名不应被误判。

能用 ZIP 目录里声明的大小判断解压炸弹吗?

不能只信声明值。预检可以按声明大小拒绝明显超额的包,但写入时还必须累计实际解压字节数,并设置单条目、总字节数和条目数量上限;任何上限触发都应中止并清理暂存目录。

把构建队列交给独享云端 Mac

选择物理节点、机型与计费周期,让 Xcode 构建和自动化任务在自己的 macOS 环境中运行。实际可用状态以控制台实时返回为准。

继续核对你的构建环境

对照机型规格与操作说明,确定下一步配置。