Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 3 additions & 58 deletions src/openhuman/skills/ops_install.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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::<Ipv4Addr>() {
return is_private_v4(&v4);
}
if let Ok(v6) = stripped.parse::<Ipv6Addr>() {
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)]
Expand Down
7 changes: 6 additions & 1 deletion src/openhuman/tools/impl/network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
61 changes: 51 additions & 10 deletions src/openhuman/tools/impl/network/url_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
pub fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow::Result<String> {
let url = raw_url.trim();

if url.is_empty() {
Expand Down Expand Up @@ -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<String> {
Expand Down Expand Up @@ -164,7 +164,7 @@ async fn resolve_host_ips(host: String, port: u16) -> anyhow::Result<Vec<IpAddr>
})?
}

pub(super) fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> {
pub fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> {
if domains.is_empty() {
return Vec::new();
}
Expand All @@ -188,7 +188,7 @@ pub(super) fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> {
normalized
}

pub(super) fn normalize_domain(raw: &str) -> Option<String> {
pub fn normalize_domain(raw: &str) -> Option<String> {
let mut d = raw.trim().to_lowercase();
if d.is_empty() {
return None;
Expand Down Expand Up @@ -217,7 +217,7 @@ pub(super) fn normalize_domain(raw: &str) -> Option<String> {
Some(d)
}

pub(super) fn extract_host(url: &str) -> anyhow::Result<String> {
pub fn extract_host(url: &str) -> anyhow::Result<String> {
let rest = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
Expand Down Expand Up @@ -255,7 +255,7 @@ pub(super) fn extract_host(url: &str) -> anyhow::Result<String> {
Ok(host)
}

fn extract_port(url: &str) -> anyhow::Result<u16> {
pub fn extract_port(url: &str) -> anyhow::Result<u16> {
let is_http = url.starts_with("http://");
let rest = url
.strip_prefix("http://")
Expand Down Expand Up @@ -283,7 +283,7 @@ fn extract_port(url: &str) -> anyhow::Result<u16> {
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
Expand All @@ -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(']'))
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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"));
}
}
Loading