Skip to main content

construct/tools/
http_request.rs

1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::net::{IpAddr, SocketAddr};
6use std::sync::Arc;
7use std::time::Duration;
8
9/// HTTP request tool for API interactions.
10/// Supports GET, POST, PUT, DELETE methods with configurable security.
11pub struct HttpRequestTool {
12    security: Arc<SecurityPolicy>,
13    allowed_domains: Vec<String>,
14    max_response_size: usize,
15    timeout_secs: u64,
16    allow_private_hosts: bool,
17}
18
19/// A URL that has passed all validation gates, carrying the DNS-resolved IPs
20/// so the caller can pin them into the reqwest client at connect time.
21#[derive(Debug)]
22struct ValidatedRequest {
23    url: String,
24    host: String,
25    /// Validated global IPs for `host`. Empty when the host is a bare IP
26    /// literal (no DNS needed) or when `allow_private_hosts` bypassed
27    /// validation entirely.
28    resolved_ips: Vec<IpAddr>,
29}
30
31impl HttpRequestTool {
32    pub fn new(
33        security: Arc<SecurityPolicy>,
34        allowed_domains: Vec<String>,
35        max_response_size: usize,
36        timeout_secs: u64,
37        allow_private_hosts: bool,
38    ) -> Self {
39        Self {
40            security,
41            allowed_domains: normalize_allowed_domains(allowed_domains),
42            max_response_size,
43            timeout_secs,
44            allow_private_hosts,
45        }
46    }
47
48    async fn validate_url(&self, raw_url: &str) -> anyhow::Result<ValidatedRequest> {
49        let url = raw_url.trim();
50
51        if url.is_empty() {
52            anyhow::bail!("URL cannot be empty");
53        }
54
55        if url.chars().any(char::is_whitespace) {
56            anyhow::bail!("URL cannot contain whitespace");
57        }
58
59        if !url.starts_with("http://") && !url.starts_with("https://") {
60            anyhow::bail!("Only http:// and https:// URLs are allowed");
61        }
62
63        if self.allowed_domains.is_empty() {
64            anyhow::bail!(
65                "HTTP request tool is enabled but no allowed_domains are configured. Add [http_request].allowed_domains in config.toml"
66            );
67        }
68
69        let host = extract_host(url)?;
70
71        if !self.allow_private_hosts && is_private_or_local_host(&host) {
72            anyhow::bail!("Blocked local/private host: {host}");
73        }
74
75        if !host_matches_allowlist(&host, &self.allowed_domains) {
76            anyhow::bail!("Host '{host}' is not in http_request.allowed_domains");
77        }
78
79        // Resolve once and pin the result into the reqwest client at the call
80        // site. Returning Ok(String) alone would leave reqwest to do its own
81        // lookup at connect time — an attacker controlling DNS for the host
82        // could answer the validator with a public IP and the connect-time
83        // lookup with 169.254.169.254 (DNS rebinding TOCTOU).
84        let resolved_ips = if self.allow_private_hosts {
85            Vec::new()
86        } else {
87            validate_resolved_host_is_public(&host).await?
88        };
89
90        Ok(ValidatedRequest {
91            url: url.to_string(),
92            host,
93            resolved_ips,
94        })
95    }
96
97    fn validate_method(&self, method: &str) -> anyhow::Result<reqwest::Method> {
98        match method.to_uppercase().as_str() {
99            "GET" => Ok(reqwest::Method::GET),
100            "POST" => Ok(reqwest::Method::POST),
101            "PUT" => Ok(reqwest::Method::PUT),
102            "DELETE" => Ok(reqwest::Method::DELETE),
103            "PATCH" => Ok(reqwest::Method::PATCH),
104            "HEAD" => Ok(reqwest::Method::HEAD),
105            "OPTIONS" => Ok(reqwest::Method::OPTIONS),
106            _ => anyhow::bail!(
107                "Unsupported HTTP method: {method}. Supported: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"
108            ),
109        }
110    }
111
112    fn parse_headers(&self, headers: &serde_json::Value) -> Vec<(String, String)> {
113        let mut result = Vec::new();
114        if let Some(obj) = headers.as_object() {
115            for (key, value) in obj {
116                if let Some(str_val) = value.as_str() {
117                    result.push((key.clone(), str_val.to_string()));
118                }
119            }
120        }
121        result
122    }
123
124    fn redact_headers_for_display(headers: &[(String, String)]) -> Vec<(String, String)> {
125        headers
126            .iter()
127            .map(|(key, value)| {
128                let lower = key.to_lowercase();
129                let is_sensitive = lower.contains("authorization")
130                    || lower.contains("api-key")
131                    || lower.contains("apikey")
132                    || lower.contains("token")
133                    || lower.contains("secret");
134                if is_sensitive {
135                    (key.clone(), "***REDACTED***".into())
136                } else {
137                    (key.clone(), value.clone())
138                }
139            })
140            .collect()
141    }
142
143    async fn execute_request(
144        &self,
145        validated: &ValidatedRequest,
146        method: reqwest::Method,
147        headers: Vec<(String, String)>,
148        body: Option<&str>,
149    ) -> anyhow::Result<reqwest::Response> {
150        let timeout_secs = if self.timeout_secs == 0 {
151            tracing::warn!("http_request: timeout_secs is 0, using safe default of 30s");
152            30
153        } else {
154            self.timeout_secs
155        };
156        let mut builder = reqwest::Client::builder()
157            .timeout(Duration::from_secs(timeout_secs))
158            .connect_timeout(Duration::from_secs(10))
159            .redirect(reqwest::redirect::Policy::none());
160
161        // Pin DNS to the IPs we already validated. Without this reqwest would
162        // perform its own resolution at connect time and an attacker with
163        // control of DNS for an allowlisted domain could serve a public IP
164        // to the validator and a metadata/RFC1918 IP to the actual request.
165        // reqwest ignores the port in the SocketAddr — the URL's port is used.
166        if !validated.resolved_ips.is_empty() {
167            let addrs: Vec<SocketAddr> = validated
168                .resolved_ips
169                .iter()
170                .map(|ip| SocketAddr::new(*ip, 0))
171                .collect();
172            builder = builder.resolve_to_addrs(&validated.host, &addrs);
173        }
174
175        let builder = crate::config::apply_runtime_proxy_to_builder(builder, "tool.http_request");
176        let client = builder.build()?;
177
178        let mut request = client.request(method, &validated.url);
179
180        for (key, value) in headers {
181            request = request.header(&key, &value);
182        }
183
184        if let Some(body_str) = body {
185            request = request.body(body_str.to_string());
186        }
187
188        Ok(request.send().await?)
189    }
190
191    fn truncate_response(&self, text: &str) -> String {
192        // 0 means unlimited — no truncation.
193        if self.max_response_size == 0 {
194            return text.to_string();
195        }
196        if text.len() > self.max_response_size {
197            let mut truncated = text
198                .chars()
199                .take(self.max_response_size)
200                .collect::<String>();
201            truncated.push_str("\n\n... [Response truncated due to size limit] ...");
202            truncated
203        } else {
204            text.to_string()
205        }
206    }
207}
208
209#[async_trait]
210impl Tool for HttpRequestTool {
211    fn name(&self) -> &str {
212        "http_request"
213    }
214
215    fn description(&self) -> &str {
216        "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \
217        Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits."
218    }
219
220    fn parameters_schema(&self) -> serde_json::Value {
221        json!({
222            "type": "object",
223            "properties": {
224                "url": {
225                    "type": "string",
226                    "description": "HTTP or HTTPS URL to request"
227                },
228                "method": {
229                    "type": "string",
230                    "description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)",
231                    "default": "GET"
232                },
233                "headers": {
234                    "type": "object",
235                    "description": "Optional HTTP headers as key-value pairs (e.g., {\"Authorization\": \"Bearer token\", \"Content-Type\": \"application/json\"})",
236                    "default": {}
237                },
238                "body": {
239                    "type": "string",
240                    "description": "Optional request body (for POST, PUT, PATCH requests)"
241                }
242            },
243            "required": ["url"]
244        })
245    }
246
247    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
248        let url = args
249            .get("url")
250            .and_then(|v| v.as_str())
251            .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?;
252
253        let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
254        let headers_val = args.get("headers").cloned().unwrap_or(json!({}));
255        let body = args.get("body").and_then(|v| v.as_str());
256
257        if !self.security.can_act() {
258            return Ok(ToolResult {
259                success: false,
260                output: String::new(),
261                error: Some("Action blocked: autonomy is read-only".into()),
262            });
263        }
264
265        if !self.security.record_action() {
266            return Ok(ToolResult {
267                success: false,
268                output: String::new(),
269                error: Some("Action blocked: rate limit exceeded".into()),
270            });
271        }
272
273        let validated = match self.validate_url(url).await {
274            Ok(v) => v,
275            Err(e) => {
276                return Ok(ToolResult {
277                    success: false,
278                    output: String::new(),
279                    error: Some(e.to_string()),
280                });
281            }
282        };
283
284        let method = match self.validate_method(method_str) {
285            Ok(m) => m,
286            Err(e) => {
287                return Ok(ToolResult {
288                    success: false,
289                    output: String::new(),
290                    error: Some(e.to_string()),
291                });
292            }
293        };
294
295        let request_headers = self.parse_headers(&headers_val);
296
297        match self
298            .execute_request(&validated, method, request_headers, body)
299            .await
300        {
301            Ok(response) => {
302                let status = response.status();
303                let status_code = status.as_u16();
304
305                // Get response headers (redact sensitive ones)
306                let response_headers = response.headers().iter();
307                let headers_text = response_headers
308                    .map(|(k, _)| {
309                        let is_sensitive = k.as_str().to_lowercase().contains("set-cookie");
310                        if is_sensitive {
311                            format!("{}: ***REDACTED***", k.as_str())
312                        } else {
313                            format!("{}: {:?}", k.as_str(), k.as_str())
314                        }
315                    })
316                    .collect::<Vec<_>>()
317                    .join(", ");
318
319                // Get response body with size limit
320                let response_text = match response.text().await {
321                    Ok(text) => self.truncate_response(&text),
322                    Err(e) => format!("[Failed to read response body: {e}]"),
323                };
324
325                let output = format!(
326                    "Status: {} {}\nResponse Headers: {}\n\nResponse Body:\n{}",
327                    status_code,
328                    status.canonical_reason().unwrap_or("Unknown"),
329                    headers_text,
330                    response_text
331                );
332
333                Ok(ToolResult {
334                    success: status.is_success(),
335                    output,
336                    error: if status.is_client_error() || status.is_server_error() {
337                        Some(format!("HTTP {}", status_code))
338                    } else {
339                        None
340                    },
341                })
342            }
343            Err(e) => Ok(ToolResult {
344                success: false,
345                output: String::new(),
346                error: Some(format!("HTTP request failed: {e}")),
347            }),
348        }
349    }
350}
351
352// Helper functions similar to browser_open.rs
353
354fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> {
355    let mut normalized = domains
356        .into_iter()
357        .filter_map(|d| normalize_domain(&d))
358        .collect::<Vec<_>>();
359    normalized.sort_unstable();
360    normalized.dedup();
361    normalized
362}
363
364fn normalize_domain(raw: &str) -> Option<String> {
365    let mut d = raw.trim().to_lowercase();
366    if d.is_empty() {
367        return None;
368    }
369
370    if let Some(stripped) = d.strip_prefix("https://") {
371        d = stripped.to_string();
372    } else if let Some(stripped) = d.strip_prefix("http://") {
373        d = stripped.to_string();
374    }
375
376    if let Some((host, _)) = d.split_once('/') {
377        d = host.to_string();
378    }
379
380    d = d.trim_start_matches('.').trim_end_matches('.').to_string();
381
382    if let Some((host, _)) = d.split_once(':') {
383        d = host.to_string();
384    }
385
386    if d.is_empty() || d.chars().any(char::is_whitespace) {
387        return None;
388    }
389
390    Some(d)
391}
392
393fn extract_host(url: &str) -> anyhow::Result<String> {
394    let rest = url
395        .strip_prefix("http://")
396        .or_else(|| url.strip_prefix("https://"))
397        .ok_or_else(|| anyhow::anyhow!("Only http:// and https:// URLs are allowed"))?;
398
399    let authority = rest
400        .split(['/', '?', '#'])
401        .next()
402        .ok_or_else(|| anyhow::anyhow!("Invalid URL"))?;
403
404    if authority.is_empty() {
405        anyhow::bail!("URL must include a host");
406    }
407
408    if authority.contains('@') {
409        anyhow::bail!("URL userinfo is not allowed");
410    }
411
412    if authority.starts_with('[') {
413        anyhow::bail!("IPv6 hosts are not supported in http_request");
414    }
415
416    let host = authority
417        .split(':')
418        .next()
419        .unwrap_or_default()
420        .trim()
421        .trim_end_matches('.')
422        .to_lowercase();
423
424    if host.is_empty() {
425        anyhow::bail!("URL must include a valid host");
426    }
427
428    Ok(host)
429}
430
431fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool {
432    if allowed_domains.iter().any(|domain| domain == "*") {
433        return true;
434    }
435
436    allowed_domains.iter().any(|domain| {
437        host == domain
438            || host
439                .strip_suffix(domain)
440                .is_some_and(|prefix| prefix.ends_with('.'))
441    })
442}
443
444#[cfg(not(test))]
445async fn validate_resolved_host_is_public(host: &str) -> anyhow::Result<Vec<IpAddr>> {
446    let bare = host
447        .strip_prefix('[')
448        .and_then(|h| h.strip_suffix(']'))
449        .unwrap_or(host);
450
451    // Bare IP literal in the URL: reqwest will connect to exactly this IP
452    // with no DNS lookup, so there is nothing to pin. The earlier
453    // `is_private_or_local_host` check already rejected non-global literals.
454    if bare.parse::<IpAddr>().is_ok() {
455        return Ok(Vec::new());
456    }
457
458    let ips: Vec<IpAddr> = tokio::net::lookup_host((bare, 0))
459        .await
460        .map_err(|e| anyhow::anyhow!("Failed to resolve host '{host}': {e}"))?
461        .map(|addr| addr.ip())
462        .collect();
463
464    validate_resolved_ips_are_public(host, &ips)?;
465    Ok(ips)
466}
467
468#[cfg(test)]
469async fn validate_resolved_host_is_public(_host: &str) -> anyhow::Result<Vec<IpAddr>> {
470    // Unit tests exercise validate_resolved_ips_are_public directly; the
471    // async DNS path is covered by end-to-end tests outside this crate.
472    Ok(Vec::new())
473}
474
475fn validate_resolved_ips_are_public(host: &str, ips: &[std::net::IpAddr]) -> anyhow::Result<()> {
476    if ips.is_empty() {
477        anyhow::bail!("Failed to resolve host '{host}'");
478    }
479
480    for ip in ips {
481        let non_global = match ip {
482            std::net::IpAddr::V4(v4) => is_non_global_v4(*v4),
483            std::net::IpAddr::V6(v6) => is_non_global_v6(*v6),
484        };
485        if non_global {
486            anyhow::bail!("Blocked host '{host}' resolved to non-global address {ip}");
487        }
488    }
489
490    Ok(())
491}
492
493fn is_private_or_local_host(host: &str) -> bool {
494    // Strip brackets from IPv6 addresses like [::1]
495    let bare = host
496        .strip_prefix('[')
497        .and_then(|h| h.strip_suffix(']'))
498        .unwrap_or(host);
499
500    let has_local_tld = bare
501        .rsplit('.')
502        .next()
503        .is_some_and(|label| label == "local");
504
505    if bare == "localhost" || bare.ends_with(".localhost") || has_local_tld {
506        return true;
507    }
508
509    if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
510        return match ip {
511            std::net::IpAddr::V4(v4) => is_non_global_v4(v4),
512            std::net::IpAddr::V6(v6) => is_non_global_v6(v6),
513        };
514    }
515
516    false
517}
518
519/// Returns true if the IPv4 address is not globally routable.
520fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
521    let [a, b, c, _] = v4.octets();
522    v4.is_loopback()                       // 127.0.0.0/8
523        || v4.is_private()                 // 10/8, 172.16/12, 192.168/16
524        || v4.is_link_local()              // 169.254.0.0/16
525        || v4.is_unspecified()             // 0.0.0.0
526        || v4.is_broadcast()              // 255.255.255.255
527        || v4.is_multicast()              // 224.0.0.0/4
528        || (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598)
529        || a >= 240                        // Reserved (240.0.0.0/4, except broadcast)
530        || (a == 192 && b == 0 && (c == 0 || c == 2)) // IETF assignments + TEST-NET-1
531        || (a == 198 && b == 51)           // Documentation (198.51.100.0/24)
532        || (a == 203 && b == 0)            // Documentation (203.0.113.0/24)
533        || (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15)
534}
535
536/// Returns true if the IPv6 address is not globally routable.
537fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
538    let segs = v6.segments();
539    v6.is_loopback()                       // ::1
540        || v6.is_unspecified()             // ::
541        || v6.is_multicast()              // ff00::/8
542        || (segs[0] & 0xfe00) == 0xfc00   // Unique-local (fc00::/7)
543        || (segs[0] & 0xffc0) == 0xfe80   // Link-local (fe80::/10)
544        || (segs[0] == 0x2001 && segs[1] == 0x0db8) // Documentation (2001:db8::/32)
545        || v6.to_ipv4_mapped().is_some_and(is_non_global_v4)
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::security::{AutonomyLevel, SecurityPolicy};
552
553    fn test_tool(allowed_domains: Vec<&str>) -> HttpRequestTool {
554        test_tool_with_private(allowed_domains, false)
555    }
556
557    fn test_tool_with_private(
558        allowed_domains: Vec<&str>,
559        allow_private_hosts: bool,
560    ) -> HttpRequestTool {
561        let security = Arc::new(SecurityPolicy {
562            autonomy: AutonomyLevel::Supervised,
563            ..SecurityPolicy::default()
564        });
565        HttpRequestTool::new(
566            security,
567            allowed_domains.into_iter().map(String::from).collect(),
568            1_000_000,
569            30,
570            allow_private_hosts,
571        )
572    }
573
574    #[test]
575    fn normalize_domain_strips_scheme_path_and_case() {
576        let got = normalize_domain("  HTTPS://Docs.Example.com/path ").unwrap();
577        assert_eq!(got, "docs.example.com");
578    }
579
580    #[test]
581    fn normalize_allowed_domains_deduplicates() {
582        let got = normalize_allowed_domains(vec![
583            "example.com".into(),
584            "EXAMPLE.COM".into(),
585            "https://example.com/".into(),
586        ]);
587        assert_eq!(got, vec!["example.com".to_string()]);
588    }
589
590    #[tokio::test]
591    async fn validate_accepts_exact_domain() {
592        let tool = test_tool(vec!["example.com"]);
593        let got = tool.validate_url("https://example.com/docs").await.unwrap();
594        assert_eq!(got.url, "https://example.com/docs");
595    }
596
597    #[tokio::test]
598    async fn validate_accepts_http() {
599        let tool = test_tool(vec!["example.com"]);
600        assert!(tool.validate_url("http://example.com").await.is_ok());
601    }
602
603    #[tokio::test]
604    async fn validate_accepts_subdomain() {
605        let tool = test_tool(vec!["example.com"]);
606        assert!(tool.validate_url("https://api.example.com/v1").await.is_ok());
607    }
608
609    #[tokio::test]
610    async fn validate_accepts_wildcard_allowlist_for_public_host() {
611        let tool = test_tool(vec!["*"]);
612        assert!(tool.validate_url("https://news.ycombinator.com").await.is_ok());
613    }
614
615    #[tokio::test]
616    async fn validate_wildcard_allowlist_still_rejects_private_host() {
617        let tool = test_tool(vec!["*"]);
618        let err = tool
619            .validate_url("https://localhost:8080")
620            .await
621            .unwrap_err()
622            .to_string();
623        assert!(err.contains("local/private"));
624    }
625
626    #[tokio::test]
627    async fn validate_rejects_allowlist_miss() {
628        let tool = test_tool(vec!["example.com"]);
629        let err = tool
630            .validate_url("https://google.com")
631            .await
632            .unwrap_err()
633            .to_string();
634        assert!(err.contains("allowed_domains"));
635    }
636
637    #[tokio::test]
638    async fn validate_rejects_localhost() {
639        let tool = test_tool(vec!["localhost"]);
640        let err = tool
641            .validate_url("https://localhost:8080")
642            .await
643            .unwrap_err()
644            .to_string();
645        assert!(err.contains("local/private"));
646    }
647
648    #[tokio::test]
649    async fn validate_rejects_private_ipv4() {
650        let tool = test_tool(vec!["192.168.1.5"]);
651        let err = tool
652            .validate_url("https://192.168.1.5")
653            .await
654            .unwrap_err()
655            .to_string();
656        assert!(err.contains("local/private"));
657    }
658
659    #[tokio::test]
660    async fn validate_rejects_whitespace() {
661        let tool = test_tool(vec!["example.com"]);
662        let err = tool
663            .validate_url("https://example.com/hello world")
664            .await
665            .unwrap_err()
666            .to_string();
667        assert!(err.contains("whitespace"));
668    }
669
670    #[tokio::test]
671    async fn validate_rejects_userinfo() {
672        let tool = test_tool(vec!["example.com"]);
673        let err = tool
674            .validate_url("https://user@example.com")
675            .await
676            .unwrap_err()
677            .to_string();
678        assert!(err.contains("userinfo"));
679    }
680
681    #[tokio::test]
682    async fn validate_requires_allowlist() {
683        let security = Arc::new(SecurityPolicy::default());
684        let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30, false);
685        let err = tool
686            .validate_url("https://example.com")
687            .await
688            .unwrap_err()
689            .to_string();
690        assert!(err.contains("allowed_domains"));
691    }
692
693    #[test]
694    fn validate_accepts_valid_methods() {
695        let tool = test_tool(vec!["example.com"]);
696        assert!(tool.validate_method("GET").is_ok());
697        assert!(tool.validate_method("POST").is_ok());
698        assert!(tool.validate_method("PUT").is_ok());
699        assert!(tool.validate_method("DELETE").is_ok());
700        assert!(tool.validate_method("PATCH").is_ok());
701        assert!(tool.validate_method("HEAD").is_ok());
702        assert!(tool.validate_method("OPTIONS").is_ok());
703    }
704
705    #[test]
706    fn validate_rejects_invalid_method() {
707        let tool = test_tool(vec!["example.com"]);
708        let err = tool.validate_method("INVALID").unwrap_err().to_string();
709        assert!(err.contains("Unsupported HTTP method"));
710    }
711
712    #[test]
713    fn blocks_multicast_ipv4() {
714        assert!(is_private_or_local_host("224.0.0.1"));
715        assert!(is_private_or_local_host("239.255.255.255"));
716    }
717
718    #[test]
719    fn blocks_broadcast() {
720        assert!(is_private_or_local_host("255.255.255.255"));
721    }
722
723    #[test]
724    fn blocks_reserved_ipv4() {
725        assert!(is_private_or_local_host("240.0.0.1"));
726        assert!(is_private_or_local_host("250.1.2.3"));
727    }
728
729    #[test]
730    fn blocks_documentation_ranges() {
731        assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1
732        assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2
733        assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3
734    }
735
736    #[test]
737    fn blocks_benchmarking_range() {
738        assert!(is_private_or_local_host("198.18.0.1"));
739        assert!(is_private_or_local_host("198.19.255.255"));
740    }
741
742    #[test]
743    fn blocks_ipv6_localhost() {
744        assert!(is_private_or_local_host("::1"));
745        assert!(is_private_or_local_host("[::1]"));
746    }
747
748    #[test]
749    fn blocks_ipv6_multicast() {
750        assert!(is_private_or_local_host("ff02::1"));
751    }
752
753    #[test]
754    fn blocks_ipv6_link_local() {
755        assert!(is_private_or_local_host("fe80::1"));
756    }
757
758    #[test]
759    fn blocks_ipv6_unique_local() {
760        assert!(is_private_or_local_host("fd00::1"));
761    }
762
763    #[test]
764    fn blocks_ipv4_mapped_ipv6() {
765        assert!(is_private_or_local_host("::ffff:127.0.0.1"));
766        assert!(is_private_or_local_host("::ffff:192.168.1.1"));
767        assert!(is_private_or_local_host("::ffff:10.0.0.1"));
768    }
769
770    #[test]
771    fn allows_public_ipv4() {
772        assert!(!is_private_or_local_host("8.8.8.8"));
773        assert!(!is_private_or_local_host("1.1.1.1"));
774        assert!(!is_private_or_local_host("93.184.216.34"));
775    }
776
777    #[test]
778    fn blocks_ipv6_documentation_range() {
779        assert!(is_private_or_local_host("2001:db8::1"));
780    }
781
782    #[test]
783    fn allows_public_ipv6() {
784        assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e"));
785    }
786
787    #[test]
788    fn blocks_shared_address_space() {
789        assert!(is_private_or_local_host("100.64.0.1"));
790        assert!(is_private_or_local_host("100.127.255.255"));
791        assert!(!is_private_or_local_host("100.63.0.1")); // Just below range
792        assert!(!is_private_or_local_host("100.128.0.1")); // Just above range
793    }
794
795    #[tokio::test]
796    async fn execute_blocks_readonly_mode() {
797        let security = Arc::new(SecurityPolicy {
798            autonomy: AutonomyLevel::ReadOnly,
799            ..SecurityPolicy::default()
800        });
801        let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, false);
802        let result = tool
803            .execute(json!({"url": "https://example.com"}))
804            .await
805            .unwrap();
806        assert!(!result.success);
807        assert!(result.error.unwrap().contains("read-only"));
808    }
809
810    #[tokio::test]
811    async fn execute_blocks_when_rate_limited() {
812        let security = Arc::new(SecurityPolicy {
813            max_actions_per_hour: 0,
814            ..SecurityPolicy::default()
815        });
816        let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, false);
817        let result = tool
818            .execute(json!({"url": "https://example.com"}))
819            .await
820            .unwrap();
821        assert!(!result.success);
822        assert!(result.error.unwrap().contains("rate limit"));
823    }
824
825    #[test]
826    fn truncate_response_within_limit() {
827        let tool = test_tool(vec!["example.com"]);
828        let text = "hello world";
829        assert_eq!(tool.truncate_response(text), "hello world");
830    }
831
832    #[test]
833    fn truncate_response_over_limit() {
834        let tool = HttpRequestTool::new(
835            Arc::new(SecurityPolicy::default()),
836            vec!["example.com".into()],
837            10,
838            30,
839            false,
840        );
841        let text = "hello world this is long";
842        let truncated = tool.truncate_response(text);
843        assert!(truncated.len() <= 10 + 60); // limit + message
844        assert!(truncated.contains("[Response truncated"));
845    }
846
847    #[test]
848    fn truncate_response_zero_means_unlimited() {
849        let tool = HttpRequestTool::new(
850            Arc::new(SecurityPolicy::default()),
851            vec!["example.com".into()],
852            0, // max_response_size = 0 means no limit
853            30,
854            false,
855        );
856        let text = "a".repeat(10_000_000);
857        assert_eq!(tool.truncate_response(&text), text);
858    }
859
860    #[test]
861    fn truncate_response_nonzero_still_truncates() {
862        let tool = HttpRequestTool::new(
863            Arc::new(SecurityPolicy::default()),
864            vec!["example.com".into()],
865            5,
866            30,
867            false,
868        );
869        let text = "hello world";
870        let truncated = tool.truncate_response(text);
871        assert!(truncated.starts_with("hello"));
872        assert!(truncated.contains("[Response truncated"));
873    }
874
875    #[test]
876    fn parse_headers_preserves_original_values() {
877        let tool = test_tool(vec!["example.com"]);
878        let headers = json!({
879            "Authorization": "Bearer secret",
880            "Content-Type": "application/json",
881            "X-API-Key": "my-key"
882        });
883        let parsed = tool.parse_headers(&headers);
884        assert_eq!(parsed.len(), 3);
885        assert!(
886            parsed
887                .iter()
888                .any(|(k, v)| k == "Authorization" && v == "Bearer secret")
889        );
890        assert!(
891            parsed
892                .iter()
893                .any(|(k, v)| k == "X-API-Key" && v == "my-key")
894        );
895        assert!(
896            parsed
897                .iter()
898                .any(|(k, v)| k == "Content-Type" && v == "application/json")
899        );
900    }
901
902    #[test]
903    fn redact_headers_for_display_redacts_sensitive() {
904        let headers = vec![
905            ("Authorization".into(), "Bearer secret".into()),
906            ("Content-Type".into(), "application/json".into()),
907            ("X-API-Key".into(), "my-key".into()),
908            ("X-Secret-Token".into(), "tok-123".into()),
909        ];
910        let redacted = HttpRequestTool::redact_headers_for_display(&headers);
911        assert_eq!(redacted.len(), 4);
912        assert!(
913            redacted
914                .iter()
915                .any(|(k, v)| k == "Authorization" && v == "***REDACTED***")
916        );
917        assert!(
918            redacted
919                .iter()
920                .any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***")
921        );
922        assert!(
923            redacted
924                .iter()
925                .any(|(k, v)| k == "X-Secret-Token" && v == "***REDACTED***")
926        );
927        assert!(
928            redacted
929                .iter()
930                .any(|(k, v)| k == "Content-Type" && v == "application/json")
931        );
932    }
933
934    #[test]
935    fn redact_headers_does_not_alter_original() {
936        let headers = vec![("Authorization".into(), "Bearer real-token".into())];
937        let _ = HttpRequestTool::redact_headers_for_display(&headers);
938        assert_eq!(headers[0].1, "Bearer real-token");
939    }
940
941    // ── SSRF: DNS-resolved IP must be global ────────────────────────
942    //
943    // Defense against DNS rebinding: a textual hostname that is not
944    // "localhost" or private-looking can still resolve to a non-global
945    // address (e.g. an attacker-controlled name pointing at
946    // 169.254.169.254 cloud metadata). `validate_resolved_ips_are_public`
947    // is what gates the request when DNS is actually consulted.
948
949    #[test]
950    fn ssrf_dns_resolves_to_metadata_ip_rejected() {
951        use std::net::{IpAddr, Ipv4Addr};
952        let ips = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))];
953        let err = validate_resolved_ips_are_public("evil.example", &ips).unwrap_err();
954        assert!(err.to_string().contains("non-global"));
955    }
956
957    #[test]
958    fn ssrf_dns_resolves_to_private_rfc1918_rejected() {
959        use std::net::{IpAddr, Ipv4Addr};
960        let ips = vec![IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))];
961        let err = validate_resolved_ips_are_public("evil.example", &ips).unwrap_err();
962        assert!(err.to_string().contains("non-global"));
963    }
964
965    #[test]
966    fn ssrf_dns_resolves_to_loopback_rejected() {
967        use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
968        let v4 = vec![IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))];
969        assert!(validate_resolved_ips_are_public("x", &v4).is_err());
970        let v6 = vec![IpAddr::V6(Ipv6Addr::LOCALHOST)];
971        assert!(validate_resolved_ips_are_public("x", &v6).is_err());
972    }
973
974    #[test]
975    fn ssrf_dns_resolves_to_public_ip_allowed() {
976        use std::net::{IpAddr, Ipv4Addr};
977        let ips = vec![IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))];
978        assert!(validate_resolved_ips_are_public("example.com", &ips).is_ok());
979    }
980
981    #[test]
982    fn ssrf_any_non_global_ip_in_set_rejects() {
983        use std::net::{IpAddr, Ipv4Addr};
984        // Mixed DNS response: public + private. Must still be rejected
985        // because an attacker can force the private IP at connect time.
986        let ips = vec![
987            IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
988            IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
989        ];
990        let err = validate_resolved_ips_are_public("evil.example", &ips).unwrap_err();
991        assert!(err.to_string().contains("non-global"));
992    }
993
994    // ── SSRF: alternate IP notation bypass defense-in-depth ─────────
995    //
996    // Rust's IpAddr::parse() rejects non-standard notations (octal, hex,
997    // decimal integer, zero-padded). These tests document that property
998    // so regressions are caught if the parsing strategy ever changes.
999
1000    #[test]
1001    fn ssrf_octal_loopback_not_parsed_as_ip() {
1002        // 0177.0.0.1 is octal for 127.0.0.1 in some languages, but
1003        // Rust's IpAddr rejects it — it falls through as a hostname.
1004        assert!(!is_private_or_local_host("0177.0.0.1"));
1005    }
1006
1007    #[test]
1008    fn ssrf_hex_loopback_not_parsed_as_ip() {
1009        // 0x7f000001 is hex for 127.0.0.1 in some languages.
1010        assert!(!is_private_or_local_host("0x7f000001"));
1011    }
1012
1013    #[test]
1014    fn ssrf_decimal_loopback_not_parsed_as_ip() {
1015        // 2130706433 is decimal for 127.0.0.1 in some languages.
1016        assert!(!is_private_or_local_host("2130706433"));
1017    }
1018
1019    #[test]
1020    fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
1021        // 127.000.000.001 uses zero-padded octets.
1022        assert!(!is_private_or_local_host("127.000.000.001"));
1023    }
1024
1025    #[tokio::test]
1026    async fn ssrf_alternate_notations_rejected_by_validate_url() {
1027        // Even if is_private_or_local_host doesn't flag these, they
1028        // fail the allowlist because they're treated as hostnames.
1029        let tool = test_tool(vec!["example.com"]);
1030        for notation in [
1031            "http://0177.0.0.1",
1032            "http://0x7f000001",
1033            "http://2130706433",
1034            "http://127.000.000.001",
1035        ] {
1036            let err = tool.validate_url(notation).await.unwrap_err().to_string();
1037            assert!(
1038                err.contains("allowed_domains"),
1039                "Expected allowlist rejection for {notation}, got: {err}"
1040            );
1041        }
1042    }
1043
1044    #[test]
1045    fn redirect_policy_is_none() {
1046        // Structural test: the tool should be buildable with redirect-safe config.
1047        // The actual Policy::none() enforcement is in execute_request's client builder.
1048        let tool = test_tool(vec!["example.com"]);
1049        assert_eq!(tool.name(), "http_request");
1050    }
1051
1052    // ── §1.4 DNS rebinding / SSRF defense-in-depth tests ─────
1053
1054    #[test]
1055    fn ssrf_blocks_loopback_127_range() {
1056        assert!(is_private_or_local_host("127.0.0.1"));
1057        assert!(is_private_or_local_host("127.0.0.2"));
1058        assert!(is_private_or_local_host("127.255.255.255"));
1059    }
1060
1061    #[test]
1062    fn ssrf_blocks_rfc1918_10_range() {
1063        assert!(is_private_or_local_host("10.0.0.1"));
1064        assert!(is_private_or_local_host("10.255.255.255"));
1065    }
1066
1067    #[test]
1068    fn ssrf_blocks_rfc1918_172_range() {
1069        assert!(is_private_or_local_host("172.16.0.1"));
1070        assert!(is_private_or_local_host("172.31.255.255"));
1071    }
1072
1073    #[test]
1074    fn ssrf_blocks_unspecified_address() {
1075        assert!(is_private_or_local_host("0.0.0.0"));
1076    }
1077
1078    #[test]
1079    fn ssrf_blocks_dot_localhost_subdomain() {
1080        assert!(is_private_or_local_host("evil.localhost"));
1081        assert!(is_private_or_local_host("a.b.localhost"));
1082    }
1083
1084    #[test]
1085    fn ssrf_blocks_dot_local_tld() {
1086        assert!(is_private_or_local_host("service.local"));
1087    }
1088
1089    #[test]
1090    fn ssrf_ipv6_unspecified() {
1091        assert!(is_private_or_local_host("::"));
1092    }
1093
1094    #[tokio::test]
1095    async fn validate_rejects_ftp_scheme() {
1096        let tool = test_tool(vec!["example.com"]);
1097        let err = tool
1098            .validate_url("ftp://example.com")
1099            .await
1100            .unwrap_err()
1101            .to_string();
1102        assert!(err.contains("http://") || err.contains("https://"));
1103    }
1104
1105    #[tokio::test]
1106    async fn validate_rejects_empty_url() {
1107        let tool = test_tool(vec!["example.com"]);
1108        let err = tool.validate_url("").await.unwrap_err().to_string();
1109        assert!(err.contains("empty"));
1110    }
1111
1112    #[tokio::test]
1113    async fn validate_rejects_ipv6_host() {
1114        let tool = test_tool(vec!["example.com"]);
1115        let err = tool
1116            .validate_url("http://[::1]:8080/path")
1117            .await
1118            .unwrap_err()
1119            .to_string();
1120        assert!(err.contains("IPv6"));
1121    }
1122
1123    // ── allow_private_hosts opt-in tests ────────────────────────
1124
1125    #[tokio::test]
1126    async fn default_blocks_private_hosts() {
1127        let tool = test_tool(vec!["localhost", "192.168.1.5", "*"]);
1128        assert!(
1129            tool.validate_url("https://localhost:8080")
1130                .await
1131                .unwrap_err()
1132                .to_string()
1133                .contains("local/private")
1134        );
1135        assert!(
1136            tool.validate_url("https://192.168.1.5")
1137                .await
1138                .unwrap_err()
1139                .to_string()
1140                .contains("local/private")
1141        );
1142        assert!(
1143            tool.validate_url("https://10.0.0.1")
1144                .await
1145                .unwrap_err()
1146                .to_string()
1147                .contains("local/private")
1148        );
1149    }
1150
1151    #[tokio::test]
1152    async fn allow_private_hosts_permits_localhost() {
1153        let tool = test_tool_with_private(vec!["localhost"], true);
1154        assert!(tool.validate_url("https://localhost:8080").await.is_ok());
1155    }
1156
1157    #[tokio::test]
1158    async fn allow_private_hosts_permits_private_ipv4() {
1159        let tool = test_tool_with_private(vec!["192.168.1.5"], true);
1160        assert!(tool.validate_url("https://192.168.1.5").await.is_ok());
1161    }
1162
1163    #[tokio::test]
1164    async fn allow_private_hosts_permits_rfc1918_with_wildcard() {
1165        let tool = test_tool_with_private(vec!["*"], true);
1166        assert!(tool.validate_url("https://10.0.0.1").await.is_ok());
1167        assert!(tool.validate_url("https://172.16.0.1").await.is_ok());
1168        assert!(tool.validate_url("https://192.168.1.1").await.is_ok());
1169        assert!(tool.validate_url("http://localhost:8123").await.is_ok());
1170    }
1171
1172    #[tokio::test]
1173    async fn allow_private_hosts_still_requires_allowlist() {
1174        let tool = test_tool_with_private(vec!["example.com"], true);
1175        let err = tool
1176            .validate_url("https://192.168.1.5")
1177            .await
1178            .unwrap_err()
1179            .to_string();
1180        assert!(
1181            err.contains("allowed_domains"),
1182            "Private host should still need allowlist match, got: {err}"
1183        );
1184    }
1185
1186    #[tokio::test]
1187    async fn allow_private_hosts_false_still_blocks() {
1188        let tool = test_tool_with_private(vec!["*"], false);
1189        assert!(
1190            tool.validate_url("https://localhost:8080")
1191                .await
1192                .unwrap_err()
1193                .to_string()
1194                .contains("local/private")
1195        );
1196    }
1197}