//! HTTP request tool. use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use futures::StreamExt; use reqwest::Client; use crate::context::JobContext; use crate::safety::LeakDetector; use crate::secrets::SecretsStore; use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str}; use crate::tools::wasm::{InjectedCredentials, SharedCredentialRegistry, inject_credential}; #[cfg(feature = "html-to-markdown")] use crate::tools::builtin::convert_html_to_markdown; /// Maximum response body size for text responses (5 MB). /// /// 5 MB is large enough for typical JSON API responses and moderate HTML pages, /// but small enough to prevent OOM from malicious or runaway servers. The WASM /// HTTP wrapper uses the same limit for consistency. const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; /// Maximum response body size when saving to disk via `save_to` (50 MB). /// /// Larger limit for file downloads since the body is written to disk, not held /// in memory for LLM context. Matches the WASM attachment size cap. const MAX_SAVE_TO_SIZE: usize = 50 * 1024 * 1024; /// Default request timeout when the caller does not provide one. const DEFAULT_TIMEOUT_SECS: u64 = 30; /// Maximum allowed request timeout to bound resource usage from LLM-controlled inputs. const MAX_TIMEOUT_SECS: u64 = 300; /// Maximum number of redirects to follow for simple GET requests. const MAX_REDIRECTS: usize = 3; /// Descriptive User-Agent so public APIs don't reject bare requests. const USER_AGENT: &str = concat!( "IronClaw-Agent/", env!("CARGO_PKG_VERSION"), " (https://github.com/nearai/ironclaw)" ); /// Tool for making HTTP requests. /// /// Each request builds a per-request [`Client`] with DNS pinning to prevent /// TOCTOU DNS rebinding attacks. The hostname is resolved once, validated /// against the SSRF blocklist, and then pinned via /// [`reqwest::ClientBuilder::resolve_to_addrs`] so that reqwest connects /// directly to the pre-validated IPs without a second DNS lookup. pub struct HttpTool { credential_registry: Option>, secrets_store: Option>, } impl HttpTool { /// Create a new HTTP tool. pub fn new() -> Self { Self { credential_registry: None, secrets_store: None, } } /// Attach a credential registry and secrets store for auto-injection. pub fn with_credentials( mut self, registry: Arc, secrets_store: Arc, ) -> Self { self.credential_registry = Some(registry); self.secrets_store = Some(secrets_store); self } } /// Validate and resolve a `save_to` path, ensuring it stays under `/tmp/`. /// /// Uses `path_utils::validate_path` with `/tmp` as the base directory to catch /// traversal attacks like `/tmp/../../etc/passwd` and symlink escapes. /// Creates parent directories only after validation succeeds. fn validate_save_to_path(save_to: &str) -> Result { // Quick prefix check before doing any fs work if !save_to.starts_with("/tmp/") { return Err(ToolError::InvalidParameters( "save_to path must be under /tmp/".to_string(), )); } // Validate path BEFORE creating directories to prevent traversal-based // directory creation outside /tmp (e.g. `/tmp/../../etc/passwd`). let tmp_base = std::path::Path::new("/tmp"); let validated = crate::tools::builtin::path_utils::validate_path(save_to, Some(tmp_base))?; // Only create parent directories for the validated (safe) path if let Some(parent) = validated.parent() { std::fs::create_dir_all(parent).map_err(|e| { ToolError::ExecutionFailed(format!("failed to create directory: {}", e)) })?; } Ok(validated) } /// Parse and validate a URL without DNS resolution. /// /// Checks scheme (HTTPS only), rejects localhost and private/link-local IP /// literals. Does **not** resolve hostnames -- use [`validate_and_resolve_url`] /// for the full DNS-pinning flow that eliminates the TOCTOU rebinding window. pub(crate) fn validate_url(url: &str) -> Result { let parsed = reqwest::Url::parse(url) .map_err(|e| ToolError::InvalidParameters(format!("invalid URL: {}", e)))?; if parsed.scheme() != "https" { return Err(ToolError::NotAuthorized( "only https URLs are allowed".to_string(), )); } let host = parsed .host_str() .ok_or_else(|| ToolError::InvalidParameters("URL missing host".to_string()))?; let host_lower = host.to_lowercase(); if host_lower == "localhost" || host_lower.ends_with(".localhost") { return Err(ToolError::NotAuthorized( "localhost is not allowed".to_string(), )); } // Check literal IP addresses if let Ok(ip) = host.parse::() && is_disallowed_ip(&ip) { return Err(ToolError::NotAuthorized( "private or local IPs are not allowed".to_string(), )); } Ok(parsed) } /// Resolve DNS for a validated URL and check every resolved address against /// the SSRF blocklist. /// /// Returns the resolved [`SocketAddr`]s so that callers can pin the hostname /// via [`reqwest::ClientBuilder::resolve_to_addrs`], preventing a DNS rebinding /// attack where a second, independent resolution (inside reqwest) returns a /// different -- potentially private -- IP after our validation pass. pub(crate) async fn validate_and_resolve_url( url: &reqwest::Url, ) -> Result, ToolError> { let host = url .host_str() .ok_or_else(|| ToolError::InvalidParameters("URL missing host".to_string()))?; let port = url.port_or_known_default().unwrap_or(443); let addrs: Vec = tokio::net::lookup_host(format!("{}:{}", host, port)) .await .map_err(|e| { ToolError::ExternalService(format!("DNS resolution failed for '{}': {}", host, e)) })? .collect(); if addrs.is_empty() { return Err(ToolError::ExternalService(format!( "DNS resolution for '{}' returned no addresses", host ))); } for addr in &addrs { if is_disallowed_ip(&addr.ip()) { return Err(ToolError::NotAuthorized(format!( "hostname '{}' resolves to disallowed IP {}", host, addr.ip() ))); } } Ok(addrs) } /// Build a reqwest [`Client`] that pins the given hostname to the /// pre-validated resolved addresses, preventing any second DNS lookup. pub(crate) fn build_pinned_client( host: &str, resolved_addrs: &[SocketAddr], timeout: Duration, redirect_policy: reqwest::redirect::Policy, ) -> Result { let builder = Client::builder() .timeout(timeout) .redirect(redirect_policy) .user_agent(USER_AGENT) .resolve_to_addrs(host, resolved_addrs); builder .build() .map_err(|e| ToolError::ExternalService(format!("failed to build HTTP client: {}", e))) } /// Check whether an IPv4 address falls in a disallowed range (private, /// loopback, link-local, multicast, unspecified, or cloud metadata). fn is_disallowed_ipv4(v4: &Ipv4Addr) -> bool { v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_multicast() || v4.is_unspecified() || *v4 == Ipv4Addr::new(169, 254, 169, 254) || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) } fn is_disallowed_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(v4) => is_disallowed_ipv4(v4), IpAddr::V6(v6) => { // Catch IPv4-mapped IPv6 addresses (e.g. ::ffff:169.254.169.254) // that would bypass IPv4-only checks. if let Some(v4) = v6.to_ipv4_mapped() && is_disallowed_ipv4(&v4) { return true; } v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local() || v6.is_multicast() || v6.is_unspecified() } } } #[cfg(feature = "html-to-markdown")] /// Heuristic: treat as HTML if the `Content-Type` header contains `text/html`. fn is_html_response(headers: &HashMap) -> bool { headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case("content-type")) .map(|(_, v)| v.to_lowercase().contains("text/html")) .unwrap_or(false) } fn parse_headers_param( headers: Option<&serde_json::Value>, ) -> Result, ToolError> { fn parse_header_object( map: &serde_json::Map, ) -> Result, ToolError> { let mut out = Vec::with_capacity(map.len()); for (k, v) in map { let value = v.as_str().ok_or_else(|| { ToolError::InvalidParameters(format!("header '{}' must have a string value", k)) })?; out.push((k.clone(), value.to_string())); } Ok(out) } fn parse_header_array(items: &[serde_json::Value]) -> Result, ToolError> { let mut out = Vec::with_capacity(items.len()); for (idx, item) in items.iter().enumerate() { let obj = item.as_object().ok_or_else(|| { ToolError::InvalidParameters(format!( "headers[{}] must be an object with 'name' and 'value'", idx )) })?; let name = obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| { ToolError::InvalidParameters(format!("headers[{}].name must be a string", idx)) })?; let value = obj.get("value").and_then(|v| v.as_str()).ok_or_else(|| { ToolError::InvalidParameters(format!("headers[{}].value must be a string", idx)) })?; out.push((name.to_string(), value.to_string())); } Ok(out) } match headers { None => Ok(Vec::new()), Some(serde_json::Value::String(raw)) => { let trimmed = raw.trim(); if trimmed.is_empty() { return Ok(Vec::new()); } let parsed = serde_json::from_str::(trimmed).map_err(|e| { ToolError::InvalidParameters(format!( "headers string must contain valid JSON object/array: {}", e )) })?; match parsed { serde_json::Value::Object(map) => parse_header_object(&map), serde_json::Value::Array(items) => parse_header_array(&items), _ => Err(ToolError::InvalidParameters( "headers string must decode to a JSON object or array".to_string(), )), } } Some(serde_json::Value::Object(map)) => parse_header_object(map), Some(serde_json::Value::Array(items)) => parse_header_array(items), Some(_) => Err(ToolError::InvalidParameters( "'headers' must be an object or an array of {name, value}".to_string(), )), } } fn parse_timeout_secs_param(timeout: Option<&serde_json::Value>) -> Result, ToolError> { let parsed = match timeout { None | Some(serde_json::Value::Null) => Ok(None), Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| { ToolError::InvalidParameters("timeout_secs must be a non-negative integer".to_string()) }), Some(serde_json::Value::String(raw)) => { let trimmed = raw.trim(); if trimmed.is_empty() { return Ok(None); } let secs = trimmed.parse::().map_err(|_| { ToolError::InvalidParameters( "timeout_secs string must contain a non-negative integer".to_string(), ) })?; Ok(Some(secs)) } Some(_) => Err(ToolError::InvalidParameters( "timeout_secs must be an integer".to_string(), )), }?; if let Some(secs) = parsed && secs > MAX_TIMEOUT_SECS { return Err(ToolError::InvalidParameters(format!( "timeout_secs must be <= {}", MAX_TIMEOUT_SECS ))); } Ok(parsed) } fn parse_save_to_param(save_to: Option<&serde_json::Value>) -> Result, ToolError> { match save_to { None | Some(serde_json::Value::Null) => Ok(None), Some(serde_json::Value::String(path)) => { let trimmed = path.trim(); if trimmed.is_empty() { Ok(None) } else { Ok(Some(trimmed.to_string())) } } Some(_) => Err(ToolError::InvalidParameters( "save_to must be a string".to_string(), )), } } /// Extract host from URL in params (for approval checks). fn extract_host_from_params(params: &serde_json::Value) -> Option { params .get("url") .and_then(|u| u.as_str()) .and_then(|u| reqwest::Url::parse(u).ok()) .and_then(|u| u.host_str().map(|h| h.to_string())) } impl Default for HttpTool { fn default() -> Self { Self::new() } } #[async_trait] impl Tool for HttpTool { fn name(&self) -> &str { "http" } fn description(&self) -> &str { "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE methods. \ Use save_to to download binary files (images, PDFs, etc.) to a local path, \ e.g. {\"method\":\"GET\",\"url\":\"https://picsum.photos/800/600\",\"save_to\":\"/tmp/photo.jpg\"}." } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE", "PATCH"], "description": "HTTP method (default: GET)" }, "url": { "type": "string", "description": "The URL to request" }, "headers": { "type": "array", "description": "Optional headers as a list of {name, value} objects", "items": { "type": "object", "properties": { "name": { "type": "string" }, "value": { "type": "string" } }, "required": ["name", "value"], "additionalProperties": false } }, "body": { "description": "Request body (for POST/PUT/PATCH). Can be a JSON object, array, string, or other value." }, "timeout_secs": { "type": "integer", "description": "Request timeout in seconds (default: 30)" }, "save_to": { "type": "string", "description": "Save response body as raw bytes to this file path instead of returning it. Use for binary downloads (images, PDFs, etc.). The path must be under /tmp/." } }, "required": ["url"] }) } async fn execute( &self, params: serde_json::Value, ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); let method = params["method"].as_str().unwrap_or("GET"); let method_upper = method.to_uppercase(); let url = require_str(¶ms, "url")?; let mut parsed_url = validate_url(url)?; // Resolve DNS once, validate against SSRF blocklist, then pin the // resolved addresses into the reqwest client so it cannot re-resolve // to a different (potentially private) IP. let resolved_addrs = validate_and_resolve_url(&parsed_url).await?; let host = parsed_url .host_str() .ok_or_else(|| ToolError::InvalidParameters("URL missing host".into()))? .to_string(); let client = build_pinned_client( &host, &resolved_addrs, Duration::from_secs(30), reqwest::redirect::Policy::none(), )?; // Parse headers let mut headers_vec = parse_headers_param(params.get("headers"))?; let timeout_secs = parse_timeout_secs_param(params.get("timeout_secs"))?; let save_to = parse_save_to_param(params.get("save_to"))?; let effective_timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)); // Build request let mut request = match method.to_uppercase().as_str() { "GET" => client.get(parsed_url.clone()), "POST" => client.post(parsed_url.clone()), "PUT" => client.put(parsed_url.clone()), "DELETE" => client.delete(parsed_url.clone()), "PATCH" => client.patch(parsed_url.clone()), _ => { return Err(ToolError::InvalidParameters(format!( "unsupported method: {}", method ))); } }; request = request.timeout(effective_timeout); // Add headers for (key, value) in &headers_vec { request = request.header(key.as_str(), value.as_str()); } // Add body if present let body_bytes = if let Some(body) = params.get("body") { if let Some(body_str) = body.as_str() { if body_str.is_empty() { None } else if let Ok(json_body) = serde_json::from_str::(body_str) { let bytes = serde_json::to_vec(&json_body).map_err(|e| { ToolError::InvalidParameters(format!("invalid body JSON: {}", e)) })?; request = request.json(&json_body); Some(bytes) } else { let bytes = body_str.as_bytes().to_vec(); request = request.body(body_str.to_string()); Some(bytes) } } else { let bytes = serde_json::to_vec(body).map_err(|e| { ToolError::InvalidParameters(format!("invalid body JSON: {}", e)) })?; request = request.json(body); Some(bytes) } } else { None }; // Credential injection from shared registry if let (Some(registry), Some(store)) = ( self.credential_registry.as_ref(), self.secrets_store.as_ref(), ) { let cred_host = parsed_url.host_str().unwrap_or(""); let matched: Vec = registry.find_for_host(cred_host); for mapping in &matched { match store .get_decrypted(&ctx.user_id, &mapping.secret_name) .await { Ok(secret) => { let mut injected = InjectedCredentials::empty(); inject_credential(&mut injected, &mapping.location, &secret); for (name, value) in &injected.headers { request = request.header(name.as_str(), value.as_str()); headers_vec.push((name.clone(), value.clone())); } for (name, value) in &injected.query_params { parsed_url.query_pairs_mut().append_pair(name, value); request = request.query(&[(name.as_str(), value.as_str())]); } } Err(e) => { tracing::warn!( secret = %mapping.secret_name, error = %e, "Failed to inject credential for HTTP tool" ); } } } } // Leak detection on outbound request (url/headers/body) let detector = LeakDetector::new(); detector .scan_http_request(parsed_url.as_str(), &headers_vec, body_bytes.as_deref()) .map_err(|e| ToolError::NotAuthorized(format!("{}", e)))?; // Build the interceptor request descriptor for recording/replay let intercept_req = crate::llm::recording::HttpExchangeRequest { method: method_upper, url: parsed_url.to_string(), headers: headers_vec.clone(), body: body_bytes .as_ref() .map(|b| String::from_utf8_lossy(b).into_owned()), }; // Check HTTP interceptor (replay mode returns pre-recorded response) if let Some(ref interceptor) = ctx.http_interceptor && let Some(recorded) = interceptor.before_request(&intercept_req).await { let headers: HashMap = recorded.headers.iter().cloned().collect(); let body: serde_json::Value = serde_json::from_str(&recorded.body) .unwrap_or_else(|_| serde_json::Value::String(recorded.body.clone())); let result = serde_json::json!({ "status": recorded.status, "headers": headers, "body": body }); return Ok(ToolOutput::success(result, start.elapsed()).with_raw(recorded.body)); } // Determine if this is a simple GET (eligible for redirect following). let is_simple_get = method.eq_ignore_ascii_case("GET") && headers_vec.is_empty() && body_bytes.is_none(); // Execute request, optionally following redirects for simple GETs. // Each redirect hop gets its own DNS resolution + SSRF validation + // pinned client to prevent rebinding attacks across hops. let response = if is_simple_get { let mut redirects_remaining = MAX_REDIRECTS; loop { // Build a per-hop pinned client for the current URL. let hop_addrs = validate_and_resolve_url(&parsed_url).await?; let hop_host = parsed_url .host_str() .ok_or_else(|| ToolError::InvalidParameters("URL missing host".into()))? .to_string(); let hop_client = build_pinned_client( &hop_host, &hop_addrs, effective_timeout, reqwest::redirect::Policy::none(), )?; let resp = hop_client .get(parsed_url.clone()) .header( reqwest::header::ACCEPT, "text/markdown, text/html;q=0.9, application/json;q=0.9, */*;q=0.8", ) .send() .await .map_err(|e| { if e.is_timeout() { ToolError::Timeout(effective_timeout) } else { ToolError::ExternalService(e.to_string()) } })?; let status = resp.status().as_u16(); if (300..400).contains(&status) { if redirects_remaining == 0 { return Err(ToolError::ExecutionFailed(format!( "too many redirects (max {})", MAX_REDIRECTS ))); } let location = resp .headers() .get(reqwest::header::LOCATION) .and_then(|v| v.to_str().ok()) .ok_or_else(|| { ToolError::ExecutionFailed(format!( "redirect (HTTP {}) has no Location header", status )) })?; let next_url_str = if location.starts_with("http://") || location.starts_with("https://") { location.to_string() } else { parsed_url .join(location) .map(|u| u.to_string()) .map_err(|e| { ToolError::ExecutionFailed(format!( "could not resolve relative redirect '{}': {}", location, e )) })? }; // SSRF re-validation on every hop (URL structure checks). // DNS resolution + IP validation happens at the top of the // next loop iteration via validate_and_resolve_url. parsed_url = validate_url(&next_url_str)?; let hop_detector = LeakDetector::new(); hop_detector .scan_http_request(parsed_url.as_str(), &[], None) .map_err(|e| ToolError::NotAuthorized(e.to_string()))?; redirects_remaining -= 1; tracing::debug!( to = %parsed_url, hops_left = redirects_remaining, "http tool following redirect" ); continue; } break resp; } } else { let resp = request.send().await.map_err(|e| { if e.is_timeout() { ToolError::Timeout(effective_timeout) } else { ToolError::ExternalService(e.to_string()) } })?; let status = resp.status().as_u16(); // Block redirects for non-simple requests (potential SSRF) if (300..400).contains(&status) { return Err(ToolError::NotAuthorized(format!( "request returned redirect (HTTP {}), which is blocked to prevent SSRF", status ))); } resp }; let status = response.status().as_u16(); let headers: HashMap = response .headers() .iter() .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string()))) .collect(); // Use a larger size limit when saving to disk (file downloads) let saving_to_disk = save_to.is_some(); let max_size = if saving_to_disk { MAX_SAVE_TO_SIZE } else { MAX_RESPONSE_SIZE }; // Pre-check Content-Length header to reject obviously oversized responses // before downloading anything, preventing OOM from malicious servers. if let Some(content_length) = response.headers().get(reqwest::header::CONTENT_LENGTH) && let Ok(s) = content_length.to_str() && let Ok(len) = s.parse::() && len > max_size { tracing::warn!( url = %parsed_url, content_length = len, max = max_size, "Rejected HTTP response: Content-Length exceeds limit" ); return Err(ToolError::ExecutionFailed(format!( "Response Content-Length ({} bytes) exceeds maximum allowed size ({} bytes)", len, max_size ))); } // Stream the response body with a hard size cap. Even if Content-Length was // absent or lied about the size, we stop reading once we exceed the limit. let mut body = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = StreamExt::next(&mut stream).await { let chunk = chunk.map_err(|e| { ToolError::ExternalService(format!("failed to read response body: {}", e)) })?; if body.len() + chunk.len() > max_size { return Err(ToolError::ExecutionFailed(format!( "Response body exceeds maximum allowed size ({} bytes)", max_size ))); } body.extend_from_slice(&chunk); } let body_bytes = bytes::Bytes::from(body); // If save_to is specified, write raw bytes to file and return metadata. if let Some(save_to) = save_to { let saved_to = save_to.clone(); let bytes_clone = body_bytes.clone(); tokio::task::spawn_blocking(move || { let canonical = validate_save_to_path(&save_to)?; std::fs::write(&canonical, &bytes_clone).map_err(|e| { ToolError::ExecutionFailed(format!("failed to write file: {}", e)) })?; Ok::<_, ToolError>(canonical) }) .await .map_err(|e| ToolError::ExecutionFailed(format!("spawn_blocking failed: {}", e)))? .map_err(|e: ToolError| e)?; let result = serde_json::json!({ "status": status, "saved_to": saved_to, "size_bytes": body_bytes.len(), "headers": headers, }); return Ok(ToolOutput::success(result, start.elapsed())); } let body_text = String::from_utf8_lossy(&body_bytes).into_owned(); // Record the HTTP exchange if interceptor is present (recording mode) if let Some(ref interceptor) = ctx.http_interceptor { let resp_headers: Vec<(String, String)> = headers .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); interceptor .after_response( &intercept_req, &crate::llm::recording::HttpExchangeResponse { status, headers: resp_headers, body: body_text.clone(), }, ) .await; } #[cfg(feature = "html-to-markdown")] let body_text = if is_html_response(&headers) { match convert_html_to_markdown(&body_text, parsed_url.as_str()) { Ok(md) => md, Err(e) => { tracing::warn!(url = %parsed_url, error = %e, "HTML-to-markdown conversion failed, returning raw HTML"); body_text } } } else { body_text }; // Try to parse as JSON, fall back to string let body: serde_json::Value = serde_json::from_str(&body_text) .unwrap_or_else(|_| serde_json::Value::String(body_text.clone())); let result = serde_json::json!({ "status": status, "headers": headers, "body": body }); Ok(ToolOutput::success(result, start.elapsed()).with_raw(body_text)) } fn estimated_duration(&self, _params: &serde_json::Value) -> Option { Some(Duration::from_secs(5)) // Average HTTP request time } fn requires_sanitization(&self) -> bool { true // External data always needs sanitization } fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement { let has_credentials = crate::safety::params_contain_manual_credentials(params) || (self.credential_registry.as_ref().is_some_and(|registry| { extract_host_from_params(params) .is_some_and(|host| registry.has_credentials_for_host(&host)) })); if has_credentials { return ApprovalRequirement::UnlessAutoApproved; } // GET requests (or missing method, since GET is the default) are low-risk let method = params["method"].as_str().unwrap_or("GET"); if method.eq_ignore_ascii_case("GET") { return ApprovalRequirement::Never; } ApprovalRequirement::UnlessAutoApproved } fn rate_limit_config(&self) -> Option { Some(crate::tools::tool::ToolRateLimitConfig::new(30, 500)) } } #[cfg(test)] mod tests { use super::*; use crate::testing::credentials::{TEST_OPENAI_API_KEY, test_secrets_store}; #[test] fn test_http_tool_schema_headers_is_array() { let tool = HttpTool::new(); let schema = tool.parameters_schema(); assert_eq!(schema["properties"]["headers"]["type"], "array"); } #[test] fn test_validate_url_rejects_http() { let err = validate_url("http://example.com").unwrap_err(); assert!(err.to_string().contains("https")); } #[test] fn test_validate_url_rejects_localhost() { let err = validate_url("https://localhost:8080").unwrap_err(); assert!(err.to_string().contains("localhost")); } #[test] fn test_validate_url_accepts_https_public() { let url = validate_url("https://example.com").unwrap(); assert_eq!(url.host_str(), Some("example.com")); } #[test] fn test_validate_url_rejects_private_ip_literal() { let err = validate_url("https://192.168.1.1/api").unwrap_err(); assert!(err.to_string().contains("private")); } #[test] fn test_validate_url_rejects_loopback_ip() { let err = validate_url("https://127.0.0.1/api").unwrap_err(); assert!(err.to_string().contains("private")); } #[test] fn test_validate_url_rejects_link_local() { let err = validate_url("https://169.254.169.254/latest/meta-data/").unwrap_err(); assert!(err.to_string().contains("private")); } #[test] fn test_is_disallowed_ip_covers_ranges() { // Private ranges assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)))); // Loopback assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::LOCALHOST))); // Cloud metadata assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new( 169, 254, 169, 254 )))); // Carrier-grade NAT assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)))); // Public assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); } #[test] fn test_is_disallowed_ip_catches_ipv4_mapped_ipv6() { use std::net::Ipv6Addr; // ::ffff:127.0.0.1 (IPv4-mapped loopback) let mapped_loopback = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001)); assert!( is_disallowed_ip(&mapped_loopback), "IPv4-mapped ::ffff:127.0.0.1 should be disallowed" ); // ::ffff:169.254.169.254 (IPv4-mapped cloud metadata) let mapped_metadata = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe)); assert!( is_disallowed_ip(&mapped_metadata), "IPv4-mapped ::ffff:169.254.169.254 should be disallowed" ); // ::ffff:10.0.0.1 (IPv4-mapped private) let mapped_private = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001)); assert!( is_disallowed_ip(&mapped_private), "IPv4-mapped ::ffff:10.0.0.1 should be disallowed" ); // ::ffff:8.8.8.8 (IPv4-mapped public -- should be allowed) let mapped_public = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808)); assert!( !is_disallowed_ip(&mapped_public), "IPv4-mapped ::ffff:8.8.8.8 should be allowed" ); } #[test] fn test_max_response_size_is_reasonable() { // MAX_RESPONSE_SIZE should be 5 MB to prevent OOM while allowing typical API responses. assert_eq!(MAX_RESPONSE_SIZE, 5 * 1024 * 1024); } #[test] fn test_parse_headers_param_accepts_object_legacy_shape() { let headers = serde_json::json!({"Authorization": "Bearer token"}); let parsed = parse_headers_param(Some(&headers)).unwrap(); assert_eq!( parsed, vec![("Authorization".to_string(), "Bearer token".to_string())] ); } #[test] fn test_parse_headers_param_accepts_array_shape() { let headers = serde_json::json!([ {"name": "Authorization", "value": "Bearer token"}, {"name": "X-Test", "value": "1"} ]); let parsed = parse_headers_param(Some(&headers)).unwrap(); assert_eq!( parsed, vec![ ("Authorization".to_string(), "Bearer token".to_string()), ("X-Test".to_string(), "1".to_string()) ] ); } #[test] fn test_parse_headers_param_accepts_stringified_array() { let headers = serde_json::json!("[{\"name\":\"Authorization\",\"value\":\"Bearer token\"}]"); let parsed = parse_headers_param(Some(&headers)).unwrap(); assert_eq!( parsed, vec![("Authorization".to_string(), "Bearer token".to_string())] ); } #[test] fn test_parse_headers_param_rejects_double_string_encoding() { let headers = serde_json::json!("\"hello\""); let err = parse_headers_param(Some(&headers)).unwrap_err(); assert!( err.to_string() .contains("headers string must decode to a JSON object or array"), "unexpected error: {}", err ); } #[test] fn test_parse_timeout_secs_param_accepts_string_integer() { let timeout = serde_json::json!("30"); assert_eq!(parse_timeout_secs_param(Some(&timeout)).unwrap(), Some(30)); } #[test] fn test_parse_timeout_secs_param_treats_empty_string_as_none() { let timeout = serde_json::json!(""); assert_eq!(parse_timeout_secs_param(Some(&timeout)).unwrap(), None); } #[test] fn test_parse_timeout_secs_param_rejects_value_above_cap() { let timeout = serde_json::json!(MAX_TIMEOUT_SECS + 1); let err = parse_timeout_secs_param(Some(&timeout)).unwrap_err(); assert!( err.to_string() .contains(&format!("timeout_secs must be <= {}", MAX_TIMEOUT_SECS)), "unexpected error: {}", err ); } #[test] fn test_parse_timeout_secs_param_rejects_string_value_above_cap() { let timeout = serde_json::json!((MAX_TIMEOUT_SECS + 1).to_string()); let err = parse_timeout_secs_param(Some(&timeout)).unwrap_err(); assert!( err.to_string() .contains(&format!("timeout_secs must be <= {}", MAX_TIMEOUT_SECS)), "unexpected error: {}", err ); } #[test] fn test_parse_save_to_param_treats_empty_string_as_none() { let save_to = serde_json::json!(""); assert_eq!(parse_save_to_param(Some(&save_to)).unwrap(), None); } #[test] fn test_http_tool_schema_body_is_freeform() { let schema = HttpTool::new().parameters_schema(); let body = schema .get("properties") .and_then(|p| p.get("body")) .expect("body schema missing"); // Body is intentionally freeform (no "type" constraint) for OpenAI // compatibility. OpenAI rejects union types containing "array" unless // "items" is also specified, and body accepts any JSON value. assert!( body.get("type").is_none(), "body schema should not have a 'type' to be freeform for OpenAI compatibility" ); } // ── Approval requirement tests ────────────────────────────────────── #[test] fn test_get_no_auth_headers_returns_never() { let tool = HttpTool::new(); let params = serde_json::json!({ "method": "GET", "url": "https://api.example.com/data" }); assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never); } #[test] fn test_post_no_auth_headers_returns_unless_auto_approved() { let tool = HttpTool::new(); let params = serde_json::json!({ "method": "POST", "url": "https://api.example.com/data" }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); } #[test] fn test_auth_header_object_format_returns_unless_auto_approved() { let tool = HttpTool::new(); let params = serde_json::json!({ "method": "GET", "url": "https://api.example.com/data", "headers": {"Authorization": "Bearer token123"} }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); } #[test] fn test_auth_header_array_format_returns_unless_auto_approved() { let tool = HttpTool::new(); let params = serde_json::json!({ "method": "GET", "url": "https://api.example.com/data", "headers": [{"name": "Authorization", "value": "Bearer token123"}] }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); } #[test] fn test_auth_header_case_insensitive() { let tool = HttpTool::new(); // Object format with mixed case let params = serde_json::json!({ "method": "GET", "url": "https://example.com", "headers": {"AUTHORIZATION": "Bearer x"} }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); // Array format with mixed case let params = serde_json::json!({ "method": "GET", "url": "https://example.com", "headers": [{"name": "X-Api-Key", "value": "key123"}] }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); } #[test] fn test_all_auth_header_names_detected() { let tool = HttpTool::new(); for header_name in [ "authorization", "x-api-key", "cookie", "proxy-authorization", "x-auth-token", "api-key", "x-token", "x-access-token", "x-session-token", "x-csrf-token", "x-secret", "x-api-secret", ] { let mut headers = serde_json::Map::new(); headers.insert(header_name.to_string(), serde_json::json!("value")); let params = serde_json::json!({ "method": "GET", "url": "https://example.com", "headers": headers }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved, "Header '{}' should trigger UnlessAutoApproved approval", header_name ); } } #[test] fn test_get_non_auth_headers_return_never() { let tool = HttpTool::new(); let params = serde_json::json!({ "method": "GET", "url": "https://example.com", "headers": {"Content-Type": "application/json", "Accept": "text/html"} }); assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never); } #[test] fn test_get_empty_headers_return_never() { let tool = HttpTool::new(); // Empty object let params = serde_json::json!({ "method": "GET", "url": "https://example.com", "headers": {} }); assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never); // Empty array let params = serde_json::json!({ "method": "GET", "url": "https://example.com", "headers": [] }); assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never); } // ── Credential registry approval tests ───────────────────────────── #[test] fn test_host_with_credential_mapping_returns_unless_auto_approved() { use crate::secrets::CredentialMapping; use crate::tools::wasm::SharedCredentialRegistry; let registry = Arc::new(SharedCredentialRegistry::new()); registry.add_mappings(vec![CredentialMapping::bearer( "openai_key", "api.openai.com", )]); let tool = HttpTool::new().with_credentials( registry, // secrets_store is not used in requires_approval, just needs to be present Arc::new(test_secrets_store()), ); let params = serde_json::json!({ "method": "GET", "url": "https://api.openai.com/v1/models" }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); } #[test] fn test_get_host_without_credential_mapping_returns_never() { use crate::tools::wasm::SharedCredentialRegistry; let registry = Arc::new(SharedCredentialRegistry::new()); // Empty registry - no credential mappings let tool = HttpTool::new().with_credentials(registry, Arc::new(test_secrets_store())); let params = serde_json::json!({ "method": "GET", "url": "https://api.example.com/data" }); assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never); } #[test] fn test_url_query_param_credential_returns_unless_auto_approved() { let tool = HttpTool::new(); let params = serde_json::json!({ "method": "GET", "url": "https://api.example.com/data?api_key=secret123" }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); } #[test] fn test_bearer_value_in_custom_header_returns_unless_auto_approved() { let tool = HttpTool::new(); let params = serde_json::json!({ "method": "GET", "url": "https://example.com", "headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")} }); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved ); } /// Regression test: credentialed HTTP requests must return /// `UnlessAutoApproved` (not `Always`) so that the session auto-approve /// set is respected when the user says "always". #[test] fn test_credentialed_requests_respect_auto_approve() { let tool = HttpTool::new(); // Manual credentials (Authorization header) let params = serde_json::json!({ "method": "GET", "url": "https://api.github.com/orgs/Casa", "headers": {"Authorization": "Bearer ghp_abc123"} }); // Must NOT be Always — Always ignores the session auto-approve set assert_ne!( tool.requires_approval(¶ms), ApprovalRequirement::Always, "Credentialed HTTP requests must not return Always; use UnlessAutoApproved" ); assert_eq!( tool.requires_approval(¶ms), ApprovalRequirement::UnlessAutoApproved, ); } #[test] fn test_extract_host_from_params_valid() { let params = serde_json::json!({ "url": "https://api.example.com/path" }); assert_eq!( extract_host_from_params(¶ms), Some("api.example.com".to_string()) ); } #[test] fn test_extract_host_from_params_missing_url() { let params = serde_json::json!({"method": "GET"}); assert_eq!(extract_host_from_params(¶ms), None); } #[test] fn test_requires_approval_with_stringified_http_params() { use crate::tools::wasm::SharedCredentialRegistry; let tool = HttpTool::new().with_credentials( Arc::new(SharedCredentialRegistry::new()), Arc::new(test_secrets_store()), ); let req = serde_json::json!({ "body": "", "headers": "[]", "method": "GET", "save_to": "", "timeout_secs": "30", "url": "https://r.jina.ai/http://news.baidu.com/" }); let _ = tool.requires_approval(&req); } // ── DNS pinning tests ───────────────────────────────────────────── #[tokio::test] async fn test_validate_and_resolve_rejects_loopback_hostname() { // "localhost" is blocked at the URL validation level, but verify // that validate_and_resolve_url also catches loopback IPs returned // by DNS for any hostname that resolves to 127.0.0.1. let url = reqwest::Url::parse("https://127.0.0.1/test").unwrap(); // 127.0.0.1 is an IP literal -- validate_url blocks it before // we ever reach validate_and_resolve_url, but the function should // still reject if called directly. let err = validate_and_resolve_url(&url).await.unwrap_err(); assert!( err.to_string().contains("disallowed"), "expected disallowed IP error, got: {}", err ); } // Requires network access -- run with: cargo test -- --ignored #[ignore] #[tokio::test] async fn test_validate_and_resolve_accepts_public_host() { // example.com resolves to public IPs. let url = reqwest::Url::parse("https://example.com").unwrap(); let addrs = validate_and_resolve_url(&url).await.unwrap(); assert!(!addrs.is_empty(), "should resolve to at least one address"); for addr in &addrs { assert!( !is_disallowed_ip(&addr.ip()), "example.com resolved to disallowed IP: {}", addr.ip() ); } } #[test] fn test_build_pinned_client_succeeds() { let addrs = vec![SocketAddr::new( IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443, )]; let client = build_pinned_client( "example.com", &addrs, Duration::from_secs(10), reqwest::redirect::Policy::none(), ); assert!(client.is_ok(), "should build client successfully"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn requires_approval_multi_thread_no_panic() { use crate::secrets::CredentialMapping; use crate::tools::wasm::SharedCredentialRegistry; // Test with credential registry (uses std::sync::RwLock - should be safe) let registry = Arc::new(SharedCredentialRegistry::new()); registry.add_mappings(vec![CredentialMapping::bearer("test_key", "api.test.com")]); let tool = HttpTool::new().with_credentials(registry, Arc::new(test_secrets_store())); // These calls should not panic in multi-thread runtime let params_no_auth = serde_json::json!({ "method": "GET", "url": "https://api.example.com/data" }); let _ = tool.requires_approval(¶ms_no_auth); let params_with_cred = serde_json::json!({ "method": "GET", "url": "https://api.test.com/v1/models" }); let _ = tool.requires_approval(¶ms_with_cred); let params_with_auth = serde_json::json!({ "method": "GET", "url": "https://api.example.com", "headers": {"Authorization": "Bearer token"} }); let _ = tool.requires_approval(¶ms_with_auth); } // ── save_to path validation tests ───────────────────────────────────── #[test] fn test_save_to_rejects_path_outside_tmp() { let err = validate_save_to_path("/etc/passwd").unwrap_err(); assert!(err.to_string().contains("must be under /tmp/")); } #[test] fn test_save_to_rejects_home_dir() { let err = validate_save_to_path("/home/user/file.txt").unwrap_err(); assert!(err.to_string().contains("must be under /tmp/")); } #[test] fn test_save_to_rejects_traversal_via_dotdot() { let err = validate_save_to_path("/tmp/../../etc/passwd").unwrap_err(); let msg = err.to_string(); assert!( msg.contains("escapes") || msg.contains("resolves outside"), "expected path traversal rejection, got: {}", msg ); } #[test] fn test_save_to_rejects_deep_traversal() { let err = validate_save_to_path("/tmp/a/b/../../../../etc/shadow").unwrap_err(); let msg = err.to_string(); assert!( msg.contains("escapes") || msg.contains("resolves outside"), "expected path traversal rejection, got: {}", msg ); } #[test] fn test_save_to_accepts_simple_tmp_path() { let path = validate_save_to_path("/tmp/test_ironclaw_photo.jpg").unwrap(); assert!(path.starts_with("/tmp")); let _ = std::fs::remove_file(&path); } #[test] fn test_save_to_accepts_nested_tmp_path() { let path = validate_save_to_path("/tmp/ironclaw_test_subdir/nested/file.png").unwrap(); assert!(path.starts_with("/tmp")); let _ = std::fs::remove_dir_all("/tmp/ironclaw_test_subdir"); } #[test] fn test_save_to_rejects_bare_tmp() { let err = validate_save_to_path("/tmp").unwrap_err(); assert!(err.to_string().contains("must be under /tmp/")); } }