侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 184 篇文章
  • 累计收到 7 条评论

路径校验说通过了,文件落在白名单外面

2026-10-13 / 0 评论 / 5 阅读

路径校验说通过了,文件落在白名单外面

给 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 结束 ==

前缀比较把整个前缀家族都放了进来

  1. 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 就住在同一个父目录下。

一个 .. 就把落点挪到了白名单外

  1. 字符串拼接不解析 ..,拼出来的结果仍然以根目录开头。候选 ../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 结束 ==

归一化只是第一步,目录名换成符号链接,同一套点段照样穿得出去。

大小写不同的两个路径,指向同一个文件

  1. 字符串比较区分大小写,本机这块卷不区分。把根目录整段转成大写再读同一文件,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 不会把大小写改回来,大写进去大写出来,归一化的活指望不上它。

归一化的两种码点序列

  1. 同一个名字有 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 结束 ==

麻烦落在去重和覆盖判断上:同一份资源按两种写法各存一次,代码里看着是两份,磁盘上只有一份。

链接跟着走,边界比较才是真的判据

  1. 白名单里的符号链接可以指到外面。链接自身的路径完全合规,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 之后跟白名单字符串对不上,合法读写被判成越界。

空字节在字符串比较里是隐形的

  1. 路径里插一个空字节,字符串比较照过,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 字节当成立即拒绝的输入。

顺序排好之后,同一批候选再跑一次

  1. 顺序比单条规则重要,前面的步骤能消掉后面步骤的漏判:先归一化,再把点段消掉,然后解析链接,最后做边界比较。八个步骤落在这份实现里。
== 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 结束 ==

越界的三条都被拒了,另外几条正常放行。大小写那一条留了一个取舍:最严格的做法是逐段列目录,把每一节还原成磁盘上真实的大小写,我选的是探测一次卷属性、比较之前两边都转小写。多一次探针,换来判定逻辑保持一行,代价是结论跟着卷走,白名单根挪到另一块盘上得重新问一次。

还有一件事这份校验管不了:它只回答落点有没有落在白名单目录树里,管不了这棵树内部两个租户之间的隔离,也管不了写入之后的文件权限。

    🤞 分享