diff --git a/src/tools/web_fetch.rs b/src/tools/web_fetch.rs index f78d80a..dec75b4 100644 --- a/src/tools/web_fetch.rs +++ b/src/tools/web_fetch.rs @@ -226,11 +226,27 @@ fn extract_host(url: &str) -> Result { 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::() { 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();