ZIP资料包接收实战:限额解包与失败清理

接收一份ZIP资料包时,任务不只是把文件展开。资料里可能有重复文件名、越过目标目录的路径,或者在第二个文件读取时才出现CRC错误。如果直接写入已有目录,“解包失败”之后仍可能留下半份结果,后续脚本却把它当成完整资料继续处理。

本文给需要编写资料接收脚本的开发者提供一个完整本地实验:只接收小型文本资料,在自己控制的父目录内创建全新的输出目录,先检查目录清单,再按实际读取字节限制写入,全部完成后才返回结果路径和校验清单。示例没有Web接口,也不会运行解包得到的文件。

下载ZIP资料接收实验包:完整解包器、对照运行器与30项测试(ZIP,约9KB)

先运行四个小包,看失败后留下什么

源码包使用Python标准库,需要Python 3.11或更新版本;本次实际验证环境为macOS、Python 3.14.4。解压源码包,进入zip-intake-lab-v1目录后运行:

python3 run_lab.py
python3 -m unittest -v

运行器在临时目录中生成资料包和一份内容为keep的已有文件,结束后自动清理这些实验材料。故障样例由程序生成,不需要寻找或下载真正的恶意压缩包。本次输出如下:

输入样例 实际结果 留下的输出目录 已有文件
中文说明与CSV 接收2文件,共21字节 1个 不变
../outside.txt 路径规则拒绝 0个 不变
高度重复的文本 压缩比规则拒绝 0个 不变
第二个文件CRC损坏 BadZipFile 0个 不变

最后一行尤其重要:清单检查通过,并不代表读取内容一定成功。实验先写入一个正常文件,再读取被改坏的第二个文件;错误发生后,本轮新目录被清除。没有把已经写完的第一项单独交给调用者,也没有修改父目录中的旧文件。

把“允许接收什么”变成明确规则

这个例子只接收.txt、.csv、.json和.md,只支持存储或DEFLATE压缩。不接受程序、图片、PDF、加密条目和嵌套ZIP,也不递归展开。这样做是为了缩小一个文本资料任务的范围,不是声称这些扩展名可以证明内容无害。

检查项 本例上限 检查时机
输入ZIP大小 2MiB 打开ZIP目录前
条目数量 100 目录读取后、写文件前
单文件内容 1MiB 清单检查及流式写入
全部文件内容 4MiB 清单合计及实际累计
压缩比 100 清单检查
路径 最多8段、200字符 写文件前

这些数值是这个实验的接收策略,不是ZIP格式的安全常数。例如,正常的重复日志也可能超过压缩比上限。放宽上限应来自业务样本和资源预算,不能只因为某个包被拒绝就删掉所有限制。清单文件另占少量空间,不计入表中的4MiB内容预算。

拒绝有歧义的名字,比改名后继续更容易核对

条目名字要逐段检查:绝对路径、..、反斜线、盘符、空路径段、末尾空格或点、保留设备名和NUL等情况直接拒绝。名称要求使用NFC形式;完整路径按大小写折叠后去重,所以A.txt与a.txt不能同时进入结果。

重复名字并非只会覆盖一份文件。folder.txt与folder.txt/a.txt还会让同一个位置既被当作文件又被当作目录。实验在写入前检查这种前缀关系,并拒绝符号链接与特殊文件条目。每个文件以xb独占创建,不恢复压缩包里携带的执行权限。

这里保留中文文件名,正常样例中的资料/说明.txt解出后仍含“你好”,按UTF-8占6字节。名称规则只是一个保守的跨文件系统配置,没有在Windows上实测,也不能代替所有平台的命名规则。Python标准解压函数本身有部分路径规范化行为;本实验的价值是补上明确的业务规则和失败处理,并不是证明标准函数必然存在路径漏洞。

预算要检查实际读取量,而不只看清单数字

清单上的展开大小适合提前拒绝明显超限的包,真正写入时仍应累计读到的内容。核心函数按16KiB读取,在写入当前块之前检查是否超出剩余预算:

count = 0
while chunk := source.read(16 * 1024):
    count += len(chunk)
    if count > budget:
        raise Rejected('actual bytes exceed budget')
    target.write(chunk)

主流程给每个文件传入“单文件剩余额度”和“整个包剩余额度”的较小值,读到结尾后再核对实际字节数,并记录SHA-256。一个独立测试让输入流提供20000字节、预算为17000字节:第二块被拒绝,目标只写入了第一块的16384字节。这是对流式计数函数的验证,不应写成已经验证所有伪造ZIP元信息的攻击形式。

ZIP读取器仍负责解压和CRC校验。代码不忽略BadZipFile后继续“尽量保存剩余内容”,因为本任务的交付单位是整包资料。源文件大小、条目数和写入字节都有限额,但没有硬性CPU时限或进程级内存配额;面对大量外部提交,仍需要独立进程、资源配额和接收速率控制。

每次写入新目录,成功才交付路径

调用者先提供一个自己控制的、已经存在的父目录。程序用mkdtemp创建随机的新目录,在本次Unix环境中权限为700。连续运行同一份正常资料包,会得到两个不同目录,旧结果仍保持原样。

这里没有“最后把临时目录重命名为固定名称”的步骤。这样避免了必须决定如何覆盖已有同名目录的问题,也没有把一次exists()检查包装成可靠的并发锁。所有条目成功后,程序写入_unpack_manifest.json,返回目录、相对路径、字节数与哈希。

python3 safe_unpack.py your-documents.zip ./output

使用这条命令前,应先在可信位置建立output父目录。成功时读取程序返回的新路径;返回错误时不要继续处理任何中间目录。父目录不能位于网站公开目录,也不能让不可信本地进程同时替换其中的路径。本例没有防御同一用户身份下的文件系统竞态。

输出目录在处理过程中已经存在,因此“成功才返回”不等于对其他本地进程原子发布。若后续服务会扫描这个父目录,应另设待处理区与发布协议,不能让它看到一个新目录就立即消费。

失败清理通过了,仍要留下哪些边界

30项测试覆盖正常中文资料、重复与大小写碰撞、路径和盘符、符号链接、特殊文件、NUL、加密标记、压缩方法、各项预算、重复执行及CRC损坏后的清理。通过这些测试,可以确认本例在这些合成输入下的行为;不能据此宣称它是通用的恶意文件扫描器。

异常处理也覆盖Ctrl-C,但进程被强杀、断电,或清理动作本身遇到I/O错误时仍可能留下目录。本次没有进行这些故障注入。实际接收服务需要记录任务状态,在启动或维护阶段识别未完成目录,并在确认没有活跃任务后处理残留。

清单里的SHA-256帮助后续核对文件是否改变,不证明来源可信。文本内容、编码、CSV公式、JSON字段以及接收人的权限,也还没有在这个解包器中校验。把结果交给下一个步骤前,应按业务分别处理;不要因为包已展开,就自动执行其中的内容。

代码包SHA-256:811893e52f8f21137fca297a6ee65f0bd803788af566ad36191f4a9ca1e06f2a

资料与前置阅读

资料查阅与编辑日期:2026年10月21日。作者:玖柒。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享