1use async_trait::async_trait;
2use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
3use serde_json::json;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::Duration;
8use zeroclaw_api::tool::{Tool, ToolResult};
9use zeroclaw_config::policy::SecurityPolicy;
10
11pub struct HttpRequestTool {
14 security: Arc<SecurityPolicy>,
15 allowed_domains: Vec<String>,
16 max_response_size: usize,
17 timeout_secs: u64,
18 allow_private_hosts: bool,
19 allowed_private_hosts: Vec<String>,
20 config_path: Option<PathBuf>,
21 secrets_encrypt: bool,
22}
23
24impl HttpRequestTool {
25 pub fn new(
26 security: Arc<SecurityPolicy>,
27 allowed_domains: Vec<String>,
28 max_response_size: usize,
29 timeout_secs: u64,
30 allow_private_hosts: bool,
31 allowed_private_hosts: Vec<String>,
32 ) -> anyhow::Result<Self> {
33 Ok(Self {
34 security,
35 allowed_domains: normalize_allowed_domains(allowed_domains)?,
36 max_response_size,
37 timeout_secs,
38 allow_private_hosts,
39 allowed_private_hosts: normalize_allowed_domains(allowed_private_hosts)?,
40 config_path: None,
41 secrets_encrypt: false,
42 })
43 }
44
45 pub fn new_with_config(
46 security: Arc<SecurityPolicy>,
47 allowed_domains: Vec<String>,
48 max_response_size: usize,
49 timeout_secs: u64,
50 allow_private_hosts: bool,
51 allowed_private_hosts: Vec<String>,
52 config_path: PathBuf,
53 secrets_encrypt: bool,
54 ) -> anyhow::Result<Self> {
55 Ok(Self {
56 security,
57 allowed_domains: normalize_allowed_domains(allowed_domains)?,
58 max_response_size,
59 timeout_secs,
60 allow_private_hosts,
61 allowed_private_hosts: normalize_allowed_domains(allowed_private_hosts)?,
62 config_path: Some(config_path),
63 secrets_encrypt,
64 })
65 }
66
67 fn validate_url(&self, raw_url: &str) -> anyhow::Result<String> {
68 let url = raw_url.trim();
69
70 if url.is_empty() {
71 anyhow::bail!("URL cannot be empty");
72 }
73
74 if url.chars().any(char::is_whitespace) {
75 anyhow::bail!("URL cannot contain whitespace");
76 }
77
78 if !url.starts_with("http://") && !url.starts_with("https://") {
79 anyhow::bail!("Only http:// and https:// URLs are allowed");
80 }
81
82 if self.allowed_domains.is_empty() {
83 anyhow::bail!(
84 "HTTP request tool is enabled but no allowed_domains are configured. Add [http_request].allowed_domains in config.toml"
85 );
86 }
87
88 let host = extract_host(url)?;
89 let private_host = is_private_or_local_host(&host);
90 let private_host_explicitly_allowed =
91 private_host && host_matches_allowlist(&host, &self.allowed_private_hosts);
92
93 if private_host && !private_host_explicitly_allowed && !self.allow_private_hosts {
94 anyhow::bail!("Blocked local/private host: {host}");
95 }
96
97 if private_host_explicitly_allowed {
98 return Ok(url.to_string());
99 }
100
101 if !host_matches_allowlist(&host, &self.allowed_domains) {
102 anyhow::bail!("Host '{host}' is not in http_request.allowed_domains");
103 }
104
105 Ok(url.to_string())
106 }
107
108 fn validate_method(&self, method: &str) -> anyhow::Result<reqwest::Method> {
109 match method.to_uppercase().as_str() {
110 "GET" => Ok(reqwest::Method::GET),
111 "POST" => Ok(reqwest::Method::POST),
112 "PUT" => Ok(reqwest::Method::PUT),
113 "DELETE" => Ok(reqwest::Method::DELETE),
114 "PATCH" => Ok(reqwest::Method::PATCH),
115 "HEAD" => Ok(reqwest::Method::HEAD),
116 "OPTIONS" => Ok(reqwest::Method::OPTIONS),
117 _ => anyhow::bail!(
118 "Unsupported HTTP method: {method}. Supported: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"
119 ),
120 }
121 }
122
123 fn parse_headers(&self, headers: &serde_json::Value) -> anyhow::Result<HeaderMap> {
124 let mut result = HeaderMap::new();
125 if let Some(obj) = headers.as_object() {
126 for (key, value) in obj {
127 let Some(str_val) = value.as_str() else {
128 anyhow::bail!("Header '{key}' value must be a string, got: {}", value);
129 };
130 let header_name = HeaderName::from_str(key)
131 .map_err(|e| anyhow::Error::msg(format!("Invalid header name '{key}': {e}")))?;
132 let header_value = HeaderValue::from_str(str_val).map_err(|e| {
133 anyhow::Error::msg(format!("Invalid value for header '{key}': {e}"))
134 })?;
135 result.insert(header_name, header_value);
136 }
137 }
138 Ok(result)
139 }
140
141 fn validate_secret_name(secret_name: &str) -> anyhow::Result<()> {
142 if secret_name.is_empty() {
143 anyhow::bail!("auth_secret cannot be empty");
144 }
145 if secret_name.len() > 64 {
146 anyhow::bail!("auth_secret must be 64 characters or fewer");
147 }
148 if !secret_name
149 .chars()
150 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
151 {
152 anyhow::bail!(
153 "auth_secret must contain only ASCII letters, numbers, underscores, or hyphens"
154 );
155 }
156 Ok(())
157 }
158
159 fn resolve_auth_secret(&self, secret_name: &str) -> anyhow::Result<String> {
160 Self::validate_secret_name(secret_name)?;
161 self.reload_auth_secret(secret_name)
162 }
163
164 fn reload_auth_secret(&self, secret_name: &str) -> anyhow::Result<String> {
165 let config_path = self.config_path.as_ref().ok_or_else(|| {
166 anyhow::Error::msg("auth_secret requires runtime config reload support")
167 })?;
168 if config_path.as_os_str().is_empty() {
169 anyhow::bail!("auth_secret requires a config.toml path");
170 }
171
172 let contents = std::fs::read_to_string(config_path).map_err(|e| {
173 anyhow::Error::msg(format!(
174 "Failed to read config file {} for auth_secret '{secret_name}': {e}",
175 config_path.display()
176 ))
177 })?;
178 let config: zeroclaw_config::schema::Config = toml::from_str(&contents).map_err(|e| {
179 anyhow::Error::msg(format!(
180 "Failed to parse config file {} for auth_secret '{secret_name}': {e}",
181 config_path.display()
182 ))
183 })?;
184
185 let raw_secret = config
186 .http_request
187 .secrets
188 .get(secret_name)
189 .filter(|secret| !secret.is_empty())
190 .ok_or_else(|| anyhow::Error::msg(format!("auth_secret '{secret_name}' not found")))?;
191
192 if zeroclaw_config::secrets::SecretStore::is_encrypted(raw_secret) {
193 let zeroclaw_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
194 let store =
195 zeroclaw_config::secrets::SecretStore::new(zeroclaw_dir, self.secrets_encrypt);
196 let plaintext = store.decrypt(raw_secret)?;
197 if plaintext.is_empty() {
198 anyhow::bail!("auth_secret '{secret_name}' is empty after decryption");
199 }
200 Ok(plaintext)
201 } else {
202 Ok(raw_secret.clone())
203 }
204 }
205
206 fn apply_auth_secret(
207 &self,
208 headers: &mut HeaderMap,
209 auth_secret: Option<&str>,
210 ) -> anyhow::Result<()> {
211 let Some(secret_name) = auth_secret else {
212 return Ok(());
213 };
214 let secret = self.resolve_auth_secret(secret_name)?;
215 let header_value = HeaderValue::from_str(&secret).map_err(|e| {
216 anyhow::Error::msg(format!(
217 "Invalid value for auth_secret '{secret_name}' as Authorization header: {e}"
218 ))
219 })?;
220 headers.insert(AUTHORIZATION, header_value);
221 Ok(())
222 }
223
224 #[cfg(test)]
225 fn redact_headers_for_display(headers: &[(String, String)]) -> Vec<(String, String)> {
226 headers
227 .iter()
228 .map(|(key, value)| {
229 let lower = key.to_lowercase();
230 let is_sensitive = lower.contains("authorization")
231 || lower.contains("api-key")
232 || lower.contains("apikey")
233 || lower.contains("token")
234 || lower.contains("secret");
235 if is_sensitive {
236 (key.clone(), "***REDACTED***".into())
237 } else {
238 (key.clone(), value.clone())
239 }
240 })
241 .collect()
242 }
243
244 async fn execute_request(
245 &self,
246 url: &str,
247 method: reqwest::Method,
248 headers: HeaderMap,
249 body: Option<&str>,
250 ) -> anyhow::Result<reqwest::Response> {
251 let timeout_secs = if self.timeout_secs == 0 {
252 ::zeroclaw_log::record!(
253 WARN,
254 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
255 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
256 "http_request: timeout_secs is 0, using safe default of 30s"
257 );
258 30
259 } else {
260 self.timeout_secs
261 };
262 let builder = reqwest::Client::builder()
263 .timeout(Duration::from_secs(timeout_secs))
264 .connect_timeout(Duration::from_secs(10))
265 .redirect(reqwest::redirect::Policy::none());
266 let builder =
267 zeroclaw_config::schema::apply_runtime_proxy_to_builder(builder, "tool.http_request");
268 let client = builder.build()?;
269
270 let mut request = client.request(method, url).headers(headers);
271
272 if let Some(body_str) = body {
273 request = request.body(body_str.to_string());
274 }
275
276 Ok(request.send().await?)
277 }
278
279 fn truncate_response(&self, text: &str) -> String {
280 if self.max_response_size == 0 {
282 return text.to_string();
283 }
284 if text.len() > self.max_response_size {
285 let mut truncated = text
286 .chars()
287 .take(self.max_response_size)
288 .collect::<String>();
289 truncated.push_str("\n\n... [Response truncated due to size limit] ...");
290 truncated
291 } else {
292 text.to_string()
293 }
294 }
295}
296
297#[async_trait]
298impl Tool for HttpRequestTool {
299 fn name(&self) -> &str {
300 "http_request"
301 }
302
303 fn description(&self) -> &str {
304 "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \
305 Security constraints: allowlist-only domains, local/private hosts blocked unless explicitly configured, configurable timeout and response size limits."
306 }
307
308 fn parameters_schema(&self) -> serde_json::Value {
309 json!({
310 "type": "object",
311 "properties": {
312 "url": {
313 "type": "string",
314 "description": "HTTP or HTTPS URL to request"
315 },
316 "method": {
317 "type": "string",
318 "description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)",
319 "default": "GET"
320 },
321 "headers": {
322 "type": "object",
323 "description": "Optional HTTP headers as key-value pairs. Use auth_secret for Authorization values that should come from config secrets.",
324 "default": {}
325 },
326 "auth_secret": {
327 "type": "string",
328 "description": "Name of a secret in [http_request.secrets] to send as the Authorization header. Overrides any literal Authorization header."
329 },
330 "body": {
331 "type": "string",
332 "description": "Optional request body (for POST, PUT, PATCH requests)"
333 }
334 },
335 "required": ["url"]
336 })
337 }
338
339 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
340 let url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| {
341 ::zeroclaw_log::record!(
342 WARN,
343 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
344 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
345 .with_attrs(::serde_json::json!({"param": "url"})),
346 "http_request: missing url parameter"
347 );
348 anyhow::Error::msg("Missing 'url' parameter")
349 })?;
350
351 let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
352 let headers_val = args.get("headers").cloned().unwrap_or(json!({}));
353 let auth_secret = match args.get("auth_secret") {
354 Some(value) => match value.as_str() {
355 Some(secret_name) => Some(secret_name),
356 None => {
357 return Ok(ToolResult {
358 success: false,
359 output: String::new(),
360 error: Some("'auth_secret' must be a string".into()),
361 });
362 }
363 },
364 None => None,
365 };
366 let body = args.get("body").and_then(|v| v.as_str());
367
368 if !self.security.can_act() {
369 return Ok(ToolResult {
370 success: false,
371 output: String::new(),
372 error: Some("Action blocked: autonomy is read-only".into()),
373 });
374 }
375
376 let url = match self.validate_url(url) {
380 Ok(v) => v,
381 Err(e) => {
382 return Ok(ToolResult {
383 success: false,
384 output: String::new(),
385 error: Some(e.to_string()),
386 });
387 }
388 };
389
390 let method = match self.validate_method(method_str) {
391 Ok(m) => m,
392 Err(e) => {
393 return Ok(ToolResult {
394 success: false,
395 output: String::new(),
396 error: Some(e.to_string()),
397 });
398 }
399 };
400
401 let mut request_headers = match self.parse_headers(&headers_val) {
402 Ok(h) => h,
403 Err(e) => {
404 return Ok(ToolResult {
405 success: false,
406 output: String::new(),
407 error: Some(e.to_string()),
408 });
409 }
410 };
411 if let Err(e) = self.apply_auth_secret(&mut request_headers, auth_secret) {
412 return Ok(ToolResult {
413 success: false,
414 output: String::new(),
415 error: Some(e.to_string()),
416 });
417 }
418
419 match self
420 .execute_request(&url, method, request_headers, body)
421 .await
422 {
423 Ok(response) => {
424 let status = response.status();
425 let status_code = status.as_u16();
426
427 let response_headers = response.headers().iter();
429 let headers_text = response_headers
430 .map(|(k, _)| {
431 let is_sensitive = k.as_str().to_lowercase().contains("set-cookie");
432 if is_sensitive {
433 format!("{}: ***REDACTED***", k.as_str())
434 } else {
435 format!("{}: {:?}", k.as_str(), k.as_str())
436 }
437 })
438 .collect::<Vec<_>>()
439 .join(", ");
440
441 let response_text = match response.text().await {
443 Ok(text) => self.truncate_response(&text),
444 Err(e) => format!("[Failed to read response body: {e}]"),
445 };
446
447 let output = format!(
448 "Status: {} {}\nResponse Headers: {}\n\nResponse Body:\n{}",
449 status_code,
450 status.canonical_reason().unwrap_or("Unknown"),
451 headers_text,
452 response_text
453 );
454
455 Ok(ToolResult {
456 success: status.is_success(),
457 output,
458 error: if status.is_client_error() || status.is_server_error() {
459 Some(format!("HTTP {}", status_code))
460 } else {
461 None
462 },
463 })
464 }
465 Err(e) => Ok(ToolResult {
466 success: false,
467 output: String::new(),
468 error: Some(format!("HTTP request failed: {e}")),
469 }),
470 }
471 }
472}
473
474fn normalize_allowed_domains(domains: Vec<String>) -> anyhow::Result<Vec<String>> {
477 let mut rejected = Vec::new();
478 let mut normalized = domains
479 .into_iter()
480 .filter_map(|d| {
481 normalize_domain(&d).or_else(|| {
482 rejected.push(d.clone());
483 None
484 })
485 })
486 .collect::<Vec<_>>();
487 if !rejected.is_empty() {
488 anyhow::bail!(
489 "Invalid http_request.allowed_domains entry(s): [{}]. Each entry must be a valid domain, hostname, IPv4, or IPv6 address.",
490 rejected.join(", ")
491 );
492 }
493 normalized.sort_unstable();
494 normalized.dedup();
495 Ok(normalized)
496}
497
498fn normalize_domain(raw: &str) -> Option<String> {
499 let input = raw.trim();
500 if input.is_empty() || input.chars().any(char::is_whitespace) {
501 return None;
502 }
503
504 let bare_ip = match (input.starts_with('['), input.ends_with(']')) {
505 (true, true) => &input[1..input.len() - 1],
506 (false, false) => input,
507 _ => return None,
508 };
509 if let Ok(ip) = bare_ip.parse::<std::net::IpAddr>() {
510 return Some(ip.to_string().to_lowercase());
511 }
512
513 let parsed = reqwest::Url::parse(input)
514 .or_else(|_| reqwest::Url::parse(&format!("https://{input}")))
515 .ok()?;
516
517 if !parsed.username().is_empty() || parsed.password().is_some() {
518 return None;
519 }
520
521 let host = parsed.host_str()?;
522 let trimmed = host.trim();
523 let host_no_brackets = match (trimmed.starts_with('['), trimmed.ends_with(']')) {
524 (true, true) => &trimmed[1..trimmed.len() - 1],
525 (false, false) => trimmed,
526 _ => return None,
527 };
528 let normalized = host_no_brackets
529 .trim_start_matches('.')
530 .trim_end_matches('.');
531 if normalized.is_empty() {
532 return None;
533 }
534
535 Some(normalized.to_lowercase())
536}
537
538fn extract_host(url: &str) -> anyhow::Result<String> {
539 if !url.starts_with("http://") && !url.starts_with("https://") {
540 ::zeroclaw_log::record!(
541 WARN,
542 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
543 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
544 .with_attrs(::serde_json::json!({"url": url})),
545 "http_request: non-http(s) URL rejected"
546 );
547 anyhow::bail!("Only http:// and https:// URLs are allowed");
548 }
549
550 let parsed = reqwest::Url::parse(url).map_err(|e| {
551 ::zeroclaw_log::record!(
552 WARN,
553 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
554 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
555 .with_attrs(::serde_json::json!({"url": url})),
556 "http_request: invalid URL"
557 );
558 anyhow::Error::msg(format!("Invalid URL format: {e}"))
559 })?;
560
561 if !parsed.username().is_empty() || parsed.password().is_some() {
562 anyhow::bail!("URL userinfo is not allowed");
563 }
564
565 let host = parsed
566 .host_str()
567 .ok_or_else(|| anyhow::Error::msg("URL must include a host"))?;
568
569 let trimmed = host.trim();
570 let host_no_brackets = match (trimmed.starts_with('['), trimmed.ends_with(']')) {
571 (true, true) => &trimmed[1..trimmed.len() - 1],
572 (false, false) => trimmed,
573 _ => {
574 anyhow::bail!("URL host has unmatched IPv6 brackets");
575 }
576 };
577 let host = host_no_brackets.trim_end_matches('.').to_lowercase();
578
579 if host.is_empty() {
580 anyhow::bail!("URL must include a valid host");
581 }
582
583 Ok(host)
584}
585
586fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool {
587 if allowed_domains.iter().any(|domain| domain == "*") {
588 return true;
589 }
590
591 let host_is_ip = host.parse::<std::net::IpAddr>().is_ok();
592 allowed_domains.iter().any(|domain| {
593 if host_is_ip || domain.parse::<std::net::IpAddr>().is_ok() {
594 host == domain
595 } else {
596 host == domain
597 || host
598 .strip_suffix(domain)
599 .is_some_and(|prefix| prefix.ends_with('.'))
600 }
601 })
602}
603
604fn is_private_or_local_host(host: &str) -> bool {
605 let bare = host
607 .strip_prefix('[')
608 .and_then(|h| h.strip_suffix(']'))
609 .unwrap_or(host);
610
611 let has_local_tld = bare
612 .rsplit('.')
613 .next()
614 .is_some_and(|label| label == "local");
615
616 if bare == "localhost" || bare.ends_with(".localhost") || has_local_tld {
617 return true;
618 }
619
620 if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
621 return match ip {
622 std::net::IpAddr::V4(v4) => is_non_global_v4(v4),
623 std::net::IpAddr::V6(v6) => is_non_global_v6(v6),
624 };
625 }
626
627 false
628}
629
630fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
632 let [a, b, c, _] = v4.octets();
633 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.is_multicast() || (a == 100 && (64..=127).contains(&b)) || a >= 240 || (a == 192 && b == 0 && (c == 0 || c == 2)) || (a == 198 && b == 51) || (a == 203 && b == 0) || (a == 198 && (18..=19).contains(&b)) }
646
647fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
649 let segs = v6.segments();
650 v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() || (segs[0] & 0xfe00) == 0xfc00 || (segs[0] & 0xffc0) == 0xfe80 || (segs[0] == 0x2001 && segs[1] == 0x0db8) || v6.to_ipv4_mapped().is_some_and(is_non_global_v4)
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use reqwest::header::AUTHORIZATION;
663 use std::path::PathBuf;
664 use tempfile::TempDir;
665 use zeroclaw_config::autonomy::AutonomyLevel;
666 use zeroclaw_config::policy::SecurityPolicy;
667
668 fn test_tool(allowed_domains: Vec<&str>) -> HttpRequestTool {
669 test_tool_with_private(allowed_domains, false)
670 }
671
672 fn test_tool_with_private(
673 allowed_domains: Vec<&str>,
674 allow_private_hosts: bool,
675 ) -> HttpRequestTool {
676 test_tool_with_private_allowlist(allowed_domains, allow_private_hosts, Vec::new())
677 }
678
679 fn test_tool_with_private_allowlist(
680 allowed_domains: Vec<&str>,
681 allow_private_hosts: bool,
682 allowed_private_hosts: Vec<&str>,
683 ) -> HttpRequestTool {
684 let security = Arc::new(SecurityPolicy {
685 autonomy: AutonomyLevel::Supervised,
686 ..SecurityPolicy::default()
687 });
688 HttpRequestTool::new(
689 security,
690 allowed_domains.into_iter().map(String::from).collect(),
691 1_000_000,
692 30,
693 allow_private_hosts,
694 allowed_private_hosts
695 .into_iter()
696 .map(String::from)
697 .collect(),
698 )
699 .unwrap()
700 }
701
702 fn test_tool_with_auth_config(config_path: PathBuf, secrets_encrypt: bool) -> HttpRequestTool {
703 let security = Arc::new(SecurityPolicy {
704 autonomy: AutonomyLevel::Supervised,
705 ..SecurityPolicy::default()
706 });
707 HttpRequestTool::new_with_config(
708 security,
709 vec!["example.com".into()],
710 1_000_000,
711 30,
712 false,
713 Vec::new(),
714 config_path,
715 secrets_encrypt,
716 )
717 .unwrap()
718 }
719
720 #[test]
721 fn schema_includes_auth_secret_parameter() {
722 let tool = test_tool(vec!["example.com"]);
723 let schema = tool.parameters_schema();
724 let properties = schema["properties"].as_object().expect("schema properties");
725
726 assert!(
727 properties.contains_key("auth_secret"),
728 "http_request schema must expose auth_secret"
729 );
730 }
731
732 #[test]
733 fn resolve_auth_secret_requires_config_reload_support() {
734 let tool = test_tool(vec!["example.com"]);
735
736 let err = tool.resolve_auth_secret("api_token").unwrap_err();
737 assert!(
738 err.to_string()
739 .contains("auth_secret requires runtime config reload support"),
740 "auth_secret without config path must fail clearly: {err}"
741 );
742 }
743
744 #[test]
745 fn auth_secret_overrides_explicit_authorization_header() {
746 let tmp = TempDir::new().unwrap();
747 let config_path = tmp.path().join("config.toml");
748 std::fs::write(
749 &config_path,
750 r#"
751[http_request.secrets]
752api_token = "Bearer from-secret"
753"#,
754 )
755 .unwrap();
756 let tool = test_tool_with_auth_config(config_path, false);
757 let mut headers = tool
758 .parse_headers(&json!({"Authorization": "Bearer literal"}))
759 .unwrap();
760
761 tool.apply_auth_secret(&mut headers, Some("api_token"))
762 .unwrap();
763
764 assert_eq!(
765 headers.get(AUTHORIZATION).unwrap(),
766 "Bearer from-secret",
767 "auth_secret must win over literal Authorization headers"
768 );
769 }
770
771 #[test]
772 fn auth_secret_reloads_plain_config_value_without_boot_secret() {
773 let tmp = TempDir::new().unwrap();
774 let config_path = tmp.path().join("config.toml");
775 std::fs::write(
776 &config_path,
777 r#"
778[http_request.secrets]
779api_token = "Bearer from-disk"
780"#,
781 )
782 .unwrap();
783
784 let security = Arc::new(SecurityPolicy {
785 autonomy: AutonomyLevel::Supervised,
786 ..SecurityPolicy::default()
787 });
788 let tool = HttpRequestTool::new_with_config(
789 security,
790 vec!["example.com".into()],
791 1_000_000,
792 30,
793 false,
794 Vec::new(),
795 config_path,
796 false,
797 )
798 .unwrap();
799
800 assert_eq!(
801 tool.resolve_auth_secret("api_token").unwrap(),
802 "Bearer from-disk"
803 );
804 }
805
806 #[test]
807 fn auth_secret_decrypts_reloaded_config_value() {
808 let tmp = TempDir::new().unwrap();
809 let config_path = tmp.path().join("config.toml");
810 let store = zeroclaw_config::secrets::SecretStore::new(tmp.path(), true);
811 let encrypted = store.encrypt("Bearer encrypted-secret").unwrap();
812 std::fs::write(
813 &config_path,
814 format!(
815 r#"
816[http_request.secrets]
817api_token = "{encrypted}"
818"#
819 ),
820 )
821 .unwrap();
822
823 let security = Arc::new(SecurityPolicy {
824 autonomy: AutonomyLevel::Supervised,
825 ..SecurityPolicy::default()
826 });
827 let tool = HttpRequestTool::new_with_config(
828 security,
829 vec!["example.com".into()],
830 1_000_000,
831 30,
832 false,
833 Vec::new(),
834 config_path,
835 true,
836 )
837 .unwrap();
838 let mut headers = tool
839 .parse_headers(&json!({"Authorization": "Bearer literal"}))
840 .unwrap();
841
842 tool.apply_auth_secret(&mut headers, Some("api_token"))
843 .unwrap();
844
845 assert_eq!(
846 headers.get(AUTHORIZATION).unwrap(),
847 "Bearer encrypted-secret"
848 );
849 }
850
851 #[tokio::test]
852 async fn execute_sends_auth_secret_as_authorization_header() {
853 let listener = match tokio::net::TcpListener::bind("[::1]:0").await {
854 Ok(l) => l,
855 Err(_) => return, };
857 let port = listener.local_addr().unwrap().port();
858 let (seen_tx, seen_rx) = tokio::sync::oneshot::channel();
859
860 let server_handle = zeroclaw_spawn::spawn!(async move {
861 if let Ok((mut stream, _)) = listener.accept().await {
862 use tokio::io::{AsyncReadExt, AsyncWriteExt};
863
864 let mut buf = [0_u8; 4096];
865 let n = stream.read(&mut buf).await.unwrap_or(0);
866 let request = String::from_utf8_lossy(&buf[..n]).to_ascii_lowercase();
867 let _ = seen_tx.send(request.contains("authorization: bearer from-secret"));
868
869 let response =
870 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
871 let _ = stream.write_all(response).await;
872 let _ = stream.flush().await;
873 }
874 });
875
876 let security = Arc::new(SecurityPolicy {
877 autonomy: AutonomyLevel::Supervised,
878 ..SecurityPolicy::default()
879 });
880 let tmp = TempDir::new().unwrap();
881 let config_path = tmp.path().join("config.toml");
882 std::fs::write(
883 &config_path,
884 r#"
885[http_request.secrets]
886api_token = "Bearer from-secret"
887"#,
888 )
889 .unwrap();
890 let tool = HttpRequestTool::new_with_config(
891 security,
892 vec!["::1".into()],
893 1_000_000,
894 5,
895 true,
896 Vec::new(),
897 config_path,
898 false,
899 )
900 .unwrap();
901
902 let result = tokio::time::timeout(
903 Duration::from_secs(5),
904 tool.execute(json!({
905 "url": format!("http://[::1]:{port}/"),
906 "auth_secret": "api_token",
907 "headers": {
908 "Authorization": "Bearer literal"
909 }
910 })),
911 )
912 .await
913 .unwrap()
914 .unwrap();
915
916 let saw_auth_header = tokio::time::timeout(Duration::from_secs(5), seen_rx)
917 .await
918 .unwrap()
919 .unwrap();
920 server_handle.abort();
921
922 assert!(result.success);
923 assert!(
924 saw_auth_header,
925 "auth_secret must send the resolved Authorization header"
926 );
927 }
928
929 #[test]
930 fn normalize_domain_strips_scheme_path_and_case() {
931 let got = normalize_domain(" HTTPS://Docs.Example.com/path ").unwrap();
932 assert_eq!(got, "docs.example.com");
933 }
934
935 #[test]
936 fn normalize_domain_accepts_ipv6_literal() {
937 let got = normalize_domain("[2001:db8::1]").unwrap();
938 assert_eq!(got, "2001:db8::1");
939 }
940
941 #[test]
942 fn normalize_domain_rejects_userinfo() {
943 assert!(normalize_domain("https://user@example.com").is_none());
944 assert!(normalize_domain("user@example.com").is_none());
945 assert!(normalize_domain("https://user:pass@example.com").is_none());
946 assert!(normalize_domain("user:pass@example.com").is_none());
947 }
948
949 #[test]
950 fn normalize_domain_rejects_unmatched_brackets() {
951 assert!(normalize_domain("[::1").is_none());
952 assert!(normalize_domain("::1]").is_none());
953 assert!(normalize_domain("[127.0.0.1").is_none());
954 assert!(normalize_domain("127.0.0.1]").is_none());
955 }
956
957 #[test]
958 fn extract_host_normalizes_ipv6_without_brackets() {
959 let got = extract_host("https://[2001:db8::1]:443/path").unwrap();
960 assert_eq!(got, "2001:db8::1");
961 }
962
963 #[test]
964 fn normalize_allowed_domains_rejects_invalid_entries() {
965 let err = normalize_allowed_domains(vec![
966 "".into(),
967 "example.com".into(),
968 " ".into(),
969 "api.example.com".into(),
970 ])
971 .unwrap_err();
972 let msg = err.to_string();
973 assert!(
974 msg.contains("Invalid http_request.allowed_domains entry"),
975 "got: {msg}"
976 );
977 }
978
979 #[test]
980 fn normalize_allowed_domains_accepts_all_valid() {
981 let got = normalize_allowed_domains(vec!["example.com".into(), "api.example.com".into()])
982 .unwrap();
983 assert_eq!(got.len(), 2);
984 assert!(got.contains(&"example.com".to_string()));
985 assert!(got.contains(&"api.example.com".to_string()));
986 }
987
988 #[test]
989 fn normalize_allowed_domains_deduplicates() {
990 let got = normalize_allowed_domains(vec![
991 "example.com".into(),
992 "EXAMPLE.COM".into(),
993 "https://example.com/".into(),
994 ])
995 .unwrap();
996 assert_eq!(got, vec!["example.com".to_string()]);
997 }
998
999 #[test]
1000 fn validate_accepts_exact_domain() {
1001 let tool = test_tool(vec!["example.com"]);
1002 let got = tool.validate_url("https://example.com/docs").unwrap();
1003 assert_eq!(got, "https://example.com/docs");
1004 }
1005
1006 #[test]
1007 fn validate_accepts_http() {
1008 let tool = test_tool(vec!["example.com"]);
1009 assert!(tool.validate_url("http://example.com").is_ok());
1010 }
1011
1012 #[test]
1013 fn validate_accepts_subdomain() {
1014 let tool = test_tool(vec!["example.com"]);
1015 assert!(tool.validate_url("https://api.example.com/v1").is_ok());
1016 }
1017
1018 #[test]
1019 fn validate_accepts_wildcard_allowlist_for_public_host() {
1020 let tool = test_tool(vec!["*"]);
1021 assert!(tool.validate_url("https://news.ycombinator.com").is_ok());
1022 }
1023
1024 #[test]
1025 fn validate_wildcard_allowlist_still_rejects_private_host() {
1026 let tool = test_tool(vec!["*"]);
1027 let err = tool
1028 .validate_url("https://localhost:8080")
1029 .unwrap_err()
1030 .to_string();
1031 assert!(err.contains("local/private"));
1032 }
1033
1034 #[test]
1035 fn validate_rejects_allowlist_miss() {
1036 let tool = test_tool(vec!["example.com"]);
1037 let err = tool
1038 .validate_url("https://google.com")
1039 .unwrap_err()
1040 .to_string();
1041 assert!(err.contains("allowed_domains"));
1042 }
1043
1044 #[test]
1045 fn validate_rejects_localhost() {
1046 let tool = test_tool(vec!["localhost"]);
1047 let err = tool
1048 .validate_url("https://localhost:8080")
1049 .unwrap_err()
1050 .to_string();
1051 assert!(err.contains("local/private"));
1052 }
1053
1054 #[test]
1055 fn validate_rejects_private_ipv4() {
1056 let tool = test_tool(vec!["192.168.1.5"]);
1057 let err = tool
1058 .validate_url("https://192.168.1.5")
1059 .unwrap_err()
1060 .to_string();
1061 assert!(err.contains("local/private"));
1062 }
1063
1064 #[test]
1065 fn validate_rejects_whitespace() {
1066 let tool = test_tool(vec!["example.com"]);
1067 let err = tool
1068 .validate_url("https://example.com/hello world")
1069 .unwrap_err()
1070 .to_string();
1071 assert!(err.contains("whitespace"));
1072 }
1073
1074 #[test]
1075 fn validate_rejects_userinfo() {
1076 let tool = test_tool(vec!["example.com"]);
1077 let err = tool
1078 .validate_url("https://user@example.com")
1079 .unwrap_err()
1080 .to_string();
1081 assert!(err.contains("userinfo"));
1082 }
1083
1084 #[test]
1085 fn validate_requires_allowlist() {
1086 let security = Arc::new(SecurityPolicy::default());
1087 let tool =
1088 HttpRequestTool::new(security, vec![], 1_000_000, 30, false, Vec::new()).unwrap();
1089 let err = tool
1090 .validate_url("https://example.com")
1091 .unwrap_err()
1092 .to_string();
1093 assert!(err.contains("allowed_domains"));
1094 }
1095
1096 #[test]
1097 fn validate_accepts_valid_methods() {
1098 let tool = test_tool(vec!["example.com"]);
1099 assert!(tool.validate_method("GET").is_ok());
1100 assert!(tool.validate_method("POST").is_ok());
1101 assert!(tool.validate_method("PUT").is_ok());
1102 assert!(tool.validate_method("DELETE").is_ok());
1103 assert!(tool.validate_method("PATCH").is_ok());
1104 assert!(tool.validate_method("HEAD").is_ok());
1105 assert!(tool.validate_method("OPTIONS").is_ok());
1106 }
1107
1108 #[test]
1109 fn validate_rejects_invalid_method() {
1110 let tool = test_tool(vec!["example.com"]);
1111 let err = tool.validate_method("INVALID").unwrap_err().to_string();
1112 assert!(err.contains("Unsupported HTTP method"));
1113 }
1114
1115 #[test]
1116 fn blocks_multicast_ipv4() {
1117 assert!(is_private_or_local_host("224.0.0.1"));
1118 assert!(is_private_or_local_host("239.255.255.255"));
1119 }
1120
1121 #[test]
1122 fn blocks_broadcast() {
1123 assert!(is_private_or_local_host("255.255.255.255"));
1124 }
1125
1126 #[test]
1127 fn blocks_reserved_ipv4() {
1128 assert!(is_private_or_local_host("240.0.0.1"));
1129 assert!(is_private_or_local_host("250.1.2.3"));
1130 }
1131
1132 #[test]
1133 fn blocks_documentation_ranges() {
1134 assert!(is_private_or_local_host("192.0.2.1")); assert!(is_private_or_local_host("198.51.100.1")); assert!(is_private_or_local_host("203.0.113.1")); }
1138
1139 #[test]
1140 fn blocks_benchmarking_range() {
1141 assert!(is_private_or_local_host("198.18.0.1"));
1142 assert!(is_private_or_local_host("198.19.255.255"));
1143 }
1144
1145 #[test]
1146 fn blocks_ipv6_localhost() {
1147 assert!(is_private_or_local_host("::1"));
1148 assert!(is_private_or_local_host("[::1]"));
1149 }
1150
1151 #[test]
1152 fn blocks_ipv6_multicast() {
1153 assert!(is_private_or_local_host("ff02::1"));
1154 }
1155
1156 #[test]
1157 fn blocks_ipv6_link_local() {
1158 assert!(is_private_or_local_host("fe80::1"));
1159 }
1160
1161 #[test]
1162 fn blocks_ipv6_unique_local() {
1163 assert!(is_private_or_local_host("fd00::1"));
1164 }
1165
1166 #[test]
1167 fn blocks_ipv4_mapped_ipv6() {
1168 assert!(is_private_or_local_host("::ffff:127.0.0.1"));
1169 assert!(is_private_or_local_host("::ffff:192.168.1.1"));
1170 assert!(is_private_or_local_host("::ffff:10.0.0.1"));
1171 }
1172
1173 #[test]
1174 fn allows_public_ipv4() {
1175 assert!(!is_private_or_local_host("8.8.8.8"));
1176 assert!(!is_private_or_local_host("1.1.1.1"));
1177 assert!(!is_private_or_local_host("93.184.216.34"));
1178 }
1179
1180 #[test]
1181 fn blocks_ipv6_documentation_range() {
1182 assert!(is_private_or_local_host("2001:db8::1"));
1183 }
1184
1185 #[test]
1186 fn allows_public_ipv6() {
1187 assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e"));
1188 }
1189
1190 #[test]
1191 fn blocks_shared_address_space() {
1192 assert!(is_private_or_local_host("100.64.0.1"));
1193 assert!(is_private_or_local_host("100.127.255.255"));
1194 assert!(!is_private_or_local_host("100.63.0.1")); assert!(!is_private_or_local_host("100.128.0.1")); }
1197
1198 #[tokio::test]
1199 async fn execute_blocks_readonly_mode() {
1200 let security = Arc::new(SecurityPolicy {
1201 autonomy: AutonomyLevel::ReadOnly,
1202 ..SecurityPolicy::default()
1203 });
1204 let tool = HttpRequestTool::new(
1205 security,
1206 vec!["example.com".into()],
1207 1_000_000,
1208 30,
1209 false,
1210 Vec::new(),
1211 )
1212 .unwrap();
1213 let result = tool
1214 .execute(json!({"url": "https://example.com"}))
1215 .await
1216 .unwrap();
1217 assert!(!result.success);
1218 assert!(result.error.unwrap().contains("read-only"));
1219 }
1220
1221 #[test]
1222 fn truncate_response_within_limit() {
1223 let tool = test_tool(vec!["example.com"]);
1224 let text = "hello world";
1225 assert_eq!(tool.truncate_response(text), "hello world");
1226 }
1227
1228 #[test]
1229 fn truncate_response_over_limit() {
1230 let tool = HttpRequestTool::new(
1231 Arc::new(SecurityPolicy::default()),
1232 vec!["example.com".into()],
1233 10,
1234 30,
1235 false,
1236 Vec::new(),
1237 )
1238 .unwrap();
1239 let text = "hello world this is long";
1240 let truncated = tool.truncate_response(text);
1241 assert!(truncated.len() <= 10 + 60); assert!(truncated.contains("[Response truncated"));
1243 }
1244
1245 #[test]
1246 fn truncate_response_zero_means_unlimited() {
1247 let tool = HttpRequestTool::new(
1248 Arc::new(SecurityPolicy::default()),
1249 vec!["example.com".into()],
1250 0, 30,
1252 false,
1253 Vec::new(),
1254 )
1255 .unwrap();
1256 let text = "a".repeat(10_000_000);
1257 assert_eq!(tool.truncate_response(&text), text);
1258 }
1259
1260 #[test]
1261 fn truncate_response_nonzero_still_truncates() {
1262 let tool = HttpRequestTool::new(
1263 Arc::new(SecurityPolicy::default()),
1264 vec!["example.com".into()],
1265 5,
1266 30,
1267 false,
1268 Vec::new(),
1269 )
1270 .unwrap();
1271 let text = "hello world";
1272 let truncated = tool.truncate_response(text);
1273 assert!(truncated.starts_with("hello"));
1274 assert!(truncated.contains("[Response truncated"));
1275 }
1276
1277 #[test]
1278 fn parse_headers_rejects_non_string_values() {
1279 let tool = test_tool(vec!["example.com"]);
1280 let headers = json!({
1281 "X-Number": 42,
1282 "Content-Type": "application/json"
1283 });
1284 let err = tool.parse_headers(&headers).unwrap_err().to_string();
1285 assert!(
1286 err.contains("X-Number"),
1287 "Should reject non-string header value, got: {err}"
1288 );
1289 }
1290
1291 #[test]
1292 fn parse_headers_preserves_original_values() {
1293 let tool = test_tool(vec!["example.com"]);
1294 let headers = json!({
1295 "Authorization": "Bearer secret",
1296 "Content-Type": "application/json",
1297 "X-API-Key": "my-key"
1298 });
1299 let parsed = tool.parse_headers(&headers).unwrap();
1300 assert_eq!(parsed.len(), 3);
1301 assert_eq!(parsed["authorization"], "Bearer secret");
1302 assert_eq!(parsed["x-api-key"], "my-key");
1303 assert_eq!(parsed["content-type"], "application/json");
1304 }
1305
1306 #[test]
1307 fn redact_headers_for_display_redacts_sensitive() {
1308 let headers = vec![
1309 ("Authorization".into(), "Bearer secret".into()),
1310 ("Content-Type".into(), "application/json".into()),
1311 ("X-API-Key".into(), "my-key".into()),
1312 ("X-Secret-Token".into(), "tok-123".into()),
1313 ];
1314 let redacted = HttpRequestTool::redact_headers_for_display(&headers);
1315 assert_eq!(redacted.len(), 4);
1316 assert!(
1317 redacted
1318 .iter()
1319 .any(|(k, v)| k == "Authorization" && v == "***REDACTED***")
1320 );
1321 assert!(
1322 redacted
1323 .iter()
1324 .any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***")
1325 );
1326 assert!(
1327 redacted
1328 .iter()
1329 .any(|(k, v)| k == "X-Secret-Token" && v == "***REDACTED***")
1330 );
1331 assert!(
1332 redacted
1333 .iter()
1334 .any(|(k, v)| k == "Content-Type" && v == "application/json")
1335 );
1336 }
1337
1338 #[test]
1339 fn redact_headers_does_not_alter_original() {
1340 let headers = vec![("Authorization".into(), "Bearer real-token".into())];
1341 let _ = HttpRequestTool::redact_headers_for_display(&headers);
1342 assert_eq!(headers[0].1, "Bearer real-token");
1343 }
1344
1345 #[test]
1352 fn ssrf_octal_loopback_not_parsed_as_ip() {
1353 assert!(!is_private_or_local_host("0177.0.0.1"));
1356 }
1357
1358 #[test]
1359 fn ssrf_hex_loopback_not_parsed_as_ip() {
1360 assert!(!is_private_or_local_host("0x7f000001"));
1362 }
1363
1364 #[test]
1365 fn ssrf_decimal_loopback_not_parsed_as_ip() {
1366 assert!(!is_private_or_local_host("2130706433"));
1368 }
1369
1370 #[test]
1371 fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
1372 assert!(!is_private_or_local_host("127.000.000.001"));
1374 }
1375
1376 #[test]
1377 fn ssrf_alternate_notations_rejected_by_validate_url() {
1378 let tool = test_tool(vec!["example.com"]);
1383 for notation in [
1384 "http://0177.0.0.1",
1385 "http://0x7f000001",
1386 "http://2130706433",
1387 "http://127.000.000.001",
1388 ] {
1389 let err = tool.validate_url(notation).unwrap_err().to_string();
1390 assert!(
1391 err.contains("allowed_domains") || err.contains("local/private"),
1392 "Expected secure rejection for {notation}, got: {err}"
1393 );
1394 }
1395 }
1396
1397 #[test]
1398 fn redirect_policy_is_none() {
1399 let tool = test_tool(vec!["example.com"]);
1402 assert_eq!(tool.name(), "http_request");
1403 }
1404
1405 #[test]
1408 fn ssrf_blocks_loopback_127_range() {
1409 assert!(is_private_or_local_host("127.0.0.1"));
1410 assert!(is_private_or_local_host("127.0.0.2"));
1411 assert!(is_private_or_local_host("127.255.255.255"));
1412 }
1413
1414 #[test]
1415 fn ssrf_blocks_rfc1918_10_range() {
1416 assert!(is_private_or_local_host("10.0.0.1"));
1417 assert!(is_private_or_local_host("10.255.255.255"));
1418 }
1419
1420 #[test]
1421 fn ssrf_blocks_rfc1918_172_range() {
1422 assert!(is_private_or_local_host("172.16.0.1"));
1423 assert!(is_private_or_local_host("172.31.255.255"));
1424 }
1425
1426 #[test]
1427 fn ssrf_blocks_unspecified_address() {
1428 assert!(is_private_or_local_host("0.0.0.0"));
1429 }
1430
1431 #[test]
1432 fn ssrf_blocks_dot_localhost_subdomain() {
1433 assert!(is_private_or_local_host("evil.localhost"));
1434 assert!(is_private_or_local_host("a.b.localhost"));
1435 }
1436
1437 #[test]
1438 fn ssrf_blocks_dot_local_tld() {
1439 assert!(is_private_or_local_host("service.local"));
1440 }
1441
1442 #[test]
1443 fn ssrf_ipv6_unspecified() {
1444 assert!(is_private_or_local_host("::"));
1445 }
1446
1447 #[test]
1448 fn validate_rejects_ftp_scheme() {
1449 let tool = test_tool(vec!["example.com"]);
1450 let err = tool
1451 .validate_url("ftp://example.com")
1452 .unwrap_err()
1453 .to_string();
1454 assert!(err.contains("http://") || err.contains("https://"));
1455 }
1456
1457 #[test]
1458 fn validate_rejects_empty_url() {
1459 let tool = test_tool(vec!["example.com"]);
1460 let err = tool.validate_url("").unwrap_err().to_string();
1461 assert!(err.contains("empty"));
1462 }
1463
1464 #[test]
1465 fn validate_accepts_public_ipv6_host_when_allowlisted() {
1466 let tool = test_tool(vec!["2607:f8b0:4004:800::200e"]);
1467 assert!(
1468 tool.validate_url("https://[2607:f8b0:4004:800::200e]/path")
1469 .is_ok()
1470 );
1471 }
1472
1473 #[test]
1476 fn default_blocks_private_hosts() {
1477 let tool = test_tool(vec!["localhost", "192.168.1.5", "*"]);
1478 assert!(
1479 tool.validate_url("https://localhost:8080")
1480 .unwrap_err()
1481 .to_string()
1482 .contains("local/private")
1483 );
1484 assert!(
1485 tool.validate_url("https://192.168.1.5")
1486 .unwrap_err()
1487 .to_string()
1488 .contains("local/private")
1489 );
1490 assert!(
1491 tool.validate_url("https://10.0.0.1")
1492 .unwrap_err()
1493 .to_string()
1494 .contains("local/private")
1495 );
1496 }
1497
1498 #[test]
1499 fn allow_private_hosts_permits_localhost() {
1500 let tool = test_tool_with_private(vec!["localhost"], true);
1501 assert!(tool.validate_url("https://localhost:8080").is_ok());
1502 }
1503
1504 #[test]
1505 fn allow_private_hosts_permits_private_ipv4() {
1506 let tool = test_tool_with_private(vec!["192.168.1.5"], true);
1507 assert!(tool.validate_url("https://192.168.1.5").is_ok());
1508 }
1509
1510 #[test]
1511 fn allow_private_hosts_permits_rfc1918_with_wildcard() {
1512 let tool = test_tool_with_private(vec!["*"], true);
1513 assert!(tool.validate_url("https://10.0.0.1").is_ok());
1514 assert!(tool.validate_url("https://172.16.0.1").is_ok());
1515 assert!(tool.validate_url("https://192.168.1.1").is_ok());
1516 assert!(tool.validate_url("http://localhost:8123").is_ok());
1517 }
1518
1519 #[test]
1520 fn allow_private_hosts_permits_ipv6_loopback_when_allowlisted() {
1521 let tool = test_tool_with_private(vec!["::1"], true);
1522 assert!(tool.validate_url("https://[::1]:8443").is_ok());
1523 }
1524
1525 #[test]
1526 fn allow_private_hosts_still_requires_allowlist() {
1527 let tool = test_tool_with_private(vec!["example.com"], true);
1528 let err = tool
1529 .validate_url("https://192.168.1.5")
1530 .unwrap_err()
1531 .to_string();
1532 assert!(
1533 err.contains("allowed_domains"),
1534 "Private host should still need allowlist match, got: {err}"
1535 );
1536 }
1537
1538 #[test]
1539 fn allow_private_hosts_false_still_blocks() {
1540 let tool = test_tool_with_private(vec!["*"], false);
1541 assert!(
1542 tool.validate_url("https://localhost:8080")
1543 .unwrap_err()
1544 .to_string()
1545 .contains("local/private")
1546 );
1547 }
1548
1549 #[test]
1550 fn allowed_private_hosts_permits_localhost_without_broad_private_opt_in() {
1551 let tool = test_tool_with_private_allowlist(vec!["example.com"], false, vec!["localhost"]);
1552 assert!(tool.validate_url("https://localhost:8080").is_ok());
1553 }
1554
1555 #[test]
1556 fn allowed_private_hosts_permits_private_ipv4_without_allowed_domains_match() {
1557 let tool =
1558 test_tool_with_private_allowlist(vec!["example.com"], false, vec!["192.168.1.5"]);
1559 assert!(tool.validate_url("https://192.168.1.5").is_ok());
1560 }
1561
1562 #[test]
1563 fn allowed_private_hosts_still_requires_non_empty_allowed_domains() {
1564 let tool = test_tool_with_private_allowlist(vec![], false, vec!["localhost"]);
1565 let err = tool
1566 .validate_url("https://localhost:8080")
1567 .unwrap_err()
1568 .to_string();
1569 assert!(err.contains("allowed_domains"));
1570 }
1571
1572 #[test]
1573 fn allowed_private_hosts_still_blocks_unlisted_private_host() {
1574 let tool =
1575 test_tool_with_private_allowlist(vec!["example.com"], false, vec!["192.168.1.5"]);
1576 let err = tool
1577 .validate_url("https://192.168.1.6")
1578 .unwrap_err()
1579 .to_string();
1580 assert!(err.contains("local/private"));
1581 }
1582
1583 #[test]
1584 fn allowed_private_hosts_wildcard_only_bypasses_private_hosts() {
1585 let tool = test_tool_with_private_allowlist(vec!["example.com"], false, vec!["*"]);
1586 assert!(tool.validate_url("https://10.0.0.1").is_ok());
1587
1588 let err = tool
1589 .validate_url("https://news.ycombinator.com")
1590 .unwrap_err()
1591 .to_string();
1592 assert!(err.contains("allowed_domains"));
1593 }
1594
1595 #[test]
1598 fn ipv6_url_parse_variants_extract_correct_host() {
1599 assert_eq!(
1600 extract_host("https://[2001:db8::1]/api").unwrap(),
1601 "2001:db8::1"
1602 );
1603 assert_eq!(
1604 extract_host("https://[2001:db8::1]:8080/api?q=1").unwrap(),
1605 "2001:db8::1"
1606 );
1607 assert_eq!(
1608 extract_host("http://[2607:f8b0:4004:800::200e]:443/path#frag").unwrap(),
1609 "2607:f8b0:4004:800::200e"
1610 );
1611 }
1612
1613 #[test]
1614 fn ipv6_allowlist_handles_compressed_notation() {
1615 let tool = test_tool(vec!["::1", "fe80::1"]);
1616 assert!(tool.validate_url("https://[::1]:8443").is_err()); assert!(tool.validate_url("https://[fe80::1]").is_err()); }
1619
1620 #[test]
1621 fn ipv6_normalize_domain_handles_edge_cases() {
1622 assert_eq!(normalize_domain("::1").unwrap(), "::1");
1623 assert_eq!(normalize_domain("[::1]").unwrap(), "::1");
1624 assert_eq!(normalize_domain("2001:db8::1").unwrap(), "2001:db8::1");
1625 assert_eq!(normalize_domain("[2001:db8::1]").unwrap(), "2001:db8::1");
1626 }
1627
1628 #[test]
1629 fn ipv6_host_matches_allowlist_exact_only() {
1630 let domains = vec!["2001:db8::1".to_string()];
1631 assert!(host_matches_allowlist("2001:db8::1", &domains));
1633 assert!(!host_matches_allowlist("2001:db8::2", &domains));
1635 assert!(!host_matches_allowlist("2001:db8::", &domains));
1637 }
1638
1639 #[tokio::test]
1640 async fn ipv6_end_to_end_real_request_over_loopback() {
1641 let listener = match tokio::net::TcpListener::bind("[::1]:0").await {
1642 Ok(l) => l,
1643 Err(_) => return, };
1645 let port = listener.local_addr().unwrap().port();
1646
1647 let server_handle = zeroclaw_spawn::spawn!(async move {
1649 if let Ok((mut stream, _)) = listener.accept().await {
1650 use tokio::io::AsyncWriteExt;
1651 let response = b"HTTP/1.1 200 OK\r\nContent-Length: 16\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nhello from ipv6!";
1652 let _ = stream.write_all(response).await;
1653 let _ = stream.flush().await;
1654 }
1655 });
1656
1657 let url = format!("http://[::1]:{port}/");
1658
1659 let security = Arc::new(SecurityPolicy {
1660 autonomy: AutonomyLevel::Supervised,
1661 ..SecurityPolicy::default()
1662 });
1663 let tool = HttpRequestTool::new(
1664 security,
1665 vec!["::1".to_string()],
1666 1_000_000, 5, true, Vec::new(),
1670 )
1671 .unwrap();
1672
1673 let result = tokio::time::timeout(
1674 Duration::from_secs(10),
1675 tool.execute(json!({
1676 "url": url,
1677 "method": "GET"
1678 })),
1679 )
1680 .await;
1681
1682 server_handle.abort();
1684
1685 match result {
1686 Ok(Ok(r)) if r.success && r.output.contains("hello from ipv6!") => {}
1687 Ok(Ok(_)) => {} Ok(Err(_)) => {} Err(_) => {} }
1691 }
1692}