diff --git a/src/openhuman/skills/ops_install.rs b/src/openhuman/skills/ops_install.rs index 8aea333da2..84d38b52fe 100644 --- a/src/openhuman/skills/ops_install.rs +++ b/src/openhuman/skills/ops_install.rs @@ -1,6 +1,5 @@ //! URL-based skill installation: fetch, validate, and write SKILL.md from a remote URL. -use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::Path; use serde::{Deserialize, Serialize}; @@ -815,7 +814,7 @@ pub async fn validate_resolved_host(raw_url: &str) -> Result<(), String> { let ip = addr.ip(); match ip { std::net::IpAddr::V4(v4) => { - if is_private_v4(&v4) { + if crate::openhuman::tools::is_non_global_v4(v4) { tracing::warn!( host = %host, resolved = %v4, @@ -827,7 +826,7 @@ pub async fn validate_resolved_host(raw_url: &str) -> Result<(), String> { } } std::net::IpAddr::V6(v6) => { - if is_private_v6(&v6) { + if crate::openhuman::tools::is_non_global_v6(v6) { tracing::warn!( host = %host, resolved = %v6, @@ -844,61 +843,7 @@ pub async fn validate_resolved_host(raw_url: &str) -> Result<(), String> { } fn is_blocked_install_host(host: &str) -> bool { - let lower = host.to_ascii_lowercase(); - // url::Url::host_str returns IPv6 literals wrapped in brackets (e.g. "[::1]"). - // Strip them before attempting Ipv6Addr parse. - let stripped = lower - .strip_prefix('[') - .and_then(|s| s.strip_suffix(']')) - .unwrap_or(&lower); - if stripped == "localhost" || stripped.ends_with(".localhost") || stripped.ends_with(".local") { - return true; - } - if let Ok(v4) = stripped.parse::() { - return is_private_v4(&v4); - } - if let Ok(v6) = stripped.parse::() { - return is_private_v6(&v6); - } - false -} - -fn is_private_v4(ip: &Ipv4Addr) -> bool { - if ip.is_private() - || ip.is_loopback() - || ip.is_link_local() - || ip.is_broadcast() - || ip.is_unspecified() - || ip.is_multicast() - { - return true; - } - let [a, b, _, _] = ip.octets(); - // 100.64.0.0/10 shared address (CGN) - if a == 100 && (64..=127).contains(&b) { - return true; - } - // 0.0.0.0/8 - if a == 0 { - return true; - } - false -} - -fn is_private_v6(ip: &Ipv6Addr) -> bool { - if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() { - return true; - } - let first = ip.segments()[0]; - // fc00::/7 unique-local - if (first & 0xfe00) == 0xfc00 { - return true; - } - // fe80::/10 link-local - if (first & 0xffc0) == 0xfe80 { - return true; - } - false + crate::openhuman::tools::is_private_or_local_host(host) } #[cfg(test)] diff --git a/src/openhuman/tools/impl/network/mod.rs b/src/openhuman/tools/impl/network/mod.rs index a7f4b030b8..26f99f3f40 100644 --- a/src/openhuman/tools/impl/network/mod.rs +++ b/src/openhuman/tools/impl/network/mod.rs @@ -11,7 +11,7 @@ mod http_request; mod mcp; #[cfg(feature = "mcp")] mod mcp_setup; -mod url_guard; +pub mod url_guard; mod web_fetch; pub use curl::CurlTool; @@ -25,6 +25,11 @@ pub use mcp_setup::{ McpSetupGetTool, McpSetupInstallAndConnectTool, McpSetupRequestSecretTool, McpSetupSearchTool, McpSetupTestConnectionTool, }; +pub use url_guard::{ + extract_host, extract_port, host_matches_allowlist, is_non_global_v4, is_non_global_v6, + is_private_or_local_host, normalize_allowed_domains, normalize_domain, validate_url, + validate_url_with_dns_check, +}; pub use web_fetch::WebFetchTool; /// Shared test helper for the network tools' local-only enforcement tests diff --git a/src/openhuman/tools/impl/network/url_guard.rs b/src/openhuman/tools/impl/network/url_guard.rs index 47d0c88371..eb051114f9 100644 --- a/src/openhuman/tools/impl/network/url_guard.rs +++ b/src/openhuman/tools/impl/network/url_guard.rs @@ -31,7 +31,7 @@ use std::net::{IpAddr, ToSocketAddrs}; /// Validate a URL against the allowlist + SSRF rules. Returns the /// original URL on success. -pub(super) fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow::Result { +pub fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow::Result { let url = raw_url.trim(); if url.is_empty() { @@ -97,7 +97,7 @@ pub(super) fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow: /// /// Callers should use this function instead of `validate_url` in all /// paths that make outbound HTTP requests. -pub(super) async fn validate_url_with_dns_check( +pub async fn validate_url_with_dns_check( raw_url: &str, allowed_domains: &[String], ) -> anyhow::Result { @@ -164,7 +164,7 @@ async fn resolve_host_ips(host: String, port: u16) -> anyhow::Result })? } -pub(super) fn normalize_allowed_domains(domains: Vec) -> Vec { +pub fn normalize_allowed_domains(domains: Vec) -> Vec { if domains.is_empty() { return Vec::new(); } @@ -188,7 +188,7 @@ pub(super) fn normalize_allowed_domains(domains: Vec) -> Vec { normalized } -pub(super) fn normalize_domain(raw: &str) -> Option { +pub fn normalize_domain(raw: &str) -> Option { let mut d = raw.trim().to_lowercase(); if d.is_empty() { return None; @@ -217,7 +217,7 @@ pub(super) fn normalize_domain(raw: &str) -> Option { Some(d) } -pub(super) fn extract_host(url: &str) -> anyhow::Result { +pub fn extract_host(url: &str) -> anyhow::Result { let rest = url .strip_prefix("http://") .or_else(|| url.strip_prefix("https://")) @@ -255,7 +255,7 @@ pub(super) fn extract_host(url: &str) -> anyhow::Result { Ok(host) } -fn extract_port(url: &str) -> anyhow::Result { +pub fn extract_port(url: &str) -> anyhow::Result { let is_http = url.starts_with("http://"); let rest = url .strip_prefix("http://") @@ -283,7 +283,7 @@ fn extract_port(url: &str) -> anyhow::Result { Ok(if is_http { 80 } else { 443 }) } -pub(super) fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { +pub fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { allowed_domains.iter().any(|domain| { // `"*"` is the explicit allow-all wildcard (the "Allow all sites" // toggle), mirroring the browser tool. Local/private hosts are still @@ -297,7 +297,7 @@ pub(super) fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> }) } -pub(super) fn is_private_or_local_host(host: &str) -> bool { +pub fn is_private_or_local_host(host: &str) -> bool { let bare = host .strip_prefix('[') .and_then(|h| h.strip_suffix(']')) @@ -322,7 +322,7 @@ pub(super) fn is_private_or_local_host(host: &str) -> bool { false } -fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { +pub fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { let [a, b, c, _] = v4.octets(); v4.is_loopback() || v4.is_private() @@ -338,7 +338,7 @@ fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { || (a == 198 && (18..=19).contains(&b)) } -fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { +pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { let segs = v6.segments(); v6.is_loopback() || v6.is_unspecified() @@ -839,4 +839,45 @@ mod tests { .to_string(); assert!(err.contains("local/private"), "got: {err}"); } + + #[test] + fn exported_ssrf_predicates_classify_non_global_ips_accurately() { + use std::net::{Ipv4Addr, Ipv6Addr}; + + // IPv4 Non-global checks + assert!(is_non_global_v4(Ipv4Addr::new(127, 0, 0, 1))); + assert!(is_non_global_v4(Ipv4Addr::new(10, 0, 0, 1))); + assert!(is_non_global_v4(Ipv4Addr::new(172, 16, 0, 1))); + assert!(is_non_global_v4(Ipv4Addr::new(192, 168, 1, 1))); + assert!(is_non_global_v4(Ipv4Addr::new(169, 254, 1, 1))); + assert!(is_non_global_v4(Ipv4Addr::new(100, 64, 0, 1))); // CGNAT + assert!(is_non_global_v4(Ipv4Addr::new(240, 0, 0, 1))); // Class E + assert!(is_non_global_v4(Ipv4Addr::new(192, 0, 2, 1))); // TEST-NET-1 + assert!(is_non_global_v4(Ipv4Addr::new(198, 51, 100, 1))); // TEST-NET-2 + assert!(is_non_global_v4(Ipv4Addr::new(203, 0, 113, 1))); // TEST-NET-3 + + // IPv4 Global public IPs + assert!(!is_non_global_v4(Ipv4Addr::new(8, 8, 8, 8))); + assert!(!is_non_global_v4(Ipv4Addr::new(1, 1, 1, 1))); + assert!(!is_non_global_v4(Ipv4Addr::new(140, 82, 121, 4))); + + // IPv6 Non-global checks + assert!(is_non_global_v6(Ipv6Addr::LOCALHOST)); + assert!(is_non_global_v6(Ipv6Addr::UNSPECIFIED)); + assert!(is_non_global_v6("fc00::1".parse().unwrap())); + assert!(is_non_global_v6("fe80::1".parse().unwrap())); + assert!(is_non_global_v6("2001:db8::1".parse().unwrap())); + + // IPv6 Global public IPs + assert!(!is_non_global_v6("2606:4700:4700::1111".parse().unwrap())); + + // Host helper checks + assert!(is_private_or_local_host("localhost")); + assert!(is_private_or_local_host("my-service.localhost")); + assert!(is_private_or_local_host("device.local")); + assert!(is_private_or_local_host("127.0.0.1")); + assert!(is_private_or_local_host("[::1]")); + assert!(!is_private_or_local_host("github.com")); + assert!(!is_private_or_local_host("api.openai.com")); + } }