16 安全路径与目录穿越

1. 威胁模型

用户可能提供:

../../server.ts
/etc/passwd
C:\Windows\System32\config
docs/../../../secret
docs\..\..\secret

目标是确保最终路径只能位于某个任务根目录。

2. 一个可复用的边界函数

import path from "node:path";

export function resolveInsideRoot(
  rootDirectory: string,
  relativeInput: string,
): string {
  if (path.isAbsolute(relativeInput)) {
    throw new Error("不允许绝对路径");
  }

  const root = path.resolve(rootDirectory);
  const target = path.resolve(root, relativeInput);
  const relative = path.relative(root, target);

  if (
    relative === ".." ||
    relative.startsWith(`..${path.sep}`) ||
    path.isAbsolute(relative)
  ) {
    throw new Error("路径越过允许目录");
  }

  return target;
}

这解决词法路径逃逸,但不自动解决目标目录中已有符号链接。

3. 为什么 startsWith() 不安全

target.startsWith(root)

如果根是:

/data/jobs/12

那么下面也具有相同字符串前缀:

/data/jobs/123-evil

必须使用路径段语义比较。

4. ZIP Entry 使用 POSIX 规则

function parseZipEntryPath(entryName: string): string[] {
  if (entryName.includes("\0")) {
    throw new Error("ZIP 路径包含 NUL");
  }

  if (entryName.includes("\\")) {
    throw new Error("ZIP 路径包含非标准分隔符");
  }

  if (path.posix.isAbsolute(entryName)) {
    throw new Error("ZIP 包含绝对路径");
  }

  const segments = entryName.split("/");

  if (segments.some(
    (segment) => segment === "" || segment === "." || segment === "..",
  )) {
    throw new Error("ZIP 路径包含非法路径段");
  }

  return segments;
}

目录 Entry 常以 / 结尾,会产生最后一个空段;调用者应先根据 Entry 类型处理尾部 /,而不是把所有空段一概忽略。忽略可能掩盖异常路径。

5. 每个路径段都要限制

建议限制:

单段长度
总路径长度
控制字符
Windows 保留名称
尾随点和空格
大小写折叠冲突
重复 Entry

不要把非法字符简单替换成 _ 后继续,因为:

a?.md  → a_.md
a*.md  → a_.md

两个不同输入可能变成同一目标并覆盖。对归档内容,更安全的默认策略是拒绝并报告具体 Entry。

6. 文件名与存储名分离

上传文件物理存储可使用:

9d2942cb285c09fb3803075520021dea

数据库另存:

originalName = 实验结果 01.zip
storageKey = uploads/2026/08/9d294...

下载时再安全生成 Content-Disposition,原始名称不参与本地路径构造。

练习题

  1. resolveInsideRoot() 编写 15 个恶意输入测试。
  2. 处理 ZIP 目录 Entry 的尾随 /
  3. 解释“规范化非法文件名”和“拒绝非法 Entry”的不同风险。
  4. 为什么词法边界检查仍不能阻止符号链接逃逸?