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());
|
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
|
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 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]
|
#[tokio::test]
|
||||||
async fn test_extract_text_simple() {
|
async fn test_extract_text_simple() {
|
||||||
let tool = test_tool();
|
let tool = test_tool();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user