Skip to main content

zeroclaw_tools/
http_request.rs

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
11/// HTTP request tool for API interactions.
12/// Supports GET, POST, PUT, DELETE methods with configurable security.
13pub 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        // 0 means unlimited — no truncation.
281        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        // Rate limiting is applied by the RateLimitedTool wrapper at
377        // registration time (see zeroclaw-runtime::tools::mod).
378
379        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                // Get response headers (redact sensitive ones)
428                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                // Get response body with size limit
442                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
474// Helper functions similar to browser_open.rs
475
476fn 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    // Strip brackets from IPv6 addresses like [::1]
606    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
630/// Returns true if the IPv4 address is not globally routable.
631fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
632    let [a, b, c, _] = v4.octets();
633    v4.is_loopback()                       // 127.0.0.0/8
634        || v4.is_private()                 // 10/8, 172.16/12, 192.168/16
635        || v4.is_link_local()              // 169.254.0.0/16
636        || v4.is_unspecified()             // 0.0.0.0
637        || v4.is_broadcast()              // 255.255.255.255
638        || v4.is_multicast()              // 224.0.0.0/4
639        || (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598)
640        || a >= 240                        // Reserved (240.0.0.0/4, except broadcast)
641        || (a == 192 && b == 0 && (c == 0 || c == 2)) // IETF assignments + TEST-NET-1
642        || (a == 198 && b == 51)           // Documentation (198.51.100.0/24)
643        || (a == 203 && b == 0)            // Documentation (203.0.113.0/24)
644        || (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15)
645}
646
647/// Returns true if the IPv6 address is not globally routable.
648fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
649    let segs = v6.segments();
650    v6.is_loopback()                       // ::1
651        || v6.is_unspecified()             // ::
652        || v6.is_multicast()              // ff00::/8
653        || (segs[0] & 0xfe00) == 0xfc00   // Unique-local (fc00::/7)
654        || (segs[0] & 0xffc0) == 0xfe80   // Link-local (fe80::/10)
655        || (segs[0] == 0x2001 && segs[1] == 0x0db8) // Documentation (2001:db8::/32)
656        || 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, // IPv6 loopback is unavailable in this environment.
856        };
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")); // TEST-NET-1
1135        assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2
1136        assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3
1137    }
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")); // Just below range
1195        assert!(!is_private_or_local_host("100.128.0.1")); // Just above range
1196    }
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); // limit + message
1242        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, // max_response_size = 0 means no limit
1251            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    // ── SSRF: alternate IP notation bypass defense-in-depth ─────────
1346    //
1347    // Rust's IpAddr::parse() rejects non-standard notations (octal, hex,
1348    // decimal integer, zero-padded). These tests document that property
1349    // so regressions are caught if the parsing strategy ever changes.
1350
1351    #[test]
1352    fn ssrf_octal_loopback_not_parsed_as_ip() {
1353        // 0177.0.0.1 is octal for 127.0.0.1 in some languages, but
1354        // Rust's IpAddr rejects it — it falls through as a hostname.
1355        assert!(!is_private_or_local_host("0177.0.0.1"));
1356    }
1357
1358    #[test]
1359    fn ssrf_hex_loopback_not_parsed_as_ip() {
1360        // 0x7f000001 is hex for 127.0.0.1 in some languages.
1361        assert!(!is_private_or_local_host("0x7f000001"));
1362    }
1363
1364    #[test]
1365    fn ssrf_decimal_loopback_not_parsed_as_ip() {
1366        // 2130706433 is decimal for 127.0.0.1 in some languages.
1367        assert!(!is_private_or_local_host("2130706433"));
1368    }
1369
1370    #[test]
1371    fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
1372        // 127.000.000.001 uses zero-padded octets.
1373        assert!(!is_private_or_local_host("127.000.000.001"));
1374    }
1375
1376    #[test]
1377    fn ssrf_alternate_notations_rejected_by_validate_url() {
1378        // Alternate notations must be blocked by validation.
1379        // Depending on URL canonicalization, they may be rejected either as:
1380        // - private/local hosts, or
1381        // - allowlist mismatches.
1382        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        // Structural test: the tool should be buildable with redirect-safe config.
1400        // The actual Policy::none() enforcement is in execute_request's client builder.
1401        let tool = test_tool(vec!["example.com"]);
1402        assert_eq!(tool.name(), "http_request");
1403    }
1404
1405    // ── §1.4 DNS rebinding / SSRF defense-in-depth tests ─────
1406
1407    #[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    // ── allow_private_hosts opt-in tests ────────────────────────
1474
1475    #[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    // ── IPv6 end-to-end coverage ──────────────────────────────
1596
1597    #[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()); // blocked — local/private
1617        assert!(tool.validate_url("https://[fe80::1]").is_err()); // blocked — local/private
1618    }
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        // exact match
1632        assert!(host_matches_allowlist("2001:db8::1", &domains));
1633        // different IP — should NOT suffix-match as if it were a domain
1634        assert!(!host_matches_allowlist("2001:db8::2", &domains));
1635        // prefix should NOT match either
1636        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, // IPv6 not available in this environment
1644        };
1645        let port = listener.local_addr().unwrap().port();
1646
1647        // Spawn a minimal HTTP server that responds with a known body.
1648        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, // max_response_size
1667            5,         // timeout_secs
1668            true,      // allow_private_hosts
1669            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        // Abort the server task regardless of outcome.
1683        server_handle.abort();
1684
1685        match result {
1686            Ok(Ok(r)) if r.success && r.output.contains("hello from ipv6!") => {}
1687            Ok(Ok(_)) => {} // request completed but response didn't match — acceptable
1688            Ok(Err(_)) => {} // validation/network error — acceptable
1689            Err(_) => {}    // timeout — IPv6 connectivity may be unavailable
1690        }
1691    }
1692}