Compare commits
2 Commits
master
...
feat/defau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91a858a31a | ||
|
|
9067da5a16 |
@ -834,6 +834,10 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_wildcard_domains() -> Vec<String> {
|
||||||
|
vec!["*".into()]
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for GatewayConfig {
|
impl Default for GatewayConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -958,10 +962,10 @@ impl Default for BrowserComputerUseConfig {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct BrowserConfig {
|
pub struct BrowserConfig {
|
||||||
/// Enable `browser_open` tool (opens URLs in the system browser without scraping)
|
/// Enable `browser_open` tool (opens URLs in the system browser without scraping)
|
||||||
#[serde(default)]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Allowed domains for `browser_open` (exact or subdomain match)
|
/// Allowed domains for `browser_open` (exact or subdomain match)
|
||||||
#[serde(default)]
|
#[serde(default = "default_wildcard_domains")]
|
||||||
pub allowed_domains: Vec<String>,
|
pub allowed_domains: Vec<String>,
|
||||||
/// Browser session name (for agent-browser automation)
|
/// Browser session name (for agent-browser automation)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -994,8 +998,8 @@ fn default_browser_webdriver_url() -> String {
|
|||||||
impl Default for BrowserConfig {
|
impl Default for BrowserConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
allowed_domains: Vec::new(),
|
allowed_domains: vec!["*".into()],
|
||||||
session_name: None,
|
session_name: None,
|
||||||
backend: default_browser_backend(),
|
backend: default_browser_backend(),
|
||||||
native_headless: default_true(),
|
native_headless: default_true(),
|
||||||
@ -1014,10 +1018,10 @@ impl Default for BrowserConfig {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct HttpRequestConfig {
|
pub struct HttpRequestConfig {
|
||||||
/// Enable `http_request` tool for API interactions
|
/// Enable `http_request` tool for API interactions
|
||||||
#[serde(default)]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Allowed domains for HTTP requests (exact or subdomain match)
|
/// Allowed domains for HTTP requests (exact or subdomain match)
|
||||||
#[serde(default)]
|
#[serde(default = "default_wildcard_domains")]
|
||||||
pub allowed_domains: Vec<String>,
|
pub allowed_domains: Vec<String>,
|
||||||
/// Maximum response size in bytes (default: 1MB, 0 = unlimited)
|
/// Maximum response size in bytes (default: 1MB, 0 = unlimited)
|
||||||
#[serde(default = "default_http_max_response_size")]
|
#[serde(default = "default_http_max_response_size")]
|
||||||
@ -1030,8 +1034,8 @@ pub struct HttpRequestConfig {
|
|||||||
impl Default for HttpRequestConfig {
|
impl Default for HttpRequestConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
allowed_domains: vec![],
|
allowed_domains: vec!["*".into()],
|
||||||
max_response_size: default_http_max_response_size(),
|
max_response_size: default_http_max_response_size(),
|
||||||
timeout_secs: default_http_timeout_secs(),
|
timeout_secs: default_http_timeout_secs(),
|
||||||
}
|
}
|
||||||
@ -1057,7 +1061,7 @@ fn default_http_timeout_secs() -> u64 {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct WebFetchConfig {
|
pub struct WebFetchConfig {
|
||||||
/// Enable `web_fetch` tool for fetching web page content
|
/// Enable `web_fetch` tool for fetching web page content
|
||||||
#[serde(default)]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
|
/// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -1084,7 +1088,7 @@ fn default_web_fetch_timeout_secs() -> u64 {
|
|||||||
impl Default for WebFetchConfig {
|
impl Default for WebFetchConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
allowed_domains: vec!["*".into()],
|
allowed_domains: vec!["*".into()],
|
||||||
blocked_domains: vec![],
|
blocked_domains: vec![],
|
||||||
max_response_size: default_web_fetch_max_response_size(),
|
max_response_size: default_web_fetch_max_response_size(),
|
||||||
@ -1099,7 +1103,7 @@ impl Default for WebFetchConfig {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct WebSearchConfig {
|
pub struct WebSearchConfig {
|
||||||
/// Enable `web_search_tool` for web searches
|
/// Enable `web_search_tool` for web searches
|
||||||
#[serde(default)]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Search provider: "duckduckgo" (free, no API key) or "brave" (requires API key)
|
/// Search provider: "duckduckgo" (free, no API key) or "brave" (requires API key)
|
||||||
#[serde(default = "default_web_search_provider")]
|
#[serde(default = "default_web_search_provider")]
|
||||||
@ -1130,7 +1134,7 @@ fn default_web_search_timeout_secs() -> u64 {
|
|||||||
impl Default for WebSearchConfig {
|
impl Default for WebSearchConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
provider: default_web_search_provider(),
|
provider: default_web_search_provider(),
|
||||||
brave_api_key: None,
|
brave_api_key: None,
|
||||||
max_results: default_web_search_max_results(),
|
max_results: default_web_search_max_results(),
|
||||||
@ -3260,6 +3264,16 @@ pub struct SecurityConfig {
|
|||||||
/// Emergency-stop state machine configuration.
|
/// Emergency-stop state machine configuration.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub estop: EstopConfig,
|
pub estop: EstopConfig,
|
||||||
|
|
||||||
|
/// Allow tools to access localhost, private IPs (10.x, 192.168.x, etc.), and file:// URLs.
|
||||||
|
/// Set to false to block local/private network access and file:// URLs.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub allow_local_network: bool,
|
||||||
|
|
||||||
|
/// Allow tools to access public internet hosts.
|
||||||
|
/// Set to false to block all public internet access from tools.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub allow_public_internet: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OTP validation strategy.
|
/// OTP validation strategy.
|
||||||
@ -4852,8 +4866,8 @@ mod tests {
|
|||||||
let cfg = HttpRequestConfig::default();
|
let cfg = HttpRequestConfig::default();
|
||||||
assert_eq!(cfg.timeout_secs, 30);
|
assert_eq!(cfg.timeout_secs, 30);
|
||||||
assert_eq!(cfg.max_response_size, 1_000_000);
|
assert_eq!(cfg.max_response_size, 1_000_000);
|
||||||
assert!(!cfg.enabled);
|
assert!(cfg.enabled);
|
||||||
assert!(cfg.allowed_domains.is_empty());
|
assert_eq!(cfg.allowed_domains, vec!["*"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -6154,15 +6168,15 @@ default_temperature = 0.7
|
|||||||
assert!(!c.composio.enabled);
|
assert!(!c.composio.enabled);
|
||||||
assert!(c.composio.api_key.is_none());
|
assert!(c.composio.api_key.is_none());
|
||||||
assert!(c.secrets.encrypt);
|
assert!(c.secrets.encrypt);
|
||||||
assert!(!c.browser.enabled);
|
assert!(c.browser.enabled);
|
||||||
assert!(c.browser.allowed_domains.is_empty());
|
assert_eq!(c.browser.allowed_domains, vec!["*"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
async fn browser_config_default_disabled() {
|
async fn browser_config_default_enabled() {
|
||||||
let b = BrowserConfig::default();
|
let b = BrowserConfig::default();
|
||||||
assert!(!b.enabled);
|
assert!(b.enabled);
|
||||||
assert!(b.allowed_domains.is_empty());
|
assert_eq!(b.allowed_domains, vec!["*"]);
|
||||||
assert_eq!(b.backend, "agent_browser");
|
assert_eq!(b.backend, "agent_browser");
|
||||||
assert!(b.native_headless);
|
assert!(b.native_headless);
|
||||||
assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
|
assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
|
||||||
@ -6227,8 +6241,8 @@ config_path = "/tmp/config.toml"
|
|||||||
default_temperature = 0.7
|
default_temperature = 0.7
|
||||||
"#;
|
"#;
|
||||||
let parsed: Config = toml::from_str(minimal).unwrap();
|
let parsed: Config = toml::from_str(minimal).unwrap();
|
||||||
assert!(!parsed.browser.enabled);
|
assert!(parsed.browser.enabled);
|
||||||
assert!(parsed.browser.allowed_domains.is_empty());
|
assert_eq!(parsed.browser.allowed_domains, vec!["*"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Environment variable overrides (Docker support) ─────────
|
// ── Environment variable overrides (Docker support) ─────────
|
||||||
|
|||||||
@ -67,6 +67,7 @@ pub struct BrowserTool {
|
|||||||
native_webdriver_url: String,
|
native_webdriver_url: String,
|
||||||
native_chrome_path: Option<String>,
|
native_chrome_path: Option<String>,
|
||||||
computer_use: ComputerUseConfig,
|
computer_use: ComputerUseConfig,
|
||||||
|
net_policy: super::NetworkAccessPolicy,
|
||||||
#[cfg(feature = "browser-native")]
|
#[cfg(feature = "browser-native")]
|
||||||
native_state: tokio::sync::Mutex<native_backend::NativeBrowserState>,
|
native_state: tokio::sync::Mutex<native_backend::NativeBrowserState>,
|
||||||
}
|
}
|
||||||
@ -211,6 +212,7 @@ impl BrowserTool {
|
|||||||
"http://127.0.0.1:9515".into(),
|
"http://127.0.0.1:9515".into(),
|
||||||
None,
|
None,
|
||||||
ComputerUseConfig::default(),
|
ComputerUseConfig::default(),
|
||||||
|
super::NetworkAccessPolicy::default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,6 +226,7 @@ impl BrowserTool {
|
|||||||
native_webdriver_url: String,
|
native_webdriver_url: String,
|
||||||
native_chrome_path: Option<String>,
|
native_chrome_path: Option<String>,
|
||||||
computer_use: ComputerUseConfig,
|
computer_use: ComputerUseConfig,
|
||||||
|
net_policy: super::NetworkAccessPolicy,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
security,
|
security,
|
||||||
@ -234,6 +237,7 @@ impl BrowserTool {
|
|||||||
native_webdriver_url,
|
native_webdriver_url,
|
||||||
native_chrome_path,
|
native_chrome_path,
|
||||||
computer_use,
|
computer_use,
|
||||||
|
net_policy,
|
||||||
#[cfg(feature = "browser-native")]
|
#[cfg(feature = "browser-native")]
|
||||||
native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()),
|
native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()),
|
||||||
}
|
}
|
||||||
@ -406,14 +410,8 @@ impl BrowserTool {
|
|||||||
anyhow::bail!("URL cannot be empty");
|
anyhow::bail!("URL cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block file:// URLs — browser file access bypasses all SSRF and
|
if !url.starts_with("https://") && !url.starts_with("http://") && !url.starts_with("file://") {
|
||||||
// domain-allowlist controls and can exfiltrate arbitrary local files.
|
anyhow::bail!("Only http://, https://, and file:// URLs are allowed");
|
||||||
if url.starts_with("file://") {
|
|
||||||
anyhow::bail!("file:// URLs are not allowed in browser automation");
|
|
||||||
}
|
|
||||||
|
|
||||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
|
||||||
anyhow::bail!("Only http:// and https:// URLs are allowed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.allowed_domains.is_empty() {
|
if self.allowed_domains.is_empty() {
|
||||||
@ -423,10 +421,23 @@ impl BrowserTool {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// file:// URLs require local network access
|
||||||
|
if url.starts_with("file://") {
|
||||||
|
if !self.net_policy.allow_local_network {
|
||||||
|
anyhow::bail!("file:// URLs are blocked (security.allow_local_network = false)");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let host = extract_host(url)?;
|
let host = extract_host(url)?;
|
||||||
|
|
||||||
|
// Apply network access policy
|
||||||
if is_private_host(&host) {
|
if is_private_host(&host) {
|
||||||
anyhow::bail!("Blocked local/private host: {host}");
|
if !self.net_policy.allow_local_network {
|
||||||
|
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
|
||||||
|
}
|
||||||
|
} else if !self.net_policy.allow_public_internet {
|
||||||
|
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !host_matches_allowlist(&host, &self.allowed_domains) {
|
if !host_matches_allowlist(&host, &self.allowed_domains) {
|
||||||
@ -2218,14 +2229,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_url_blocks_ipv6_ssrf() {
|
fn validate_url_allows_ipv6_with_wildcard() {
|
||||||
let security = Arc::new(SecurityPolicy::default());
|
let security = Arc::new(SecurityPolicy::default());
|
||||||
let tool = BrowserTool::new(security, vec!["*".into()], None);
|
let tool = BrowserTool::new(security, vec!["*".into()], None);
|
||||||
assert!(tool.validate_url("https://[::1]/").is_err());
|
assert!(tool.validate_url("https://[::1]/").is_ok());
|
||||||
assert!(tool.validate_url("https://[::ffff:127.0.0.1]/").is_err());
|
assert!(tool.validate_url("https://[::ffff:127.0.0.1]/").is_ok());
|
||||||
assert!(tool
|
assert!(tool
|
||||||
.validate_url("https://[::ffff:10.0.0.1]:8080/")
|
.validate_url("https://[::ffff:10.0.0.1]:8080/")
|
||||||
.is_err());
|
.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -2298,6 +2309,7 @@ mod tests {
|
|||||||
"http://127.0.0.1:9515".into(),
|
"http://127.0.0.1:9515".into(),
|
||||||
None,
|
None,
|
||||||
ComputerUseConfig::default(),
|
ComputerUseConfig::default(),
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto);
|
assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto);
|
||||||
}
|
}
|
||||||
@ -2314,6 +2326,7 @@ mod tests {
|
|||||||
"http://127.0.0.1:9515".into(),
|
"http://127.0.0.1:9515".into(),
|
||||||
None,
|
None,
|
||||||
ComputerUseConfig::default(),
|
ComputerUseConfig::default(),
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.configured_backend().unwrap(),
|
tool.configured_backend().unwrap(),
|
||||||
@ -2336,6 +2349,7 @@ mod tests {
|
|||||||
endpoint: "http://computer-use.example.com/v1/actions".into(),
|
endpoint: "http://computer-use.example.com/v1/actions".into(),
|
||||||
..ComputerUseConfig::default()
|
..ComputerUseConfig::default()
|
||||||
},
|
},
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(tool.computer_use_endpoint_url().is_err());
|
assert!(tool.computer_use_endpoint_url().is_err());
|
||||||
@ -2357,6 +2371,7 @@ mod tests {
|
|||||||
allow_remote_endpoint: true,
|
allow_remote_endpoint: true,
|
||||||
..ComputerUseConfig::default()
|
..ComputerUseConfig::default()
|
||||||
},
|
},
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(tool.computer_use_endpoint_url().is_ok());
|
assert!(tool.computer_use_endpoint_url().is_ok());
|
||||||
@ -2378,6 +2393,7 @@ mod tests {
|
|||||||
max_coordinate_y: Some(100),
|
max_coordinate_y: Some(100),
|
||||||
..ComputerUseConfig::default()
|
..ComputerUseConfig::default()
|
||||||
},
|
},
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(tool
|
assert!(tool
|
||||||
@ -2410,15 +2426,15 @@ mod tests {
|
|||||||
// Invalid - not in allowlist
|
// Invalid - not in allowlist
|
||||||
assert!(tool.validate_url("https://other.com").is_err());
|
assert!(tool.validate_url("https://other.com").is_err());
|
||||||
|
|
||||||
// Invalid - private host
|
// localhost/private hosts are allowed (not in allowlist though)
|
||||||
assert!(tool.validate_url("https://localhost").is_err());
|
assert!(tool.validate_url("https://localhost").is_err()); // not in allowlist
|
||||||
assert!(tool.validate_url("https://127.0.0.1").is_err());
|
assert!(tool.validate_url("https://127.0.0.1").is_err()); // not in allowlist
|
||||||
|
|
||||||
// Invalid - not https
|
// Invalid - unsupported scheme
|
||||||
assert!(tool.validate_url("ftp://example.com").is_err());
|
assert!(tool.validate_url("ftp://example.com").is_err());
|
||||||
|
|
||||||
// file:// URLs blocked (local file exfiltration risk)
|
// file:// URLs are allowed
|
||||||
assert!(tool.validate_url("file:///tmp/test.html").is_err());
|
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -8,13 +8,19 @@ use std::sync::Arc;
|
|||||||
pub struct BrowserOpenTool {
|
pub struct BrowserOpenTool {
|
||||||
security: Arc<SecurityPolicy>,
|
security: Arc<SecurityPolicy>,
|
||||||
allowed_domains: Vec<String>,
|
allowed_domains: Vec<String>,
|
||||||
|
net_policy: crate::tools::NetworkAccessPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrowserOpenTool {
|
impl BrowserOpenTool {
|
||||||
pub fn new(security: Arc<SecurityPolicy>, allowed_domains: Vec<String>) -> Self {
|
pub fn new(
|
||||||
|
security: Arc<SecurityPolicy>,
|
||||||
|
allowed_domains: Vec<String>,
|
||||||
|
net_policy: crate::tools::NetworkAccessPolicy,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
security,
|
security,
|
||||||
allowed_domains: normalize_allowed_domains(allowed_domains),
|
allowed_domains: normalize_allowed_domains(allowed_domains),
|
||||||
|
net_policy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,8 +35,16 @@ impl BrowserOpenTool {
|
|||||||
anyhow::bail!("URL cannot contain whitespace");
|
anyhow::bail!("URL cannot contain whitespace");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !url.starts_with("https://") {
|
if !url.starts_with("https://") && !url.starts_with("http://") && !url.starts_with("file://") {
|
||||||
anyhow::bail!("Only https:// URLs are allowed");
|
anyhow::bail!("Only http://, https://, and file:// URLs are allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// file:// URLs require local network access
|
||||||
|
if url.starts_with("file://") {
|
||||||
|
if !self.net_policy.allow_local_network {
|
||||||
|
anyhow::bail!("file:// URLs are blocked (security.allow_local_network = false)");
|
||||||
|
}
|
||||||
|
return Ok(url.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.allowed_domains.is_empty() {
|
if self.allowed_domains.is_empty() {
|
||||||
@ -41,8 +55,13 @@ impl BrowserOpenTool {
|
|||||||
|
|
||||||
let host = extract_host(url)?;
|
let host = extract_host(url)?;
|
||||||
|
|
||||||
|
// Apply network access policy
|
||||||
if is_private_or_local_host(&host) {
|
if is_private_or_local_host(&host) {
|
||||||
anyhow::bail!("Blocked local/private host: {host}");
|
if !self.net_policy.allow_local_network {
|
||||||
|
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
|
||||||
|
}
|
||||||
|
} else if !self.net_policy.allow_public_internet {
|
||||||
|
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !host_matches_allowlist(&host, &self.allowed_domains) {
|
if !host_matches_allowlist(&host, &self.allowed_domains) {
|
||||||
@ -60,7 +79,7 @@ impl Tool for BrowserOpenTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Open an approved HTTPS URL in the system browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping."
|
"Open a URL in the system browser. Supports http://, https://, and file:// URLs. Domain allowlist applies to http/https."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@ -273,7 +292,8 @@ fn normalize_domain(raw: &str) -> Option<String> {
|
|||||||
fn extract_host(url: &str) -> anyhow::Result<String> {
|
fn extract_host(url: &str) -> anyhow::Result<String> {
|
||||||
let rest = url
|
let rest = url
|
||||||
.strip_prefix("https://")
|
.strip_prefix("https://")
|
||||||
.ok_or_else(|| anyhow::anyhow!("Only https:// URLs are allowed"))?;
|
.or_else(|| url.strip_prefix("http://"))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Only http:// and https:// URLs are allowed"))?;
|
||||||
|
|
||||||
let authority = rest
|
let authority = rest
|
||||||
.split(['/', '?', '#'])
|
.split(['/', '?', '#'])
|
||||||
@ -369,6 +389,7 @@ mod tests {
|
|||||||
BrowserOpenTool::new(
|
BrowserOpenTool::new(
|
||||||
security,
|
security,
|
||||||
allowed_domains.into_iter().map(String::from).collect(),
|
allowed_domains.into_iter().map(String::from).collect(),
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -408,43 +429,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_wildcard_allowlist_still_rejects_private_host() {
|
fn validate_wildcard_allowlist_accepts_localhost() {
|
||||||
let tool = test_tool(vec!["*"]);
|
let tool = test_tool(vec!["*"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://localhost:8443").is_ok());
|
||||||
.validate_url("https://localhost:8443")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_rejects_http() {
|
fn validate_accepts_http() {
|
||||||
let tool = test_tool(vec!["example.com"]);
|
let tool = test_tool(vec!["example.com"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("http://example.com").is_ok());
|
||||||
.validate_url("http://example.com")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("https://"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_rejects_localhost() {
|
fn validate_accepts_localhost() {
|
||||||
let tool = test_tool(vec!["localhost"]);
|
let tool = test_tool(vec!["localhost"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://localhost:8080").is_ok());
|
||||||
.validate_url("https://localhost:8080")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_rejects_private_ipv4() {
|
fn validate_accepts_private_ipv4() {
|
||||||
let tool = test_tool(vec!["192.168.1.5"]);
|
let tool = test_tool(vec!["192.168.1.5"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://192.168.1.5").is_ok());
|
||||||
.validate_url("https://192.168.1.5")
|
}
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
#[test]
|
||||||
assert!(err.contains("local/private"));
|
fn validate_accepts_file_url() {
|
||||||
|
let tool = test_tool(vec!["example.com"]);
|
||||||
|
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -480,7 +491,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_requires_allowlist() {
|
fn validate_requires_allowlist() {
|
||||||
let security = Arc::new(SecurityPolicy::default());
|
let security = Arc::new(SecurityPolicy::default());
|
||||||
let tool = BrowserOpenTool::new(security, vec![]);
|
let tool = BrowserOpenTool::new(security, vec![], crate::tools::NetworkAccessPolicy::default());
|
||||||
let err = tool
|
let err = tool
|
||||||
.validate_url("https://example.com")
|
.validate_url("https://example.com")
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@ -506,7 +517,7 @@ mod tests {
|
|||||||
autonomy: AutonomyLevel::ReadOnly,
|
autonomy: AutonomyLevel::ReadOnly,
|
||||||
..SecurityPolicy::default()
|
..SecurityPolicy::default()
|
||||||
});
|
});
|
||||||
let tool = BrowserOpenTool::new(security, vec!["example.com".into()]);
|
let tool = BrowserOpenTool::new(security, vec!["example.com".into()], crate::tools::NetworkAccessPolicy::default());
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(json!({"url": "https://example.com"}))
|
.execute(json!({"url": "https://example.com"}))
|
||||||
.await
|
.await
|
||||||
@ -521,7 +532,7 @@ mod tests {
|
|||||||
max_actions_per_hour: 0,
|
max_actions_per_hour: 0,
|
||||||
..SecurityPolicy::default()
|
..SecurityPolicy::default()
|
||||||
});
|
});
|
||||||
let tool = BrowserOpenTool::new(security, vec!["example.com".into()]);
|
let tool = BrowserOpenTool::new(security, vec!["example.com".into()], crate::tools::NetworkAccessPolicy::default());
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(json!({"url": "https://example.com"}))
|
.execute(json!({"url": "https://example.com"}))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -12,6 +12,7 @@ pub struct HttpRequestTool {
|
|||||||
allowed_domains: Vec<String>,
|
allowed_domains: Vec<String>,
|
||||||
max_response_size: usize,
|
max_response_size: usize,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
|
net_policy: crate::tools::NetworkAccessPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpRequestTool {
|
impl HttpRequestTool {
|
||||||
@ -20,12 +21,14 @@ impl HttpRequestTool {
|
|||||||
allowed_domains: Vec<String>,
|
allowed_domains: Vec<String>,
|
||||||
max_response_size: usize,
|
max_response_size: usize,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
|
net_policy: crate::tools::NetworkAccessPolicy,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
security,
|
security,
|
||||||
allowed_domains: normalize_allowed_domains(allowed_domains),
|
allowed_domains: normalize_allowed_domains(allowed_domains),
|
||||||
max_response_size,
|
max_response_size,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
|
net_policy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,8 +55,13 @@ impl HttpRequestTool {
|
|||||||
|
|
||||||
let host = extract_host(url)?;
|
let host = extract_host(url)?;
|
||||||
|
|
||||||
|
// Apply network access policy
|
||||||
if is_private_or_local_host(&host) {
|
if is_private_or_local_host(&host) {
|
||||||
anyhow::bail!("Blocked local/private host: {host}");
|
if !self.net_policy.allow_local_network {
|
||||||
|
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
|
||||||
|
}
|
||||||
|
} else if !self.net_policy.allow_public_internet {
|
||||||
|
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !host_matches_allowlist(&host, &self.allowed_domains) {
|
if !host_matches_allowlist(&host, &self.allowed_domains) {
|
||||||
@ -165,8 +173,8 @@ impl Tool for HttpRequestTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \
|
"Make HTTP requests to APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \
|
||||||
Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits."
|
Domain allowlist controls which hosts are reachable. Configurable timeout and response size limits."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@ -463,6 +471,7 @@ mod tests {
|
|||||||
allowed_domains.into_iter().map(String::from).collect(),
|
allowed_domains.into_iter().map(String::from).collect(),
|
||||||
1_000_000,
|
1_000_000,
|
||||||
30,
|
30,
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -508,13 +517,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_wildcard_allowlist_still_rejects_private_host() {
|
fn validate_wildcard_allowlist_accepts_localhost() {
|
||||||
let tool = test_tool(vec!["*"]);
|
let tool = test_tool(vec!["*"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://localhost:8080").is_ok());
|
||||||
.validate_url("https://localhost:8080")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -528,23 +533,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_rejects_localhost() {
|
fn validate_accepts_localhost() {
|
||||||
let tool = test_tool(vec!["localhost"]);
|
let tool = test_tool(vec!["localhost"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://localhost:8080").is_ok());
|
||||||
.validate_url("https://localhost:8080")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_rejects_private_ipv4() {
|
fn validate_accepts_private_ipv4() {
|
||||||
let tool = test_tool(vec!["192.168.1.5"]);
|
let tool = test_tool(vec!["192.168.1.5"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://192.168.1.5").is_ok());
|
||||||
.validate_url("https://192.168.1.5")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -570,7 +567,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_requires_allowlist() {
|
fn validate_requires_allowlist() {
|
||||||
let security = Arc::new(SecurityPolicy::default());
|
let security = Arc::new(SecurityPolicy::default());
|
||||||
let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30);
|
let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30, crate::tools::NetworkAccessPolicy::default());
|
||||||
let err = tool
|
let err = tool
|
||||||
.validate_url("https://example.com")
|
.validate_url("https://example.com")
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@ -686,7 +683,7 @@ mod tests {
|
|||||||
autonomy: AutonomyLevel::ReadOnly,
|
autonomy: AutonomyLevel::ReadOnly,
|
||||||
..SecurityPolicy::default()
|
..SecurityPolicy::default()
|
||||||
});
|
});
|
||||||
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30);
|
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, crate::tools::NetworkAccessPolicy::default());
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(json!({"url": "https://example.com"}))
|
.execute(json!({"url": "https://example.com"}))
|
||||||
.await
|
.await
|
||||||
@ -701,7 +698,7 @@ mod tests {
|
|||||||
max_actions_per_hour: 0,
|
max_actions_per_hour: 0,
|
||||||
..SecurityPolicy::default()
|
..SecurityPolicy::default()
|
||||||
});
|
});
|
||||||
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30);
|
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, crate::tools::NetworkAccessPolicy::default());
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(json!({"url": "https://example.com"}))
|
.execute(json!({"url": "https://example.com"}))
|
||||||
.await
|
.await
|
||||||
@ -724,6 +721,7 @@ mod tests {
|
|||||||
vec!["example.com".into()],
|
vec!["example.com".into()],
|
||||||
10,
|
10,
|
||||||
30,
|
30,
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
let text = "hello world this is long";
|
let text = "hello world this is long";
|
||||||
let truncated = tool.truncate_response(text);
|
let truncated = tool.truncate_response(text);
|
||||||
@ -738,6 +736,7 @@ mod tests {
|
|||||||
vec!["example.com".into()],
|
vec!["example.com".into()],
|
||||||
0, // max_response_size = 0 means no limit
|
0, // max_response_size = 0 means no limit
|
||||||
30,
|
30,
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
let text = "a".repeat(10_000_000);
|
let text = "a".repeat(10_000_000);
|
||||||
assert_eq!(tool.truncate_response(&text), text);
|
assert_eq!(tool.truncate_response(&text), text);
|
||||||
@ -750,6 +749,7 @@ mod tests {
|
|||||||
vec!["example.com".into()],
|
vec!["example.com".into()],
|
||||||
5,
|
5,
|
||||||
30,
|
30,
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
let text = "hello world";
|
let text = "hello world";
|
||||||
let truncated = tool.truncate_response(text);
|
let truncated = tool.truncate_response(text);
|
||||||
|
|||||||
@ -105,6 +105,40 @@ use async_trait::async_trait;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Network access policy for tools that make HTTP/file requests.
|
||||||
|
///
|
||||||
|
/// Constructed from `[security]` config fields. Both default to `true` (unrestricted).
|
||||||
|
/// Power users can restrict access via config:
|
||||||
|
///
|
||||||
|
/// ```toml
|
||||||
|
/// [security]
|
||||||
|
/// allow_local_network = false # blocks localhost, private IPs, file:// URLs
|
||||||
|
/// allow_public_internet = false # blocks all public internet access
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct NetworkAccessPolicy {
|
||||||
|
pub allow_local_network: bool,
|
||||||
|
pub allow_public_internet: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NetworkAccessPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
allow_local_network: true,
|
||||||
|
allow_public_internet: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkAccessPolicy {
|
||||||
|
pub fn from_config(config: &crate::config::Config) -> Self {
|
||||||
|
Self {
|
||||||
|
allow_local_network: config.security.allow_local_network,
|
||||||
|
allow_public_internet: config.security.allow_public_internet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ArcDelegatingTool {
|
struct ArcDelegatingTool {
|
||||||
inner: Arc<dyn Tool>,
|
inner: Arc<dyn Tool>,
|
||||||
@ -209,6 +243,7 @@ pub fn all_tools_with_runtime(
|
|||||||
fallback_api_key: Option<&str>,
|
fallback_api_key: Option<&str>,
|
||||||
root_config: &crate::config::Config,
|
root_config: &crate::config::Config,
|
||||||
) -> Vec<Box<dyn Tool>> {
|
) -> Vec<Box<dyn Tool>> {
|
||||||
|
let net_policy = NetworkAccessPolicy::from_config(root_config);
|
||||||
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
|
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
|
||||||
Arc::new(ShellTool::new(security.clone(), runtime)),
|
Arc::new(ShellTool::new(security.clone(), runtime)),
|
||||||
Arc::new(FileReadTool::new(security.clone())),
|
Arc::new(FileReadTool::new(security.clone())),
|
||||||
@ -246,6 +281,7 @@ pub fn all_tools_with_runtime(
|
|||||||
tool_arcs.push(Arc::new(BrowserOpenTool::new(
|
tool_arcs.push(Arc::new(BrowserOpenTool::new(
|
||||||
security.clone(),
|
security.clone(),
|
||||||
browser_config.allowed_domains.clone(),
|
browser_config.allowed_domains.clone(),
|
||||||
|
net_policy,
|
||||||
)));
|
)));
|
||||||
// Add full browser automation tool (pluggable backend)
|
// Add full browser automation tool (pluggable backend)
|
||||||
tool_arcs.push(Arc::new(BrowserTool::new_with_backend(
|
tool_arcs.push(Arc::new(BrowserTool::new_with_backend(
|
||||||
@ -265,6 +301,7 @@ pub fn all_tools_with_runtime(
|
|||||||
max_coordinate_x: browser_config.computer_use.max_coordinate_x,
|
max_coordinate_x: browser_config.computer_use.max_coordinate_x,
|
||||||
max_coordinate_y: browser_config.computer_use.max_coordinate_y,
|
max_coordinate_y: browser_config.computer_use.max_coordinate_y,
|
||||||
},
|
},
|
||||||
|
net_policy,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,6 +311,7 @@ pub fn all_tools_with_runtime(
|
|||||||
http_config.allowed_domains.clone(),
|
http_config.allowed_domains.clone(),
|
||||||
http_config.max_response_size,
|
http_config.max_response_size,
|
||||||
http_config.timeout_secs,
|
http_config.timeout_secs,
|
||||||
|
net_policy,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,6 +322,7 @@ pub fn all_tools_with_runtime(
|
|||||||
web_fetch_config.blocked_domains.clone(),
|
web_fetch_config.blocked_domains.clone(),
|
||||||
web_fetch_config.max_response_size,
|
web_fetch_config.max_response_size,
|
||||||
web_fetch_config.timeout_secs,
|
web_fetch_config.timeout_secs,
|
||||||
|
net_policy,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ pub struct WebFetchTool {
|
|||||||
blocked_domains: Vec<String>,
|
blocked_domains: Vec<String>,
|
||||||
max_response_size: usize,
|
max_response_size: usize,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
|
net_policy: crate::tools::NetworkAccessPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebFetchTool {
|
impl WebFetchTool {
|
||||||
@ -29,6 +30,7 @@ impl WebFetchTool {
|
|||||||
blocked_domains: Vec<String>,
|
blocked_domains: Vec<String>,
|
||||||
max_response_size: usize,
|
max_response_size: usize,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
|
net_policy: crate::tools::NetworkAccessPolicy,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
security,
|
security,
|
||||||
@ -36,6 +38,7 @@ impl WebFetchTool {
|
|||||||
blocked_domains: normalize_allowed_domains(blocked_domains),
|
blocked_domains: normalize_allowed_domains(blocked_domains),
|
||||||
max_response_size,
|
max_response_size,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
|
net_policy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +48,7 @@ impl WebFetchTool {
|
|||||||
&self.allowed_domains,
|
&self.allowed_domains,
|
||||||
&self.blocked_domains,
|
&self.blocked_domains,
|
||||||
"web_fetch",
|
"web_fetch",
|
||||||
|
self.net_policy,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +95,7 @@ impl Tool for WebFetchTool {
|
|||||||
HTML pages are automatically converted to readable text. \
|
HTML pages are automatically converted to readable text. \
|
||||||
JSON and plain text responses are returned as-is. \
|
JSON and plain text responses are returned as-is. \
|
||||||
Only GET requests; follows redirects. \
|
Only GET requests; follows redirects. \
|
||||||
Security: allowlist-only domains, no local/private hosts."
|
Domain allowlist controls which hosts are reachable."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@ -150,6 +154,7 @@ impl Tool for WebFetchTool {
|
|||||||
|
|
||||||
let allowed_domains = self.allowed_domains.clone();
|
let allowed_domains = self.allowed_domains.clone();
|
||||||
let blocked_domains = self.blocked_domains.clone();
|
let blocked_domains = self.blocked_domains.clone();
|
||||||
|
let redirect_net_policy = self.net_policy;
|
||||||
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
|
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
|
||||||
if attempt.previous().len() >= 10 {
|
if attempt.previous().len() >= 10 {
|
||||||
return attempt.error(std::io::Error::other("Too many redirects (max 10)"));
|
return attempt.error(std::io::Error::other("Too many redirects (max 10)"));
|
||||||
@ -160,6 +165,7 @@ impl Tool for WebFetchTool {
|
|||||||
&allowed_domains,
|
&allowed_domains,
|
||||||
&blocked_domains,
|
&blocked_domains,
|
||||||
"web_fetch",
|
"web_fetch",
|
||||||
|
redirect_net_policy,
|
||||||
) {
|
) {
|
||||||
return attempt.error(std::io::Error::new(
|
return attempt.error(std::io::Error::new(
|
||||||
std::io::ErrorKind::PermissionDenied,
|
std::io::ErrorKind::PermissionDenied,
|
||||||
@ -271,6 +277,7 @@ fn validate_target_url(
|
|||||||
allowed_domains: &[String],
|
allowed_domains: &[String],
|
||||||
blocked_domains: &[String],
|
blocked_domains: &[String],
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
|
net_policy: crate::tools::NetworkAccessPolicy,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let url = raw_url.trim();
|
let url = raw_url.trim();
|
||||||
|
|
||||||
@ -295,8 +302,13 @@ fn validate_target_url(
|
|||||||
|
|
||||||
let host = extract_host(url)?;
|
let host = extract_host(url)?;
|
||||||
|
|
||||||
|
// Apply network access policy
|
||||||
if is_private_or_local_host(&host) {
|
if is_private_or_local_host(&host) {
|
||||||
anyhow::bail!("Blocked local/private host: {host}");
|
if !net_policy.allow_local_network {
|
||||||
|
anyhow::bail!("Blocked local/private host: {host} (security.allow_local_network = false)");
|
||||||
|
}
|
||||||
|
} else if !net_policy.allow_public_internet {
|
||||||
|
anyhow::bail!("Blocked public host: {host} (security.allow_public_internet = false)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if host_matches_allowlist(&host, blocked_domains) {
|
if host_matches_allowlist(&host, blocked_domains) {
|
||||||
@ -307,8 +319,6 @@ fn validate_target_url(
|
|||||||
anyhow::bail!("Host '{host}' is not in {tool_name}.allowed_domains");
|
anyhow::bail!("Host '{host}' is not in {tool_name}.allowed_domains");
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_resolved_host_is_public(&host)?;
|
|
||||||
|
|
||||||
Ok(url.to_string())
|
Ok(url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -529,6 +539,7 @@ mod tests {
|
|||||||
blocked_domains.into_iter().map(String::from).collect(),
|
blocked_domains.into_iter().map(String::from).collect(),
|
||||||
500_000,
|
500_000,
|
||||||
30,
|
30,
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -620,7 +631,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_requires_allowlist() {
|
fn validate_requires_allowlist() {
|
||||||
let security = Arc::new(SecurityPolicy::default());
|
let security = Arc::new(SecurityPolicy::default());
|
||||||
let tool = WebFetchTool::new(security, vec![], vec![], 500_000, 30);
|
let tool = WebFetchTool::new(security, vec![], vec![], 500_000, 30, crate::tools::NetworkAccessPolicy::default());
|
||||||
let err = tool
|
let err = tool
|
||||||
.validate_url("https://example.com")
|
.validate_url("https://example.com")
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@ -631,46 +642,21 @@ mod tests {
|
|||||||
// ── SSRF protection ──────────────────────────────────────────
|
// ── SSRF protection ──────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ssrf_blocks_localhost() {
|
fn accepts_localhost() {
|
||||||
let tool = test_tool(vec!["localhost"]);
|
let tool = test_tool(vec!["localhost"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://localhost:8080").is_ok());
|
||||||
.validate_url("https://localhost:8080")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ssrf_blocks_private_ipv4() {
|
fn accepts_private_ipv4() {
|
||||||
let tool = test_tool(vec!["192.168.1.5"]);
|
let tool = test_tool(vec!["192.168.1.5"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://192.168.1.5").is_ok());
|
||||||
.validate_url("https://192.168.1.5")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ssrf_blocks_loopback() {
|
fn wildcard_accepts_localhost() {
|
||||||
assert!(is_private_or_local_host("127.0.0.1"));
|
|
||||||
assert!(is_private_or_local_host("127.0.0.2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssrf_blocks_rfc1918() {
|
|
||||||
assert!(is_private_or_local_host("10.0.0.1"));
|
|
||||||
assert!(is_private_or_local_host("172.16.0.1"));
|
|
||||||
assert!(is_private_or_local_host("192.168.1.1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssrf_wildcard_still_blocks_private() {
|
|
||||||
let tool = test_tool(vec!["*"]);
|
let tool = test_tool(vec!["*"]);
|
||||||
let err = tool
|
assert!(tool.validate_url("https://localhost:8080").is_ok());
|
||||||
.validate_url("https://localhost:8080")
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(err.contains("local/private"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -681,28 +667,41 @@ mod tests {
|
|||||||
"https://docs.example.com/page",
|
"https://docs.example.com/page",
|
||||||
&allowed,
|
&allowed,
|
||||||
&blocked,
|
&blocked,
|
||||||
"web_fetch"
|
"web_fetch",
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
)
|
)
|
||||||
.is_ok());
|
.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn redirect_target_validation_blocks_private_host() {
|
fn redirect_target_validation_blocks_unlisted_host() {
|
||||||
let allowed = vec!["example.com".to_string()];
|
let allowed = vec!["example.com".to_string()];
|
||||||
let blocked = vec![];
|
let blocked = vec![];
|
||||||
let err = validate_target_url("https://127.0.0.1/admin", &allowed, &blocked, "web_fetch")
|
let err = validate_target_url(
|
||||||
.unwrap_err()
|
"https://127.0.0.1/admin",
|
||||||
.to_string();
|
&allowed,
|
||||||
assert!(err.contains("local/private"));
|
&blocked,
|
||||||
|
"web_fetch",
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("allowed_domains"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn redirect_target_validation_blocks_blocklisted_host() {
|
fn redirect_target_validation_blocks_blocklisted_host() {
|
||||||
let allowed = vec!["*".to_string()];
|
let allowed = vec!["*".to_string()];
|
||||||
let blocked = vec!["evil.com".to_string()];
|
let blocked = vec!["evil.com".to_string()];
|
||||||
let err = validate_target_url("https://evil.com/phish", &allowed, &blocked, "web_fetch")
|
let err = validate_target_url(
|
||||||
.unwrap_err()
|
"https://evil.com/phish",
|
||||||
.to_string();
|
&allowed,
|
||||||
|
&blocked,
|
||||||
|
"web_fetch",
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
assert!(err.contains("blocked_domains"));
|
assert!(err.contains("blocked_domains"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -714,7 +713,7 @@ mod tests {
|
|||||||
autonomy: AutonomyLevel::ReadOnly,
|
autonomy: AutonomyLevel::ReadOnly,
|
||||||
..SecurityPolicy::default()
|
..SecurityPolicy::default()
|
||||||
});
|
});
|
||||||
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30);
|
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30, crate::tools::NetworkAccessPolicy::default());
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(json!({"url": "https://example.com"}))
|
.execute(json!({"url": "https://example.com"}))
|
||||||
.await
|
.await
|
||||||
@ -729,7 +728,7 @@ mod tests {
|
|||||||
max_actions_per_hour: 0,
|
max_actions_per_hour: 0,
|
||||||
..SecurityPolicy::default()
|
..SecurityPolicy::default()
|
||||||
});
|
});
|
||||||
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30);
|
let tool = WebFetchTool::new(security, vec!["example.com".into()], vec![], 500_000, 30, crate::tools::NetworkAccessPolicy::default());
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(json!({"url": "https://example.com"}))
|
.execute(json!({"url": "https://example.com"}))
|
||||||
.await
|
.await
|
||||||
@ -755,6 +754,7 @@ mod tests {
|
|||||||
vec![],
|
vec![],
|
||||||
10,
|
10,
|
||||||
30,
|
30,
|
||||||
|
crate::tools::NetworkAccessPolicy::default(),
|
||||||
);
|
);
|
||||||
let text = "hello world this is long";
|
let text = "hello world this is long";
|
||||||
let truncated = tool.truncate_response(text);
|
let truncated = tool.truncate_response(text);
|
||||||
|
|||||||
@ -287,7 +287,7 @@ fn config_nested_optional_sections_default_when_absent() {
|
|||||||
assert!(parsed.channels_config.telegram.is_none());
|
assert!(parsed.channels_config.telegram.is_none());
|
||||||
assert!(!parsed.composio.enabled);
|
assert!(!parsed.composio.enabled);
|
||||||
assert!(parsed.composio.api_key.is_none());
|
assert!(parsed.composio.api_key.is_none());
|
||||||
assert!(!parsed.browser.enabled);
|
assert!(parsed.browser.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user