fast-uri 把反斜杠当普通字符,Node 原生 URL 把它当路径分隔符。同一个 URL 在检测端解析出主机 allowed.com,在请求时却连到 127.0.0.1,CVE-2026-16221 与 CVE-2026-18446 都出在这个分歧上。

0. 两个 CVE 与影响版本

编号

触发形态

受影响版本

修复版本

CVE-2026-16221

字面反斜杠 \ 不被当作 authority 分隔符,被折进 userinfo:http://127.0.0.1:9011\@allowed.com

2.3.1–2.4.2、3.0.0–3.1.3、4.0.0–4.1.0

2.4.3、3.1.4、4.1.1

CVE-2026-18446

不要求字面 // 才识别 authority,\\、/\、\/ 当引入符时被折进 path

< 2.4.4、3.0.0–3.1.4、4.0.0–4.1.1

2.4.4、3.1.5、4.1.2

两个通告在 OSV 数据库里也有镜像记录:GHSA-v2hh-gcrm-f6hx、GHSA-7p8r-x3mc-p8w7。

补丁不是同一时刻发布的,npm registry 的 time 字段可以对齐两条修复线:

# 取 fast-uri 的 dist-tags 与各版本发布时间
curl -s https://registry.npmjs.org/fast-uri -o /tmp/reg.json
python3 -c "import json;d=json.load(open('/tmp/reg.json'));print('dist-tags:',json.dumps(d['dist-tags']));[print(v,d['time'].get(v)) for v in ['2.4.2','2.4.3','2.4.4','2.4.7','3.1.3','3.1.4','3.1.5','3.1.8','4.1.0','4.1.1','4.1.2','4.2.2']]"

按版本线对齐看:字面反斜杠这条修复线落在 4.1.1(07-18)、2.4.3 与 3.1.4(07-19);引入符这条落在 2.4.4、3.1.5、4.1.2(同为 07-31),两条线相隔约两周。按 CWE-436(解释冲突)归类,两枚属于同一类语义分歧;白名单被绕过之后的落点则是服务端请求伪造(CWE-918)。

分歧由此产生:校验用 fast-uri,执行用 Node,两边对同一个 \ 的理解相反。

1. 复现环境与依赖版本

# 打印 Node 运行时版本
node -v

# 列出顶层依赖(--depth=0)
npm ls --depth=0

别名统一挂在 fasturi 前缀下:fasturi410 即 fast-uri@4.1.0,fasturi412 = 4.1.2,fasturi422 = 4.2.2,其余同理。

其余运行时版本:

# 横向对照用到的其余运行时版本
python3 --version; go version; java -version 2>&1 | head -1

# curl 版本
curl --version | head -1

2. 同一字符串的解析分歧

# 用 4.1.0 跑六组输入:基线、U+FF3C、U+FE68、U+FF0F、单斜杠、%5C
node latest_probe.js fasturi410

host= 后面的双引号来自 JSON.stringify,路径里的反斜杠同样按 JSON 规则双写。

# 用 4.1.1 跑同样六组输入
node latest_probe.js fasturi411

两版 fast-uri 都给出 host="allowed.com",而 Node 给出 host=evil.com(WHATWG 的 host 解析规则见 host parsing)。

normalize() 在 4.1.0 把 P 改写成 "http://evil.com\\@allowed.com/",4.1.1 原样返回。两版都没有把 \ 换成 /,所以拿 normalize() 的输出再送进执行端,分歧照旧。

3. 六个解析器横向对照

# 汇总六个解析器对同一组 URL 的判断,结果写入 out/parsers.txt
bash run_parsers_v2.sh

六个解析器分成四类:

  1. Go 与 Java 直接拒绝(invalid userinfo、Illegal character in authority),对 userinfo 里的 \ 零容忍。
  2. Python 与 fast-uri 把 \ 当 userinfo 里的普通字符,解析出的主机名是 allowed.example.com。
  3. Node 原生 URL 把 \ 当成 /,主机名变成 127.0.0.1。
  4. curl 内部两个方向自相矛盾:方向 A 把主机判成 allowed.example.com,去解析它的 DNS(Could not resolve host);方向 B 把 allowed.example.com\ 整段当 userinfo 送进 Basic 认证头(Authorization: Basic YWxsb3dlZC5leGFtcGxlLmNvbVw6,解开即 allowed.example.com\:),然后真的连上了 127.0.0.1:9001。

同一个字符串里“谁被写进 Host 头、谁被连”,六份答案并不一致。

#!/usr/bin/env bash
# run_parsers_v2.sh —— 把六个解析器对同一组 URL 的理解汇总到一个文件里
# 输出:out/parsers.txt(同时打印到终端)
set -u
cd "$(dirname "$0")"
mkdir -p out
OUT=out/parsers.txt

# 回显服务(127.0.0.1:9001)未监听则后台拉起,保证 curl 有实际连接目标
if curl -s --max-time 2 http://127.0.0.1:9001/ | grep -q 'echo server 9001'; then
  echo "[i] 回显服务已在 127.0.0.1:9001 运行"
elif ss -ltn 2>/dev/null | grep -q ':9001 '; then
  echo "[!] 127.0.0.1:9001 已被其它进程占用,请先释放端口后重跑(ss -ltnp | grep 9001)"
  exit 1
else
  nohup node echo_host.js >/tmp/echo_host_9001.log 2>&1 &
  sleep 1
  curl -s --max-time 2 http://127.0.0.1:9001/ | grep -q 'echo server 9001' \
    || { echo "[!] 回显服务启动失败"; cat /tmp/echo_host_9001.log; exit 1; }
fi

PYV=$(python3 -c 'import sys;print("%d.%d" % sys.version_info[:2])')
NODEMAJOR=$(node -p 'process.versions.node.split(".")[0]')
GOV=$(go version | awk '{print $3}' | sed 's/^go//')
JAVAMAJOR=$(java -version 2>&1 | head -n 1 | sed -n 's/.*version "\([0-9][0-9]*\).*/\1/p')
CURLV=$(curl --version | head -n 1 | awk '{print $2}')

: > "$OUT"

printf '== Python %s urllib.parse ==\n' "$PYV" >> "$OUT"
python3 probe_host.py >> "$OUT" 2>&1
printf '\n== Node %s WHATWG URL + fast-uri 4.1.1 ==\n' "$NODEMAJOR" >> "$OUT"
node probe_host.js >> "$OUT" 2>&1
printf '\n== Go %s net/url ==\n' "$GOV" >> "$OUT"
go run probe_host.go >> "$OUT" 2>&1
printf '\n== Java %s java.net.URI ==\n' "$JAVAMAJOR" >> "$OUT"
java ProbeHost.java >> "$OUT" 2>&1
# curl 走两次真实连接:-g 关闭 curl 自身的通配符处理,-v 留下连接细节
printf '\n== curl %s actual dial, direction A: http://127.0.0.1:9001\\@allowed.example.com ==\n' "$CURLV" >> "$OUT"
curl -g -sS -v --max-time 6 "http://127.0.0.1:9001\@allowed.example.com" -o /dev/null >> "$OUT" 2>&1
printf '\n== curl %s actual dial, direction B: http://allowed.example.com\\@127.0.0.1:9001/ ==\n' "$CURLV" >> "$OUT"
curl -g -sS -v --max-time 6 "http://allowed.example.com\@127.0.0.1:9001/" -o /dev/null >> "$OUT" 2>&1

cat "$OUT"

4. 第二个 CVE:不写 // 也能进 authority

# 三个引用都用相对 base 解析,base = https://allowed.com/
node cve18446.js

ref 行里的每个反斜杠在 JSON 打印里出现两次,所以 ref="\\\\evil.com/path" 对应的实际输入是两个反斜杠。

同一个引用,Node 认为去了 evil.com,fast-uri 4.1.0/4.1.1 认为还留在 allowed.com(\\evil.com/path 被整段折进 path,编码后成了 %5C%5Cevil.com/path)。4.1.2 与 4.2.2 改为直接抛错。抛错点不在 parse(),而在 resolve()——同一个探针把两种调用都打了:

# 同一输入分别走 parse() 与 resolve(),并把异常栈打出来
node stack_probe.js

栈帧第一条即抛错位置,固定为 fasturi412/index.js:32 与 fasturi422/index.js:76;另有两条为探针自身与 Node 模块加载器的帧。

CVE-2026-18446 与 CVE-2026-16221 的性质不同:前者是引入符判定,后者是分隔符判定。4.1.1 只堵了后者,引入符检查到 4.1.2 才补上。

5. 打通验证:守卫写法、单请求与端到端

守卫 A 只看 host,守卫 B 同时看 host 与 error,对同一个 payload 的判定:

# 两种守卫写法 × 五个版本,对同一个 payload 判定
node guard_latest.js

payload 是 http://127.0.0.1:9001\@allowed.com,全部打到本机回显服务。

只要调用方只看 host,这些版本线的守卫全部放行,4.2.2 也不例外。这不是老版本独有的问题。

把原始字符串直接交给执行端 fetch,不读 error:

# 单请求打到本机回显服务 127.0.0.1:9011,看守卫放行后是否真的连上
node ssrf_latest.js

fast-uri 已经标了 error,但调用方没读,请求照旧打到 127.0.0.1:9011,并且把内网响应里的凭证带回了业务层。

一份带域名白名单的 Fastify 抓取接口,五种组合跑同一个攻击 URL:

# Fastify 真实路由:白名单校验之后由框架自己发请求
node fastify_e2e.js

攻击 URL 是 http://127.0.0.1:9002\@allowed.example.com,内网服务跑在 127.0.0.1:9002。三行 200 说明:升级到最新版并不会自动保住业务,是否读 error 才是分水岭。

跨版本、跨守卫的矩阵覆盖 6 个版本 × 3 种形态 × 2 种守卫:

# 6 个版本 × 3 种形态(字面反斜杠 / 全角反斜杠+NFKC / 全角斜杠+NFKC)× 2 种守卫写法
node ssrf_local.js

矩阵 36 行里命中 33 条,上面列的是 S1 段的 12 行。

S1 场景(字面反斜杠 U+005C)下:

  • 守卫 A 在 6 个版本上全部放行。
  • 守卫 B 从 2.4.3 / 3.1.4 / 4.1.1 起生效。3.1.3 那一行是例外,原因不在引入符补丁:S1 形态命中的是字面反斜杠检查,这条检查在 3.1.x 线上到 3.1.4 才引入(即 CVE-2026-16221 的修复版),所以 3.1.3 的 error 仍为空。3.1.x 线上的引入符修复版是 3.1.5,对应 S2/S3 形态(CVE-2026-18446)。

6. 补丁到底改了什么

# Kali 侧:npm tarball 与 GitHub raw 逐字节比对
bash patch_audit.sh

fast-uri 的 4.1.1 是 CVE-2026-16221 的修复版,新增的是这样一个检查(index.js:255):

  // A literal backslash (U+005C) is not a valid RFC 3986 URI character and is
  // not an authority delimiter. Reject it in the authority rather than
  // rewriting it: normalizing "\" -> "/" (WHATWG error recovery) could silently
  // change the resource identified by an otherwise-invalid input, and lets "\"
  // act as a host delimiter here while Node's native URL parses a different
  // host (SSRF / redirect / origin-allowlist bypass). Percent-encoded %5C is
  // untouched and remains valid encoded data.
  const authorityMatch = uri.match(AUTHORITY_PREFIX)
  if (authorityMatch !== null && authorityMatch[1].indexOf('\\') !== -1) {
    parsed.error = 'URI authority must not contain a literal backslash.'
    malformedAuthorityOrPort = true
  }

配套的正则(index.js:207)只负责切出 authority 那一段:

const AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/

4.1.2 是 CVE-2026-18446 的修复版,在同一个位置又加了一段,专门看“引入符”(使用处 index.js:288,配套正则定义在 index.js:221):

const AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/

  const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION)
  if (introducerMatch !== null) {
    const region = introducerMatch[1]
    const normalizedRegion = region.replace(/[\t\n\r]/g, '')
    // Two or more leading separators introduce an authority.
    if (normalizedRegion.length >= 2) {
      if (normalizedRegion.slice(0, 2) !== '//') {
        parsed.error = parsed.error || 'URI authority must not contain a literal backslash.'
        malformedAuthorityOrPort = true
      } else if (region.length !== normalizedRegion.length) {
        parsed.error = parsed.error || 'URI authority introducer must not contain whitespace.'
        malformedAuthorityOrPort = true
      }
    }
  }

4.2.2 保留了这两段检查:字面反斜杠检查在 index.js:468,引入符检查在 index.js:481;两版抛错点分别在 4.1.2 的 index.js:32 与 4.2.2 的 index.js:76。

关键设计在两处检查本身:两处检查命中之后都只是赋值 parsed.error,既不 return,也不修改 parsed.host。4.2.2 对字面反斜杠 host 依然是 "allowed.com",只在旁边多出一个 error。于是:

  • 只用 parse().host 判断的调用方(守卫 A):完全无感,行为与修复前一致。
  • 只有显式写 !p.error 的调用方(守卫 B):才拿到拦截能力。

而“要读 error”这件事既没有版本号上的强制,也没有类型系统的提示,parse() 更不会抛异常——漏读是完全静默的。

7. 补丁之后的残余绕过面

fast-uri 在 4.2.2 上对三种变体的判定,与字面反斜杠放在一起对比:

# 4.2.2 对六组输入的 host / error 判定
node latest_probe.js fasturi422

原因写在源码里:检查只用 indexOf('\\') 匹配 U+005C(index.js:476),上方的注释也明确写着 %5C 属于合法编码数据、不处理(index.js:468)。三个变体的码位出处:U+FF3C 全角反斜杠与 U+FF0F 全角斜杠同属 UFF00 区,U+FE68 小型反斜杠见 UFE50 区。

单斜杠那一行的差异顺带说明了两个 CVE 的边界:同一个 http:/\evil.com\@allowed.com,4.1.0/4.1.1 报 HTTP URIs must have a host.,4.2.2 报的是反斜杠错误——引入符补丁补上了这一格。

这些变体能不能真的打穿,取决于执行端有没有先做归一化:

# 全角变体先做 NFKC 归一化,再交给执行端
node ssrf_latest_nfkc.js

NFKC 是 Unicode 的兼容归一化,会把全角字符折叠成半角(Unicode 标准附件 15)。折叠之后 U+FF3C 变成 U+005C,Node 立刻按“反斜杠等于斜杠”的规则解析,落到 127.0.0.1:9011。这两行的 error 都是 null,守卫 B 也拦不住。

把原始全角字符串直接交给 Node,并不会被打穿:

# 不做归一化,直接把全角字符串交给 Node,作为对照
node uff3c_probe.js

没做归一化时,Node 认为 host 是 allowed.com,反斜杠留在 userinfo 里,执行端因为凭据里含非法字符直接抛错,内网服务零命中。所以这一条的成立条件是:链路上任何一环对 URL 做了 NFKC 归一化或百分号解码,而校验端读到的是归一化之前的字符串。

8. 影响面:依赖链与生态跟进

fastify 与 ajv 都挂在 fast-uri 上,依赖链嵌套:

# 列出 fast-uri 的依赖链,含嵌套层级
npm ls fast-uri

npm 官方下载量接口:

# 打 npm 官方下载量接口,取最近一周的数据
curl -s "https://api.npmjs.org/downloads/point/last-week/fast-uri"

接口字段说明见 npm 下载量文档。

一周 1.5 亿次下载。

消费方的跟进程度并不统一。ajv 的 uri 格式校验实测:

# 用 ajv 的 uri 格式校验三条 URL
node ajv_uri.js

ajv 依赖 fast-uri,但只用它做 schema $ref 的 URI 解析(dist/runtime/uri.js);format: uri 的校验实现来自 ajv-formats,它的依赖里没有 fast-uri:

# 读两个包的 package.json 依赖字段
node -e "for (const p of ['ajv-formats','ajv']) { const j=require(p+'/package.json'); console.log(p+'@'+j.version+' dependencies: '+JSON.stringify(j.dependencies)) }"

# 定位 ajv 运行时对 fast-uri 的引用行
grep -n 'fast-uri' node_modules/ajv/dist/runtime/uri.js

所以字面反斜杠被拒,起作用的不是 fast-uri 的版本,而是 ajv-formats 默认 mode: "full" 下 uri 用的是纯正则:fullFormats.uri 由 NOT_URI_FRAGMENT.test(str) && URI.test(str) 构成,\ 不在正则字符集内,直接被拒。三档模式的校验口径:

# 同一组 URL 在默认 / mode:fast / mode:full 三档下的校验结果
node verify_ajv.js

换成 mode: "fast",字面反斜杠三行全部放行(fastFormats.uri 的正则对 \ 不做限制);换回 mode: "full"(默认)又全部拒绝。同一个 ajv,开关一换结论就翻转:这一格与 fast-uri 打了几个补丁无关,是 ajv-formats 的 full 模式正则拒绝了字面反斜杠。真正与 fast-uri 边界一致的漏点是 %5C 那一行:三种模式都放行,正因为校验端做的是字符串正则,执行端做的是 URL 解析,两者对编码/归一化的处理并不一致。

9. 修复与加固建议

  1. 升级:升到各版本线最新(2.4.7 / 3.1.8 / 4.2.2),最低也要跨过两个补丁对应的 2.4.4 / 3.1.5 / 4.1.2,并把整条依赖链上的副本一起换掉(版本列表见 registry,源码见 GitHub 仓库)。升级只解决有没有 error 字段的问题。
  2. 改守卫写法:白名单判断必须同时看 error,并显式处理字段不存在的老版本情况:
function isAllowed (rawUrl) {
  let p
  try { p = fastUri.parse(rawUrl) } catch (e) { return false }
  if (p.error) return false          // 旧版本没有这个字段,undefined 不会在这里拦下,因此还要补归一化检查
  if (typeof p.host !== 'string') return false
  const host = p.host.normalize('NFKC')          // 先归一化,再比对
  if (/[\\/]/.test(host)) return false           // 归一化后残留的分隔符类字符一律拒绝
  return host === 'allowed.com'
}

顺序不能颠倒:先归一化,再比对;反过来做,全角变体会直接穿过。

  1. 校验与执行必须看到同一个字符串:把归一化(NFKC)与解码步骤收拢成一次,产出“最终用于请求的字符串”,再拿它做白名单校验。链路上任何一环偷偷改字符串,前面所有判断都会作废。
  2. 不要用字符串做主机白名单:真正连接前自己解析一次目标,取最终 IP,再按 IP 段过滤(拒绝回环、私网、链路本地、云元数据地址 —— 169.254.169.254 属于链路本地段 169.254.0.0/16,见 RFC 3927)。字符串层面的比较挡不住解析器分歧。
  3. 加回归用例:把 \、\\、/\、\/、%5C、U+FF3C、U+FE68、U+FF0F 以及它们的 NFKC 结果一起钉进测试;用例写“必须拦截”,不要写“主机等于某值”。

这两个 CVE 的修复方向是“标记可疑”而不是“拒绝可疑”,把“抬头看 error”的责任留给了每一个调用方;只要还有一行代码只比较 host,补丁就不算落地。

10. 相关资料

漏洞记录

源码与版本

规范与标准

依赖与生态