本章目标:不使用“解压全部”黑盒接口,而是逐个检查
Entry、逐个打开读取流,并把允许的文件写入一次性的隔离目录。
ZIP 来自用户时,其中的名称、大小、类型和内容都属于不可信输入。即使上传的压缩包只有 5 MiB,也可能包含:
../../.env 这样的路径穿越名称(Zip Slip);因此安全流程不是:
打开 ZIP → extractAll(目标目录)
而是:
限制上传文件本身
→ 打开 ZIP
→ 每次读取一个 Entry
→ 检查名称、类型和声明大小
→ 打开当前 Entry 的数据流
→ 边读取边限制实际字节数并写入隔离目录
→ 全部成功后才发布结果
Multer 的 limits.fileSize 只限制 .zip 文件本身,不能限制解压后的大小。
先把限制集中到一个配置对象中。下面的数字只是适合教程的示例,并不是所有业务的标准答案:
interface ZipSafetyLimits {
maxEntries: number;
maxSingleFileBytes: number;
maxTotalUncompressedBytes: number;
maxCompressionRatio: number;
maxPathDepth: number;
maxFileNameLength: number;
}
const zipLimits: ZipSafetyLimits = {
maxEntries: 500,
maxSingleFileBytes: 10 * 1024 * 1024,
maxTotalUncompressedBytes: 50 * 1024 * 1024,
maxCompressionRatio: 100,
maxPathDepth: 10,
maxFileNameLength: 240,
};
每项限制解决的问题不同:
| 限制 | 防范的问题 |
|---|---|
| Entry 数量 | 海量小文件、inode 和遍历时间耗尽 |
| 单文件解压大小 | 一个超大文件占满磁盘或内存 |
| 总解压大小 | 大量中等文件合计占满磁盘 |
| 压缩比 | 很小的压缩数据膨胀成巨大内容 |
| 路径深度 | 极深目录造成工具异常和维护困难 |
| 文件名长度 | 文件系统限制、日志污染和异常路径 |
绝对大小限制是主要防线,压缩比只是补充。空文件的 compressedSize 可能是 0,计算时要避免除以零:
function compressionRatio(entry: yauzl.Entry): number {
if (entry.uncompressedSize === 0) return 0;
if (entry.compressedSize === 0) return Number.POSITIVE_INFINITY;
return entry.uncompressedSize / entry.compressedSize;
}
不要只相信 Central Directory 中声明的 uncompressedSize。应开启 validateEntrySizes,并在读取流时再统计实际流出的字节。
import yauzl from "yauzl";
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(
zipPath,
{
lazyEntries: true,
autoClose: true,
decodeStrings: true,
validateEntrySizes: true,
strictFileNames: true,
},
(error, zipFile) => {
if (error) {
reject(error);
return;
}
resolve(zipFile);
},
);
});
}
这里最关键的是:
lazyEntries: true:只有主动调用 readEntry() 才读取下一个 Entry,便于串行处理和实施背压;validateEntrySizes: true:让 yauzl 检查声明大小和实际解压数据是否一致;strictFileNames: true:对不规范的反斜杠名称采取严格策略,而不是静默替换。即使库本身已有文件名防护,应用仍应做自己的“目标路径必须留在隔离目录中”检查。这是纵深防御,也能保护以后替换 ZIP 库时不丢失安全边界。
ZIP 内部路径约定使用 /。攻击者可能故意放入反斜杠、盘符、UNC 路径、空段或 ..。
import path from "node:path";
function validateEntryName(
fileName: string,
limits: ZipSafetyLimits,
): string {
if (fileName.length === 0 || fileName.length > limits.maxFileNameLength) {
throw new Error("ZIP_ENTRY_NAME_INVALID");
}
if (fileName.includes("\\") || fileName.includes("\0")) {
throw new Error("ZIP_ENTRY_NAME_INVALID");
}
if (
fileName.startsWith("/") ||
/^[A-Za-z]:\//.test(fileName) ||
fileName.startsWith("//")
) {
throw new Error("ZIP_ABSOLUTE_PATH");
}
const parts = fileName.split("/").filter((part) => part.length > 0);
if (parts.some((part) => part === "." || part === "..")) {
throw new Error("ZIP_PATH_TRAVERSAL");
}
if (parts.length > limits.maxPathDepth) {
throw new Error("ZIP_PATH_TOO_DEEP");
}
return parts.join("/");
}
function resolveInside(root: string, safeRelativeName: string): string {
const absoluteRoot = path.resolve(root);
const target = path.resolve(absoluteRoot, ...safeRelativeName.split("/"));
const relative = path.relative(absoluteRoot, target);
if (
relative === "" ||
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new Error("ZIP_PATH_TRAVERSAL");
}
return target;
}
注意:目录 Entry 经过过滤后可能得到空字符串,目录应该单独处理,不能把根目录本身当成普通文件写入。
仅检查:
target.startsWith(root)
不够严谨,因为 /tmp/job-12-evil 也以 /tmp/job-12 开头。path.relative() 更适合表达“目标是否仍位于根目录内”。
ZIP 可在外部属性中保存 Unix 文件类型。对于当前 Markdown 导入业务,最简单可靠的策略是:只接受普通文件和目录,拒绝符号链接以及设备文件等特殊类型。
const UNIX_PLATFORM = 3;
const FILE_TYPE_MASK = 0o170000;
const REGULAR_FILE = 0o100000;
const DIRECTORY = 0o040000;
const SYMBOLIC_LINK = 0o120000;
function getUnixFileType(entry: yauzl.Entry): number | undefined {
const madeByPlatform = entry.versionMadeBy >>> 8;
if (madeByPlatform !== UNIX_PLATFORM) return undefined;
const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff;
return unixMode & FILE_TYPE_MASK;
}
function assertSupportedEntryType(entry: yauzl.Entry): void {
const type = getUnixFileType(entry);
if (type === SYMBOLIC_LINK) {
throw new Error("ZIP_SYMBOLIC_LINK_NOT_ALLOWED");
}
if (type !== undefined && type !== 0 && type !== REGULAR_FILE && type !== DIRECTORY) {
throw new Error("ZIP_SPECIAL_FILE_NOT_ALLOWED");
}
}
外部属性依赖创建 ZIP 的平台,不能保证每个压缩包都带有完整 Unix 类型信息。因此还要遵守两条工程规则:
fs.mkdtemp() 创建服务器独占的全新隔离目录,不在用户可写的既有目录中解压;这样可以减少既有符号链接被利用的机会。
Linux 通常区分大小写,而 Windows 通常不区分。下面两个名称可能在开发机和生产机得到不同结果:
assets/Logo.png
assets/logo.png
建议同时维护原始名称集合和大小写折叠集合:
const exactNames = new Set<string>();
const foldedNames = new Set<string>();
function registerUniqueName(safeName: string): void {
const normalized = safeName.normalize("NFC");
const folded = normalized.toLocaleLowerCase("en-US");
if (exactNames.has(normalized) || foldedNames.has(folded)) {
throw new Error("ZIP_DUPLICATE_ENTRY");
}
exactNames.add(normalized);
foldedNames.add(folded);
}
还要拒绝“同一路径先作为文件、后又作为目录”的冲突。生产代码可维护一份 manifest,在注册新名称时检查它的所有父路径是否已被注册为文件。
下面展示核心结构,省略业务文件类型白名单。重点是:处理完当前 Entry 后才调用下一次 readEntry()。
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
function openEntryStream(
zipFile: yauzl.ZipFile,
entry: yauzl.Entry,
): Promise<NodeJS.ReadableStream> {
return new Promise((resolve, reject) => {
zipFile.openReadStream(entry, (error, stream) => {
if (error) {
reject(error);
return;
}
resolve(stream);
});
});
}
function byteLimit(maxBytes: number): Transform {
let received = 0;
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length;
if (received > maxBytes) {
callback(new Error("ZIP_ENTRY_ACTUAL_SIZE_EXCEEDED"));
return;
}
callback(null, chunk);
},
});
}
async function writeEntry(
zipFile: yauzl.ZipFile,
entry: yauzl.Entry,
target: string,
maxBytes: number,
): Promise<void> {
await mkdir(path.dirname(target), { recursive: true });
const input = await openEntryStream(zipFile, entry);
await pipeline(
input,
byteLimit(maxBytes),
createWriteStream(target, { flags: "wx" }),
);
}
flags: "wx" 会在目标已存在时失败,避免静默覆盖。pipeline() 会传播读写错误并处理背压,比手工监听 data 后调用 write() 更可靠。
总大小需要同时在 Entry 元数据阶段累加,并在实际流读取阶段独立统计。生产实现可以让 byteLimit 接收共享计数器,一旦实际总量超限就销毁流水线。
ZIP 的 Entry 包含 CRC-32,但 yauzl 的大小验证不等于完整的 CRC-32 内容校验。若业务需要校验传输完整性,应在流经过时计算 CRC-32,再与 entry.crc32 比较;这通常需要一个专门的、维护良好的 CRC 库。
不要使用 CRC-32 判断文件是否“安全”:
损坏包可能在不同阶段报错:打开 Central Directory、读取 Entry、解压数据或结束大小验证。以下错误都必须使整个导入失败,不能跳过后继续发布一个半成品项目。
建议为每次任务创建独立目录:
/var/app/tmp/md-import-a1b2c3/
├── extracted/
├── generated/
└── result.zip.part
完整生命周期是:
mkdtemp() 创建任务目录;extracted;.part 临时结果;数据库事务无法回滚文件系统操作。若最终文件需要长期保存,应先记录 processing 状态,文件发布成功后再更新为 ready;失败任务由补偿清理程序处理。
面向客户端返回稳定错误码,不要返回服务器绝对路径或 yauzl 的完整内部错误:
INVALID_ZIP
ZIP_ENTRY_LIMIT_EXCEEDED
ZIP_ENTRY_TOO_LARGE
ZIP_TOTAL_SIZE_EXCEEDED
ZIP_COMPRESSION_RATIO_EXCEEDED
ZIP_PATH_TRAVERSAL
ZIP_SYMBOLIC_LINK_NOT_ALLOWED
ZIP_DUPLICATE_ENTRY
ZIP_CORRUPTED
日志中可以保留原始异常、请求 ID 和处理阶段,但文件名也属于用户输入,写日志时要移除控制字符并限制长度。
fileSize 无法防止 ZIP bomb?lazyEntries: true 为什么有助于限制资源,而不只是改变 API 写法?target.startsWith(root) 仍可能出现目录逃逸判断错误?Logo.png 与 logo.png,为什么部署到 Windows 后可能出问题?validateEntrySizes 与 CRC-32 分别能发现什么问题?CRC-32 为什么不能用于安全认证?