
给 Agent 的写文件工具加白名单,第一版校验就是一句前缀比较:把根目录和候选路径都绝对化,再看候选是不是以根目录开头。本机搭了一份等价的实现,根目录用 /private/tmp/pv-root,白名单外放一个名字多几个字符的兄弟目录 /private/tmp/pv-root-evil,喂进六类候选路径。字符串这一层全部放行,其中三条的落点真的在白名单外面,一条把合法路径判成越界,两条出在字符串与文件系统各说各话的地方。输出来自 node v26.8.1。
== 0 场景 ==
node v26.8.1 darwin arm64
白名单根 ROOT(取过 realpath) = /private/tmp/pv-root
白名单外的同前缀兄弟目录 = /private/tmp/pv-root-evil
校验函数 isInside(x) = x.startsWith(ROOT)
本卷大小写探针:.case-probe 能用 .CASE-PROBE 命中 = true
== 0 结束 ==
前缀比较把整个前缀家族都放了进来
- startsWith 比的是字符前缀,不是目录边界。兄弟目录 pv-root-evil 的名字先带上完整根目录名,isInside 对两个候选都返回 true,内容也确实读了出来。
const ok = full.startsWith(root); // '/private/tmp/pv-root-evil/pwn.txt' 也返回 true
== 1 前缀比较放行同前缀的兄弟目录 ==
候选 A = /private/tmp/pv-root/ok.txt
候选 B = /private/tmp/pv-root-evil/pwn.txt
isInside(A) = true
isInside(B) = true
B 的真实内容 = outside root
== 1 结束 ==
前缀比较在这里等于把整个家族都放进来。这种命名在部署里常见,workspace 与 workspace-old 就住在同一个父目录下。
一个 .. 就把落点挪到了白名单外
- 字符串拼接不解析 ..,拼出来的结果仍然以根目录开头。候选 ../pv-root-evil/pwn.txt 直接接在根目录后面,isInside 返回 true,内核解析出来的却是白名单外的文件,读到的内容也来自那里。归一化要在字符串比较之前做完,path.resolve 能消掉中间的点段。
path.resolve(root + '/../pv-root-evil/pwn.txt'); // -> /private/tmp/pv-root-evil/pwn.txt
== 2 拼接路径里的 .. 在字符串里看不见 ==
上层传下来的相对路径 = ../pv-root-evil/pwn.txt
直接拼接的结果 = /private/tmp/pv-root/../pv-root-evil/pwn.txt
isInside(拼接结果) = true
真实读到的内容 = outside root
内核解析后的真实路径 = /private/tmp/pv-root-evil/pwn.txt
path.join 归一化后 = /private/tmp/pv-root-evil/pwn.txt
path.resolve 之后 = /private/tmp/pv-root-evil/pwn.txt
== 2 结束 ==
归一化只是第一步,目录名换成符号链接,同一套点段照样穿得出去。
大小写不同的两个路径,指向同一个文件
- 字符串比较区分大小写,本机这块卷不区分。把根目录整段转成大写再读同一文件,existsSync 返回 true,内容也读得出来,而 isInside 对大写写法返回 false。字符串不相等不代表两个目标。要不要按大小写敏感处理,得问一次文件系统。
fs.existsSync('/PRIVATE/TMP/PV-ROOT/ok.txt'); // true,而前缀比较是 false
== 3 字符串区分大小写,本地卷不区分 ==
ROOT = /private/tmp/pv-root
同一个路径全大写 = /PRIVATE/TMP/PV-ROOT
isInside(大写路径) = false
fs.existsSync(大写) = true
实际读到的内容 = inside root
realpath(大写路径) = /PRIVATE/TMP/PV-ROOT/ok.txt
realpath 把大小写改回来了 = false
== 3 结束 ==
记一笔:realpath 不会把大小写改回来,大写进去大写出来,归一化的活指望不上它。
归一化的两种码点序列
- 同一个名字有 NFC 与 NFD 两种码点序列,合成的 00e9 和字母加组合记号 0301 是两个不同的值。用 NFC 创建再用 NFD 去读,existsSync 返回 true,内容照样读得到,相等判断却是 false。readdir 列出来的名字带着创建时那串码点,realpath 也保留输入的归一化形式,NFD 进就 NFD 出。
'caf\u00e9.txt' === 'cafe\u0301.txt'; // false,而两个名字读的是同一个文件
== 4 Unicode 归一化:码点不同,文件系统当同一个 ==
NFC 名字的码点 = 0063 0061 0066 00e9 002e 0074 0078 0074
NFD 名字的码点 = 0063 0061 0066 0065 0301 002e 0074 0078 0074
两个字符串相等 = false
fs.existsSync(NFD 名字) = true
用 NFD 名字读到的内容 = nfc file
readdir 返回的名字 = "caf\u00e9.txt"
readdir 返回的码点 = 0063 0061 0066 00e9 002e 0074 0078 0074
realpath(NFD 名字) = "/private/tmp/pv-root/cafe\u0301.txt"
realpath 末段码点 = 0063 0061 0066 0065 0301 002e 0074 0078 0074
== 4 结束 ==
麻烦落在去重和覆盖判断上:同一份资源按两种写法各存一次,代码里看着是两份,磁盘上只有一份。
链接跟着走,边界比较才是真的判据
- 白名单里的符号链接可以指到外面。链接自身的路径完全合规,isInside 返回 true,lstat 才看得出它是个链接,realpath 跟过去落在兄弟目录里的文件上,顺着链接读出来的也是那个文件。把 realpath 结果再拿去做前缀比较还会被骗第二次,白名单外的目录名本身就带着根目录的名字。
fs.realpathSync(root + '/link.txt'); // -> /private/tmp/pv-root-evil/pwn.txt
== 5 符号链接指到白名单外面 ==
链接自身的路径 = /private/tmp/pv-root/link.txt
isInside(链接路径) = true
lstat 是符号链接 = true
realpath(链接) = /private/tmp/pv-root-evil/pwn.txt
只用前缀比较 realpath = true <- 兄弟目录名字里就带着 ROOT
带分隔符的边界比较 = false
顺着链接读到的内容 = outside root
同一个文件换个写法,反过来误判:
isInside(/tmp/pv-root/ok.txt) = false
而它的 realpath 是 /private/tmp/pv-root/ok.txt
== 5 结束 ==
边界比较要带上分隔符,real === root 或者 real.startsWith(root + path.sep)。反方向也会出错:/tmp/pv-root/ok.txt 完全合法,而 /tmp 在 macOS 上指向 /private/tmp,realpath 之后跟白名单字符串对不上,合法读写被判成越界。
空字节在字符串比较里是隐形的
- 路径里插一个空字节,字符串比较照过,path.resolve 也照过,只有落到文件系统的那一步会抛错。相等与包含判断全都照常工作,路径看着就是根目录下面的普通文件。
fs.readFileSync(root + '/ok.txt\u0000/../../etc/hosts'); // ERR_INVALID_ARG_VALUE
== 6 路径里的空字节 ==
拼出来的字符串长度 = 44
isInside(带空字节) = true
path.resolve 能过 = true
fs.existsSync = false <- 不抛错,只说文件不存在
readFileSync 抛错 code = ERR_INVALID_ARG_VALUE
readFileSync 抛错原文 = The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received '/private/tmp/pv-root/ok.txt\x00/../../etc/hosts'
fs.openSync 抛错 code = ERR_INVALID_ARG_VALUE
fs.statSync 抛错 code = ERR_INVALID_ARG_VALUE
fs.mkdirSync 抛错 code = ERR_INVALID_ARG_VALUE
fs.readdirSync 抛错 code = ERR_INVALID_ARG_VALUE
fs.unlinkSync 抛错 code = ERR_INVALID_ARG_VALUE
传 Buffer 而不是字符串,抛错 code = ERR_INVALID_ARG_VALUE
== 6 结束 ==
existsSync 走另一条路,它不抛错,把这类路径当成不存在,回一个 false。fs 入口在参数层就把它挡了,openSync 到 unlinkSync 报的都是 ERR_INVALID_ARG_VALUE。风险不在这里:字符串本身允许带 0 字节,任何把它交给 C 层的实现,路径会在 0 字节处被截断,后半段悄悄消失。校验层该把 0 字节当成立即拒绝的输入。
顺序排好之后,同一批候选再跑一次
- 顺序比单条规则重要,前面的步骤能消掉后面步骤的漏判:先归一化,再把点段消掉,然后解析链接,最后做边界比较。八个步骤落在这份实现里。
== 7 一套能落地的校验顺序 ==
第 1 步 类型与非空:typeof x === "string" 且 x.length > 0
第 2 步 空字节:x.includes("\u0000") 为真就拒
第 3 步 归一化:x = x.normalize("NFC")
第 4 步 绝对化:full = path.resolve(base, x) 把 . 与 .. 消掉
第 5 步 解析链接:real = fs.realpathSync(full),失败退回最近一层已存在的祖先
第 6 步 大小写:本卷不区分时两边都转小写再比,realpath 不会替你改大小写
第 7 步 边界比较:real === root 或者 real.startsWith(root + path.sep)
第 8 步 落点类型:目标是目录、已存在的文件、还是新文件,三种分开处理
== 7 结束 ==
const fold = (s) => (CASE_INSENSITIVE ? s.toLowerCase() : s);
function guard(base, x) {
if (typeof x !== 'string' || x.length === 0) throw new Error('不是非空字符串');
if (x.includes('\u0000')) throw new Error('路径含空字节');
const full = path.resolve(base, x.normalize('NFC'));
let real;
try { real = fs.realpathSync(full); } catch { real = full; }
const root = fold(fs.realpathSync(base));
const r = fold(real);
if (r !== root && !r.startsWith(root + path.sep)) throw new Error('落在白名单外 ' + real);
return real;
}
== 8 按第 7 步重跑同一批候选 ==
"ok.txt" -> 放行 "/private/tmp/pv-root/ok.txt"
"../pv-root-evil/pwn.txt" -> 拒 白名单外 "/private/tmp/pv-root-evil/pwn.txt"
"/PRIVATE/TMP/PV-ROOT/ok.txt" -> 放行 "/PRIVATE/TMP/PV-ROOT/ok.txt"
"cafe\u0301.txt" -> 放行 "/private/tmp/pv-root/caf\u00e9.txt"
"link.txt" -> 拒 白名单外 "/private/tmp/pv-root-evil/pwn.txt"
"ok.txt\u0000/../../etc/hosts" -> 拒 含空字节 ""
"sub/../ok.txt" -> 放行 "/private/tmp/pv-root/ok.txt"
比较时把两边都转小写,是因为第 0 节的探针回答 true
rootReal = /private/tmp/pv-root
== 8 结束 ==
越界的三条都被拒了,另外几条正常放行。大小写那一条留了一个取舍:最严格的做法是逐段列目录,把每一节还原成磁盘上真实的大小写,我选的是探测一次卷属性、比较之前两边都转小写。多一次探针,换来判定逻辑保持一行,代价是结论跟着卷走,白名单根挪到另一块盘上得重新问一次。
还有一件事这份校验管不了:它只回答落点有没有落在白名单目录树里,管不了这棵树内部两个租户之间的隔离,也管不了写入之后的文件权限。