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

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

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

URL 解析先折叠了路径,验权不能再看原字符串

2026-10-21 / 0 评论 / 8 阅读

URL 解析先折叠了路径,验权不能再看原字符串

/a/../admin 到了 new URL() 手里,pathname 已经是 /admin。/a/%2e%2e/admin 也一样。要是访问控制先按原始字符串做判断,路由随后看解析后的路径,两边检查的就不是同一个东西。

这个差异影响资源最终落到哪里。比如一个接口只允许读取用户自己的目录,鉴权时检查路径前缀,文件读取时又把路径正规化;两边拿到的字符串一旦不同,前缀判断就失去意义。先确定路由实际使用的路径形式,再把同一个值交给权限判断,比在不同位置各补一次解码更稳。

本机 Node v26.8.1 跑过下列输入,脚本在 tools/verify-url-path-boundary.mjs,逐行输出保存在同目录的 .log。

点段在解析时消失

{"requestTarget":"/a/../admin","pathname":"/admin","search":"","decodedPathname":"/admin"}
{"requestTarget":"/a/%2e%2e/admin","pathname":"/admin","search":"","decodedPathname":"/admin"}
{"requestTarget":"/a/%252e%252e/admin","pathname":"/a/%252e%252e/admin","search":"","decodedPathname":"/a/%2e%2e/admin"}

前两条在解析阶段都变成 /admin,第三条还保留 %252e%252e。再调用一次 decodeURIComponent 才得到 %2e%2e,还没变成 ..。路径处理不能只说「解析过了」或「解码过了」,得说清每个环节具体做了几遍什么。尤其是业务代码接手 pathname 之后,是否还会交给框架再解一次,不能靠变量名判断。最好把解析位置固定在入口,后续组件只消费已经确定的表示;若确实需要转换,也要让调用边界明确可见。

一个容易写错的门禁是:按原始 request target 判断它是不是 /admin,然后把 URL 对象交给后面的路由。经实测,原始字符串 /a/%2e%2e/admin 跟解析后的 /admin 完全不是一回事。权限判断和后续资源处理必须基于同一份规范化结果;不满足策略就拒绝,不能在门禁之后再做一次可能改变层级的解码。

编码斜杠让分段前后不一样

斜杠编码 %2F 不会在 pathname 中立即展开。拆分和解码顺序一换,层级就变了:

{"value":"/a/%2Fadmin","pathname":"/a/%2Fadmin","segments":["","a","%2Fadmin"],"decodedOnce":"/a//admin","decodedTwice":"/a//admin"}
{"value":"/a/%252Fadmin","pathname":"/a/%252Fadmin","segments":["","a","%252Fadmin"],"decodedOnce":"/a/%2Fadmin","decodedTwice":"/a//admin"}

第一条先按 / 拆段时,%2Fadmin 还是一个段;解码后它变成了 /admin,多出来的斜杠已经在拆分动作之后出现。第二条再解一次,才从 %2F 变出分隔符。要是鉴权按段判断,而文件查找在后面解码,检查边界和实际边界会错开。

所以路径不是「解码越多越稳」。decodeURIComponent 每多跑一次,都可能把数据变成结构字符。尤其不能靠对整个路径做两遍解码来「清理」输入;双重编码的点段和斜杠会因此获得新的语义。

坏百分号序列不会悄悄变安全

这份脚本还测了 /a/%ZZ。new URL() 接受这个输入,pathname 仍能读出原样;随后调用 decodeURIComponent 抛出 URIError: URI malformed。错误处理应该在路由入口明确接住并返回 400,不能把异常漏成 500,也不能尝试宽松替换后继续走授权逻辑。

import { URL } from 'node:url';

function parseRequestPath(requestTarget) {
  const url = new URL(requestTarget, 'http://localhost');
  try {
    return decodeURIComponent(url.pathname);
  } catch {
    return null;
  }
}

for (const target of ['/a/%2Fadmin', '/a/%ZZ']) {
  console.log(target, '=>', parseRequestPath(target));
}

这段只示范把解码失败变成拒绝信号,不是通用的路径安全库。实际接口还要决定 %2F 是否允许,以及连续斜杠如何处理,并确保权限判断使用的结果就是文件查找使用的结果。我不建议在不同中间件里重复解析,隐式的「多做一次」才是最难排查的部分。

已有文章谈过查询参数中的加号如何转为空格;这里关注的是路径中的分隔符和点段,两套语义不要混为一谈。Node 的 URL 解析可以帮忙规范化,但它不会替应用决定授权策略。先确定唯一的路径表示,再在它上面做权限判断,并让后续资源处理沿用同一个值。

如果应用还要把路径映射到磁盘,可以在入口函数里集中处理解析和拒绝,把结果作为明确参数传给后续逻辑。日志最好分别记录收到的 request target 与最终采用的路径,发生误判时能定位差异究竟出现在解析前还是解析后。只保存一个叫 path 的字段,过几层之后往往分不清它是哪一种表示。

    🤞 分享