04 用 yauzl 安全读取 ZIP

本章目标:不使用“解压全部”黑盒接口,而是逐个检查 Entry、逐个打开读取流,并把允许的文件写入一次性的隔离目录。

1. 为什么“能打开 ZIP”不等于“可以解压”

ZIP 来自用户时,其中的名称、大小、类型和内容都属于不可信输入。即使上传的压缩包只有 5 MiB,也可能包含:

因此安全流程不是:

打开 ZIP → extractAll(目标目录)

而是:

限制上传文件本身
  → 打开 ZIP
  → 每次读取一个 Entry
  → 检查名称、类型和声明大小
  → 打开当前 Entry 的数据流
  → 边读取边限制实际字节数并写入隔离目录
  → 全部成功后才发布结果

Multer 的 limits.fileSize 只限制 .zip 文件本身,不能限制解压后的大小。

2. 建立多道、相互独立的限制

先把限制集中到一个配置对象中。下面的数字只是适合教程的示例,并不是所有业务的标准答案:

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,并在读取流时再统计实际流出的字节。

3. 打开 ZIP 时选择安全、可控的选项

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);
      },
    );
  });
}

这里最关键的是:

即使库本身已有文件名防护,应用仍应做自己的“目标路径必须留在隔离目录中”检查。这是纵深防御,也能保护以后替换 ZIP 库时不丢失安全边界。

4. 先把 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() 更适合表达“目标是否仍位于根目录内”。

5. 拒绝符号链接和特殊文件

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 类型信息。因此还要遵守两条工程规则:

  1. 使用 fs.mkdtemp() 创建服务器独占的全新隔离目录,不在用户可写的既有目录中解压;
  2. 在发布前不要把该目录暴露给 Nginx,也不要运行其中任何内容。

这样可以减少既有符号链接被利用的机会。

6. 检查重复名称和大小写冲突

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,在注册新名称时检查它的所有父路径是否已被注册为文件。

7. 逐项读取、逐项写入

下面展示核心结构,省略业务文件类型白名单。重点是:处理完当前 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 接收共享计数器,一旦实际总量超限就销毁流水线。

8. CRC、损坏压缩包与“读取成功”

ZIP 的 Entry 包含 CRC-32,但 yauzl 的大小验证不等于完整的 CRC-32 内容校验。若业务需要校验传输完整性,应在流经过时计算 CRC-32,再与 entry.crc32 比较;这通常需要一个专门的、维护良好的 CRC 库。

不要使用 CRC-32 判断文件是否“安全”:

损坏包可能在不同阶段报错:打开 Central Directory、读取 Entry、解压数据或结束大小验证。以下错误都必须使整个导入失败,不能跳过后继续发布一个半成品项目。

9. 隔离目录和原子发布

建议为每次任务创建独立目录:

/var/app/tmp/md-import-a1b2c3/
├── extracted/
├── generated/
└── result.zip.part

完整生命周期是:

  1. 使用 mkdtemp() 创建任务目录;
  2. 将允许的 Entry 写入 extracted
  3. 完成全部校验后生成 HTML;
  4. archiver 写入 .part 临时结果;
  5. ZIP 成功关闭后再重命名为最终名称;
  6. 响应结束或失败时清理任务目录。

数据库事务无法回滚文件系统操作。若最终文件需要长期保存,应先记录 processing 状态,文件发布成功后再更新为 ready;失败任务由补偿清理程序处理。

10. 推荐的错误码

面向客户端返回稳定错误码,不要返回服务器绝对路径或 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 和处理阶段,但文件名也属于用户输入,写日志时要移除控制字符并限制长度。

复盘题

  1. 为什么 Multer 的 fileSize 无法防止 ZIP bomb?
  2. lazyEntries: true 为什么有助于限制资源,而不只是改变 API 写法?
  3. 为什么检查 target.startsWith(root) 仍可能出现目录逃逸判断错误?
  4. 为什么应同时限制单文件大小、总大小、文件数量和压缩比?
  5. 在 Linux 上解压正常的 Logo.pnglogo.png,为什么部署到 Windows 后可能出问题?
  6. validateEntrySizes 与 CRC-32 分别能发现什么问题?CRC-32 为什么不能用于安全认证?
  7. 为什么把文件写入新建隔离目录,比直接写入公开静态目录更安全?
  8. 如果第 499 个 Entry 校验失败,前 498 个已写入的文件应该如何处理?

官方参考