fix(web_fetch): 加固 SSRF 防护,对齐 http_request 实现

修复 extract_host 的 4 处 host 解析缺口,使 web_fetch 与
http_request 的内网拦截策略完全一致:

- 拒绝 userinfo(authority 中的 "@"):防止 http://evil.com@127.0.0.1/
  以 evil.com 作为表面 host 绕过检查,实际请求发往 127.0.0.1
- 拒绝 IPv6 字面量(authority 以 "[" 开头):防止 http://[::1]/
  被拆分为 "[" 导致 IpAddr::parse 失败而放行
- trim_end_matches('.') 规范化末尾点:防止 http://127.0.0.1./
  因标准库解析失败绕过检查(reqwest 视为 FQDN 解析为 loopback)
- is_private_host 补充 is_broadcast/is_multicast:拦截
  255.255.255.255 与 224.0.0.1 等广播/组播地址

新增 5 个针对性测试覆盖以上场景。
This commit is contained in:
oudecheng 2026-08-04 15:17:52 +08:00
parent 134aa073d5
commit 924017fe7b

View File

@ -226,11 +226,27 @@ fn extract_host(url: &str) -> Result<String, String> {
return Err("URL must include a host".to_string()); return Err("URL must include a host".to_string());
} }
// 拒绝 userinfoRFC 3986 规定 authority 可包含 [userinfo "@"] host。
// 形如 http://evil.com@127.0.0.1/ 的 URL"@" 前的 evil.com 是 userinfo
// 实际请求目标 host 是 127.0.0.1,但 split(':') 会取到 evil.com@127.0.0.1
// 导致 is_private_host 无法识别内网地址而放行。
if authority.contains('@') {
return Err("URL userinfo is not allowed".to_string());
}
// 拒绝 IPv6 字面量:形如 http://[::1]/ 的 URLauthority 以 "[" 开头,
// split(':') 会取到 "[",无法被 IpAddr::parse 解析,绕过 is_private_host。
// 当前实现不支持 IPv6 host直接拒绝。
if authority.starts_with('[') {
return Err("IPv6 hosts are not supported".to_string());
}
let host = authority let host = authority
.split(':') .split(':')
.next() .next()
.unwrap_or_default() .unwrap_or_default()
.trim() .trim()
.trim_end_matches('.')
.to_lowercase(); .to_lowercase();
if host.is_empty() { if host.is_empty() {
@ -256,7 +272,12 @@ fn is_private_host(host: &str) -> bool {
if let Ok(ip) = host.parse::<std::net::IpAddr>() { if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return match ip { return match ip {
std::net::IpAddr::V4(v4) => { std::net::IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_multicast()
} }
std::net::IpAddr::V6(v6) => { std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() v6.is_loopback() || v6.is_unspecified() || v6.is_multicast()
@ -363,6 +384,55 @@ mod tests {
assert!(result.unwrap_err().contains("whitespace")); assert!(result.unwrap_err().contains("whitespace"));
} }
#[tokio::test]
async fn test_validate_url_rejects_userinfo_ssrf() {
let tool = test_tool();
// http://evil.com@127.0.0.1/ 中 evil.com 是 userinfo
// 实际请求目标 host 是 127.0.0.1,必须被拒绝。
let result = tool.validate_url("http://evil.com@127.0.0.1/");
assert!(result.is_err());
assert!(result.unwrap_err().contains("userinfo"));
}
#[tokio::test]
async fn test_validate_url_rejects_ipv6_loopback_ssrf() {
let tool = test_tool();
// http://[::1]/ 是 IPv6 loopbackauthority 以 "[" 开头,
// split(':') 会取到 "[" 绕过 IpAddr::parse 检查,必须被拒绝。
let result = tool.validate_url("http://[::1]/");
assert!(result.is_err());
assert!(result.unwrap_err().contains("IPv6"));
}
#[tokio::test]
async fn test_validate_url_rejects_trailing_dot_ssrf() {
let tool = test_tool();
// http://127.0.0.1./ 中末尾的点使标准库 IpAddr::parse 失败,
// 但 reqwest 将其视为 FQDN 解析为 127.0.0.1loopback
// trim_end_matches('.') 去掉末尾点后可被识别为 loopback。
let result = tool.validate_url("http://127.0.0.1./");
assert!(result.is_err());
assert!(result.unwrap_err().contains("local/private"));
}
#[tokio::test]
async fn test_validate_url_rejects_broadcast_ssrf() {
let tool = test_tool();
// 255.255.255.255 是广播地址,可用于网络扫描,必须被拒绝。
let result = tool.validate_url("http://255.255.255.255/");
assert!(result.is_err());
assert!(result.unwrap_err().contains("local/private"));
}
#[tokio::test]
async fn test_validate_url_rejects_multicast_ssrf() {
let tool = test_tool();
// 224.0.0.1 是组播地址,必须被拒绝。
let result = tool.validate_url("http://224.0.0.1/");
assert!(result.is_err());
assert!(result.unwrap_err().contains("local/private"));
}
#[tokio::test] #[tokio::test]
async fn test_extract_text_simple() { async fn test_extract_text_simple() {
let tool = test_tool(); let tool = test_tool();