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:
parent
134aa073d5
commit
924017fe7b
@ -226,11 +226,27 @@ fn extract_host(url: &str) -> Result<String, String> {
|
||||
return Err("URL must include a host".to_string());
|
||||
}
|
||||
|
||||
// 拒绝 userinfo:RFC 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]/ 的 URL,authority 以 "[" 开头,
|
||||
// split(':') 会取到 "[",无法被 IpAddr::parse 解析,绕过 is_private_host。
|
||||
// 当前实现不支持 IPv6 host,直接拒绝。
|
||||
if authority.starts_with('[') {
|
||||
return Err("IPv6 hosts are not supported".to_string());
|
||||
}
|
||||
|
||||
let host = authority
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.trim_end_matches('.')
|
||||
.to_lowercase();
|
||||
|
||||
if host.is_empty() {
|
||||
@ -256,7 +272,12 @@ fn is_private_host(host: &str) -> bool {
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
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) => {
|
||||
v6.is_loopback() || v6.is_unspecified() || v6.is_multicast()
|
||||
@ -363,6 +384,55 @@ mod tests {
|
||||
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 loopback,authority 以 "[" 开头,
|
||||
// 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.1(loopback)。
|
||||
// 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]
|
||||
async fn test_extract_text_simple() {
|
||||
let tool = test_tool();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user