一、现象
现象:http://127.0.0.1:8080/xxx 和 http://127.0.0.1:8080:8080/xxx , 两个地址发起 HTTP 请求,都调用成功。
环境:JDK 8 + Apache HttpClient 4.5.6(CloseableHttpClient.execute)。
线上一处用字符串拼出来的 URL,端口被重复拼接了一次,变成了 "host:8080:8080"。按直觉这是个非法地址,应该直接报错;但它的的确确调通了,返回还完全正常。 本着打破沙锅问到底的原则,开始认真分析这个问题,于是从"为什么不报错"变成了两个更具体的问题:
-
new URI("http://127.0.0.1:8080:8080/xxx") 为什么不抛异常?
-
请求到底发到哪儿去了?
二、第一层宽容:new URI() 不报错
先看 URI 语法。RFC 3986 里 authority 的定义:
ini
authority = [ userinfo "@" ] host [ ":" port ]
port = *DIGITS ← 端口必须是纯数字
"8080:8080" 显然不是合法端口。用 "new URI()" 解析时,JDK 内部按 server 语法解析 authority 失败,但它不抛异常,而是降级为 RFC 2396 时代的 "registry-based authority"------这是一种历史遗留的宽松形式,冒号允许出现在 authority 里。实测(JDK 8 与 JDK 21 行为一致):
csharp
URI uri = new URI("http://127.0.0.1:8080:8080/xxx");
System.out.println(uri.getHost()); // null ← 注意!
System.out.println(uri.getPort()); // -1
System.out.println(uri.getAuthority()); // 127.0.0.1:8080:8080
也就是说,构造出来的这个 URI 是一个没有 host、没有 port 的"空壳",原始 authority 字符串原样保存着。这是整个问题的起点:非法地址通过了第一道关卡,而且从 URI 对象上你拿不到任何有效的连接信息。
顺带一提,"URI" 提供了严格模式:"uri.parseServerAuthority()" 会在 authority 不符合 server 语法时立刻抛 "URISyntaxException"。只是默认构造函数不调用它。
三、第二层宽容:谁把这个地址"修好"了?
URI 对象里 host 是 null,那 HttpClient 是怎么发出请求的?用排除法实测一轮(每项都在本地起真实服务端验证过落点),真凶锁定:老版本 Apache HttpClient 的"URIUtils.extractHost()"。
"CloseableHttpClient.execute()"执行时,会先通过路由规划器调用 "URIUtils.extractHost(request.getURI())"提取目标主机。这个方法发现 "uri.getHost()"返回 null 后,进入了一段手写的兜底逻辑------源码注释原话非常直白:
ini
if (host == null) { // normal parse failed; let's do it ourselves
// authority does not seem to care about the valid character-set for host names
host = uri.getAuthority();
...
final int colon = host.indexOf(':'); // ① 找第一个冒号
if (colon >= 0) {
final int pos = colon + 1;
int len = 0;
for (int i = pos; i < host.length(); i++) {
if (Character.isDigit(host.charAt(i))) {
len++; // ② 只取冒号后的连续数字
} else {
break; // 遇到第二个冒号就停
}
}
if (len > 0) {
try {
port = Integer.parseInt(host.substring(pos, pos + len)); // ③ 8080
} catch (final NumberFormatException ex) { }
}
host = host.substring(0, colon); // ④ host = "127.0.0.1"
} // 尾巴上的 ":8080" 就此静默丢弃
}
...
target = new HttpHost(host, port, scheme); // → http://127.0.0.1:8080
对 "127.0.0.1:8080:8080"走一遍:第一个冒号后的连续数字是 "8080"(第二个冒号处停止),端口取 8080,host 取冒号前的 "127.0.0.1",剩下的 ":8080"从此无人问津。最终目标和正确写法完全一致------所以两个 URL"都能调通",本质上它们是同一个请求。
四、端到端实验:请求到底落在哪个端口
空口无凭,本机起了三个监听(8080 / 9999 / 80)分别验证(HttpClient 4.5.6):
结果清晰:第一个端口生效,其余丢弃。用两个不同的端口做实验才能看出来------这也是原案例里看不出来的原因:两个都是 8080,殊途同归。
五、版本边界:4.5.13 与 CVE-2020-13956
继续测版本边界,发现行为在 4.5.13 处突变:4.5.6 宽容,4.5.13 起一律抛 "ClientProtocolException: URI does not specify a valid host name"。
查了一下,这不是普通的 bugfix,而是 CVE-2020-13956(www.cve.org/CVERecord?i...) 的修复(Apache 官方通告(s.apache.org/lpvrx)):Htt...%25EF%25BC%2589%25EF%25BC%259AHttpClient "https://s.apache.org/lpvrx)%EF%BC%89%EF%BC%9AHttpClient") 4.5.13 / 5.0.3 之前的版本,会把畸形 authority 组件解析成错误的目标主机------也就是上面那段 "extractHost"兜底代码,典型的 SSRF 攻击面:如果请求 URL 来自外部输入,攻击者可以构造特殊 authority 让请求发到与代码校验结果不同的主机(有人专门分析了这个 CVE(priyankn.github.io/2021-02-26-...%25EF%25BC%2589%25E3%2580%2582 "https://priyankn.github.io/2021-02-26-CVE-2020-13956)%EF%BC%89%E3%80%82") 换句话说,"宽容"在这里不是feature而是漏洞,官方最终的选择是不再兜底、直接拒绝。
六、风险与修复
这个 bug 的危险之处恰恰在于它默认"成功":
- 改端口不生效:哪天地址写成 "host:8080:8081",请求照样发 8080。配置"改了但没生效"的幽灵问题,排查起来极其痛苦。
- 升级即炸:出于安全考虑迟早要升到 ≥4.5.13,届时所有这类畸形 URL 会从"正常工作"变成直接抛异常。升级前必须先清洗存量地址,否则升级瞬间一批调用报错。
- CVE-2020-13956 本身:URL 若来自外部输入,存在请求被导向非预期主机的风险。
修复建议(按优先级):
java
// 1. 治本:在拼接源头修掉重复端口,不要用字符串拼 URL
// (这类 bug 的典型成因就是 "http://" + host + ":" + port 拼了两次端口)
// 2. 快速失败:构造后强制按 server 语法校验,畸形地址立刻抛 URISyntaxException
URI uri = new URI(url);
uri.parseServerAuthority();
// 3. 或者至少检查 host 是否解析成功
if (uri.getHost() == null) {
throw new IllegalArgumentException("invalid url: " + url);
}
更工程化的做法是用 "URIBuilder"显式 "setHost() / setPort()",从构造上杜绝畸形。