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,原始名称不参与本地路径构造。
练习题
- 为
resolveInsideRoot()编写 15 个恶意输入测试。 - 处理 ZIP 目录 Entry 的尾随
/。 - 解释“规范化非法文件名”和“拒绝非法 Entry”的不同风险。
- 为什么词法边界检查仍不能阻止符号链接逃逸?