17 符号链接、硬链接与文件系统竞态
1. 符号链接
符号链接保存另一个路径。假设任务目录中有:
work/job-1/output → /etc
词法上 work/job-1/output/file 位于任务目录,实际访问却可能进入 /etc。
2. stat() 与 lstat()
const linkInfo = await lstat(entryPath);
const targetInfo = await stat(entryPath);
lstat() 返回链接自身信息,stat() 跟随链接。清理和归档导入需要明确是否允许链接。用户 ZIP/TAR 默认建议拒绝符号链接和硬链接。
3. realpath()
const realRoot = await realpath(rootDirectory);
const realTarget = await realpath(targetPath);
比较真实路径可以发现一部分链接逃逸,但目标尚未创建时无法直接 realpath(),而检查后到写入前目标仍可能被替换。
4. TOCTOU
Time Of Check To Time Of Use:
检查目标安全
→ 攻击者替换目录为链接
→ 程序写入
因此:
- 不要在其他不可信用户可写的共享目录内处理;
- 每任务目录使用不可预测名称和严格权限;
- 尽量使用排他打开;
- 不接受归档链接;
- 操作系统级隔离比不断增加字符串正则更可靠。
5. 硬链接
硬链接是同一文件数据的另一个目录项,不像符号链接那样保存目标路径。若允许不可信归档恢复硬链接,可能影响配额、覆盖和清理语义。初学项目最简单的安全策略仍是拒绝。
6. “先检查存在”也是竞态
if (await exists(targetPath)) {
throw new Error("存在");
}
await writeFile(targetPath, content);
两个请求可能同时检查不存在,然后同时写。应使用操作系统原子能力:
await writeFile(targetPath, content, {
flag: "wx",
});
唯一约束、排他打开和数据库条件更新都体现同一原则:把“检查并修改”尽量交给能原子执行的层。
7. 删除竞态
定时清理程序检查目录过期后,任务可能正好重新开始使用。需要数据库状态、锁文件、原子状态更新或任务目录所有权约定,不能仅根据 mtime 就删除所有旧目录。
练习题
- 创建符号链接并比较
stat()、lstat()和realpath()。 - 用两个并发 Promise 复现“先检查再写”的竞态。
- 为什么
wx只能防止目标已存在,不能解决所有路径安全问题? - 设计清理器与 Worker 之间避免误删的状态协议。