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 就删除所有旧目录。

练习题

  1. 创建符号链接并比较 stat()lstat()realpath()
  2. 用两个并发 Promise 复现“先检查再写”的竞态。
  3. 为什么 wx 只能防止目标已存在,不能解决所有路径安全问题?
  4. 设计清理器与 Worker 之间避免误删的状态协议。