From 924017fe7b7b4d279cf0010e35921ec844133338 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Tue, 4 Aug 2026 15:17:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(web=5Ffetch):=20=E5=8A=A0=E5=9B=BA=20SSRF?= =?UTF-8?q?=20=E9=98=B2=E6=8A=A4=EF=BC=8C=E5=AF=B9=E9=BD=90=20http=5Freque?= =?UTF-8?q?st=20=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 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 个针对性测试覆盖以上场景。 --- src/tools/web_fetch.rs | 72 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) 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();