用户导入一个 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 目录里声明的大小判断解压炸弹吗?
不能只信声明值。预检可以按声明大小拒绝明显超额的包,但写入时还必须累计实际解压字节数,并设置单条目、总字节数和条目数量上限;任何上限触发都应中止并清理暂存目录。