处理用户上传的 zip、批量打包导出、解压第三方数据包时,Python 标准库 zipfile 几乎绕不开。真正容易出问题的地方不是会不会调用 ZipFile,而是 read() 大文件把服务 OOM、extractall 解压不可信包触发 Zip Slip、Windows 中文文件名乱码和 zip 炸弹。下面按读写、流式处理、路径安全、编码修复、追加与体积限制展开。
基本读写先跑通
创建一个 zip 并写入几个文件:- import zipfile
- with zipfile.ZipFile('out.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
- zf.write('report.pdf')
- zf.write('data/a.csv', 'a.csv')
- zf.writestr('note.txt', '直接写字符串,不用先落地文件')
复制代码 compression 参数很容易被忽略:不传时默认 ZIP_STORED,只是把文件原样塞进包,体积不会减少。要压缩必须显式指定 ZIP_DEFLATED(通用)或 ZIP_LZMA(压得更狠但更慢)。zf.write() 的第二个参数 arcname 可以把文件改成压缩包内的路径;writestr() 则适合把内存中生成的内容直接写进包,省去临时文件。
读取和查看内容:- import zipfile
- with zipfile.ZipFile('out.zip', 'r') as zf:
- print(zf.namelist())
- for info in zf.infolist():
- print(info.filename, info.file_size, info.compress_size)
- data = zf.read('note.txt')
- print(data.decode('utf-8'))
复制代码 read() 返回 bytes,会把整个条目一次性读进内存。小文件无所谓,大文件就是隐患。
坑一:read() 大文件爆内存,改用流式
假设包里有个 2GB 的日志文件,要逐行处理,下面写法会把 2GB 条目一次读进内存,直接 OOM:- import zipfile
- with zipfile.ZipFile('logs.zip') as zf:
- content = zf.read('huge.log')
- for line in content.splitlines():
- handle(line)
复制代码 正确做法是用 zf.open() 拿到文件式对象,像普通文件一样流式读,内存中始终只有一块数据:- import io
- import zipfile
- with zipfile.ZipFile('logs.zip') as zf:
- with zf.open('huge.log') as raw:
- for line in io.TextIOWrapper(raw, encoding='utf-8'):
- handle(line.rstrip('\n'))
复制代码 zf.open() 返回的是边解压边读的流,配合 TextIOWrapper 就能按行迭代,内存占用和文件大小无关。解压到磁盘时也不要 read() 后再自己写,用 shutil.copyfileobj 分块拷贝:- import shutil
- import zipfile
- with zipfile.ZipFile('logs.zip') as zf:
- with zf.open('huge.log') as src, open('huge.log', 'wb') as dst:
- shutil.copyfileobj(src, dst, length=1024 * 1024)
复制代码 只要条目可能很大,就用 zf.open() 流式处理,别碰 zf.read()。
坑二:Zip Slip 路径穿越,extractall 对不可信包等于开后门
对不可信上传包直接 extractall 很危险:- import zipfile
- with zipfile.ZipFile(uploaded_zip) as zf:
- zf.extractall('/data/uploads/')
复制代码 zip 内条目的文件名可以是任意字符串,包括 ../../../etc/cron.d/evil 或绝对路径 /etc/passwd。攻击者构造这样的包,解压时文件就可能写到 /data/uploads/ 之外,覆盖系统配置、往定时任务目录塞脚本、篡改项目代码。这类 Zip Slip 问题在 Java、Python、JS 生态都出现过。先看攻击怎么造出来,理解后才知道怎么防:- import zipfile
- with zipfile.ZipFile('evil.zip', 'w') as zf:
- zf.writestr('../../../../tmp/pwned.txt', 'escaped!')
复制代码 Python 3.6.2 起,extractall / extract 已经会把带 .. 和绝对路径的条目名做净化,重定向到目标目录内,不会真的穿越。但这个净化只防最经典的 ../,不能全信,不同版本行为有差异,符号链接、特殊字符仍可能有边界情况。生产环境处理不可信压缩包,应当自己显式校验每一个解压目标路径:- import os
- import zipfile
- def safe_extract(zip_path: str, dest_dir: str) -> None:
- dest_dir = os.path.realpath(dest_dir)
- with zipfile.ZipFile(zip_path) as zf:
- for member in zf.namelist():
- target = os.path.realpath(os.path.join(dest_dir, member))
- if not target.startswith(dest_dir + os.sep) and target != dest_dir:
- raise ValueError(f'检测到路径穿越,拒绝解压条目:{member}')
- zf.extractall(dest_dir)
- # safe_extract('evil.zip', '/data/uploads') # 会抛 ValueError,拒之门外
复制代码 关键是先用 os.path.realpath 把拼出的目标路径解析成真实绝对路径,它会展开 .. 和符号链接,再判断结果是否仍以 dest_dir 为前缀。只要跳出了目标目录,一律拒绝。这层校验不依赖标准库版本,自己守住最踏实。
坑三:zip 里的中文文件名乱码
Windows 下用资源管理器打的 zip,文件名常用 GBK 编码,而 zip 规范默认 CP437,Python 解出来就是一串乱码:- import zipfile
- with zipfile.ZipFile('windows.zip') as zf:
- for info in zf.infolist():
- name = info.filename
- if not info.flag_bits & 0x800:
- name = info.filename.encode('cp437').decode('gbk', errors='replace')
- print(name)
复制代码 判断依据是 flag_bits & 0x800,也就是通用位标记的第 11 位:置位表示文件名是 UTF-8,不用处理;没置位且出现乱码,基本就是 CP437 误读了 GBK,用 encode('cp437').decode('gbk') 绕回去即可。
追加与防 zip 炸弹
往已有 zip 追加文件用 'a' 模式:- import zipfile
- with zipfile.ZipFile('out.zip', 'a', zipfile.ZIP_DEFLATED) as zf:
- zf.writestr('added.txt', '追加进来的内容')
复制代码 处理不可信包还要防 zip 炸弹:一个几十 KB 的包可能解压出几十 GB,撑爆磁盘。解压前用 infolist() 检查累计的 file_size,超过阈值就拒绝:- import zipfile
- MAX_TOTAL = 500 * 1024 * 1024
- with zipfile.ZipFile(untrusted) as zf:
- total = sum(info.file_size for info in zf.infolist())
- if total > MAX_TOTAL:
- raise ValueError(f'解压后体积 {total} 超限,疑似 zip 炸弹')
复制代码 小结:写包必须显式使用 compression=ZIP_DEFLATED,否则默认只打包不压缩;writestr 可把内存内容直接入包;大条目一律用 zf.open() 流式读,配合 shutil.copyfileobj 分块写,别用 zf.read() 一次吞进内存;解压不可信压缩包必须防 Zip Slip,用 os.path.realpath 校验每个目标路径仍在目标目录内,跳出就拒绝;Windows 来源的 zip 中文名乱码,靠 flag_bits & 0x800 判断编码,用 encode('cp437').decode('gbk') 修复;不可信包还要防 zip 炸弹,解压前用 infolist() 累加 file_size 设上限。记忆点:读大用流、解压先验路径、来源不可信就当它有毒。 |