一个反斜杠击穿主机白名单:fast-uri 双 CVE 的成因、复现与补丁审计
fast-uri 把反斜杠当普通字符,Node 原生 URL 把它当路径分隔符。同一个 URL 在检测端解析出主机 allowed.com,在请求时却连到 127.0.0.1,CVE-2026-16221 与 CVE-2026-18446 都出在这个分歧上。
fast-uri把反斜杠当普通字符,Node 原生URL把它当路径分隔符。同一个 URL 在检测端解析出主机allowed.com,在请求时却连到127.0.0.1,CVE-2026-16221 与 CVE-2026-18446 都出在这个分歧上。
0. 两个 CVE 与影响版本
编号 | 触发形态 | 受影响版本 | 修复版本 |
字面反斜杠 |
|
| |
不要求字面 |
|
|
两个通告在 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)。
- authority:URL 里
//之后、路径之前的那一段,装着主机和端口(RFC 3986 §3.2)。 - userinfo:authority 里
@之前的那一段(RFC 3986 §3.2.1),是登录凭据位置。 - WHATWG URL:浏览器与 Node 共用的 URL 解析规范(规范正文,Node 实现说明)。它在解析时会做“错误恢复”:把
\(U+005C)当作/(U+002F),这条规则写在规范的 special-authority-ignore-slashes-state 状态 里。
分歧由此产生:校验用
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

六个解析器分成四类:
- Go 与 Java 直接拒绝(
invalid userinfo、Illegal character in authority),对 userinfo 里的\零容忍。 - Python 与
fast-uri把\当 userinfo 里的普通字符,解析出的主机名是allowed.example.com。 - Node 原生 URL 把
\当成/,主机名变成127.0.0.1。 - 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. 修复与加固建议
- 升级:升到各版本线最新(
2.4.7/3.1.8/4.2.2),最低也要跨过两个补丁对应的2.4.4/3.1.5/4.1.2,并把整条依赖链上的副本一起换掉(版本列表见 registry,源码见 GitHub 仓库)。升级只解决有没有error字段的问题。 - 改守卫写法:白名单判断必须同时看
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'
}顺序不能颠倒:先归一化,再比对;反过来做,全角变体会直接穿过。
- 校验与执行必须看到同一个字符串:把归一化(NFKC)与解码步骤收拢成一次,产出“最终用于请求的字符串”,再拿它做白名单校验。链路上任何一环偷偷改字符串,前面所有判断都会作废。
- 不要用字符串做主机白名单:真正连接前自己解析一次目标,取最终 IP,再按 IP 段过滤(拒绝回环、私网、链路本地、云元数据地址 ——
169.254.169.254属于链路本地段169.254.0.0/16,见 RFC 3927)。字符串层面的比较挡不住解析器分歧。 - 加回归用例:把
\、\\、/\、\/、%5C、U+FF3C、U+FE68、U+FF0F以及它们的 NFKC 结果一起钉进测试;用例写“必须拦截”,不要写“主机等于某值”。
这两个 CVE 的修复方向是“标记可疑”而不是“拒绝可疑”,把“抬头看 error”的责任留给了每一个调用方;只要还有一行代码只比较 host,补丁就不算落地。
10. 相关资料
漏洞记录
- CVE-2026-16221:GHSA-v2hh-gcrm-f6hx、NVD、CVE 记录、OSV
- CVE-2026-18446:GHSA-7p8r-x3mc-p8w7、NVD、CVE 记录、OSV
- CWE-436 解释冲突、CWE-918 服务端请求伪造
源码与版本
- fast-uri 仓库、发布记录、registry 版本清单
- 4.1.1:index.js#L207
AUTHORITY_PREFIX正则;index.js#L255 字面反斜杠检查 - 4.1.2:index.js#L221
AUTHORITY_INTRODUCER_REGION正则;index.js#L288 引入符匹配;index.js#L32resolve()抛错 - 4.2.2:index.js#L468、#L476 字面反斜杠检查;#L481 引入符检查;#L76
resolve()抛错
规范与标准
- RFC 3986 §3.2 authority、§3.2.1 userinfo
- RFC 3927 链路本地地址
- WHATWG URL 标准:basic URL parser、host parsing、special-authority-ignore-slashes-state
- Node.js URL 文档
- Unicode TR15 归一化、TR39 易混字符;码表 U+FF00 半角及全角形式、U+FE50 小型变体
依赖与生态