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