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!(
607            tool.validate_url("https://api.example.com/v1")
608                .await
609                .is_ok()
610        );
611    }
612
613    #[tokio::test]
614    async fn validate_accepts_wildcard_allowlist_for_public_host() {
615        let tool = test_tool(vec!["*"]);
616        assert!(
617            tool.validate_url("https://news.ycombinator.com")
618                .await
619                .is_ok()
620        );
621    }
622
623    #[tokio::test]
624    async fn validate_wildcard_allowlist_still_rejects_private_host() {
625        let tool = test_tool(vec!["*"]);
626        let err = tool
627            .validate_url("https://localhost:8080")
628            .await
629            .unwrap_err()
630            .to_string();
631        assert!(err.contains("local/private"));
632    }
633
634    #[tokio::test]
635    async fn validate_rejects_allowlist_miss() {
636        let tool = test_tool(vec!["example.com"]);
637        let err = tool
638            .validate_url("https://google.com")
639            .await
640            .unwrap_err()
641            .to_string();
642        assert!(err.contains("allowed_domains"));
643    }
644
645    #[tokio::test]
646    async fn validate_rejects_localhost() {
647        let tool = test_tool(vec!["localhost"]);
648        let err = tool
649            .validate_url("https://localhost:8080")
650            .await
651            .unwrap_err()
652            .to_string();
653        assert!(err.contains("local/private"));
654    }
655
656    #[tokio::test]
657    async fn validate_rejects_private_ipv4() {
658        let tool = test_tool(vec!["192.168.1.5"]);
659        let err = tool
660            .validate_url("https://192.168.1.5")
661            .await
662            .unwrap_err()
663            .to_string();
664        assert!(err.contains("local/private"));
665    }
666
667    #[tokio::test]
668    async fn validate_rejects_whitespace() {
669        let tool = test_tool(vec!["example.com"]);
670        let err = tool
671            .validate_url("https://example.com/hello world")
672            .await
673            .unwrap_err()
674            .to_string();
675        assert!(err.contains("whitespace"));
676    }
677
678    #[tokio::test]
679    async fn validate_rejects_userinfo() {
680        let tool = test_tool(vec!["example.com"]);
681        let err = tool
682            .validate_url("https://user@example.com")
683            .await
684            .unwrap_err()
685            .to_string();
686        assert!(err.contains("userinfo"));
687    }
688
689    #[tokio::test]
690    async fn validate_requires_allowlist() {
691        let security = Arc::new(SecurityPolicy::default());
692        let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30, false);
693        let err = tool
694            .validate_url("https://example.com")
695            .await
696            .unwrap_err()
697            .to_string();
698        assert!(err.contains("allowed_domains"));
699    }
700
701    #[test]
702    fn validate_accepts_valid_methods() {
703        let tool = test_tool(vec!["example.com"]);
704        assert!(tool.validate_method("GET").is_ok());
705        assert!(tool.validate_method("POST").is_ok());
706        assert!(tool.validate_method("PUT").is_ok());
707        assert!(tool.validate_method("DELETE").is_ok());
708        assert!(tool.validate_method("PATCH").is_ok());
709        assert!(tool.validate_method("HEAD").is_ok());
710        assert!(tool.validate_method("OPTIONS").is_ok());
711    }
712
713    #[test]
714    fn validate_rejects_invalid_method() {
715        let tool = test_tool(vec!["example.com"]);
716        let err = tool.validate_method("INVALID").unwrap_err().to_string();
717        assert!(err.contains("Unsupported HTTP method"));
718    }
719
720    #[test]
721    fn blocks_multicast_ipv4() {
722        assert!(is_private_or_local_host("224.0.0.1"));
723        assert!(is_private_or_local_host("239.255.255.255"));
724    }
725
726    #[test]
727    fn blocks_broadcast() {
728        assert!(is_private_or_local_host("255.255.255.255"));
729    }
730
731    #[test]
732    fn blocks_reserved_ipv4() {
733        assert!(is_private_or_local_host("240.0.0.1"));
734        assert!(is_private_or_local_host("250.1.2.3"));
735    }
736
737    #[test]
738    fn blocks_documentation_ranges() {
739        assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1
740        assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2
741        assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3
742    }
743
744    #[test]
745    fn blocks_benchmarking_range() {
746        assert!(is_private_or_local_host("198.18.0.1"));
747        assert!(is_private_or_local_host("198.19.255.255"));
748    }
749
750    #[test]
751    fn blocks_ipv6_localhost() {
752        assert!(is_private_or_local_host("::1"));
753        assert!(is_private_or_local_host("[::1]"));
754    }
755
756    #[test]
757    fn blocks_ipv6_multicast() {
758        assert!(is_private_or_local_host("ff02::1"));
759    }
760
761    #[test]
762    fn blocks_ipv6_link_local() {
763        assert!(is_private_or_local_host("fe80::1"));
764    }
765
766    #[test]
767    fn blocks_ipv6_unique_local() {
768        assert!(is_private_or_local_host("fd00::1"));
769    }
770
771    #[test]
772    fn blocks_ipv4_mapped_ipv6() {
773        assert!(is_private_or_local_host("::ffff:127.0.0.1"));
774        assert!(is_private_or_local_host("::ffff:192.168.1.1"));
775        assert!(is_private_or_local_host("::ffff:10.0.0.1"));
776    }
777
778    #[test]
779    fn allows_public_ipv4() {
780        assert!(!is_private_or_local_host("8.8.8.8"));
781        assert!(!is_private_or_local_host("1.1.1.1"));
782        assert!(!is_private_or_local_host("93.184.216.34"));
783    }
784
785    #[test]
786    fn blocks_ipv6_documentation_range() {
787        assert!(is_private_or_local_host("2001:db8::1"));
788    }
789
790    #[test]
791    fn allows_public_ipv6() {
792        assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e"));
793    }
794
795    #[test]
796    fn blocks_shared_address_space() {
797        assert!(is_private_or_local_host("100.64.0.1"));
798        assert!(is_private_or_local_host("100.127.255.255"));
799        assert!(!is_private_or_local_host("100.63.0.1")); // Just below range
800        assert!(!is_private_or_local_host("100.128.0.1")); // Just above range
801    }
802
803    #[tokio::test]
804    async fn execute_blocks_readonly_mode() {
805        let security = Arc::new(SecurityPolicy {
806            autonomy: AutonomyLevel::ReadOnly,
807            ..SecurityPolicy::default()
808        });
809        let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, false);
810        let result = tool
811            .execute(json!({"url": "https://example.com"}))
812            .await
813            .unwrap();
814        assert!(!result.success);
815        assert!(result.error.unwrap().contains("read-only"));
816    }
817
818    #[tokio::test]
819    async fn execute_blocks_when_rate_limited() {
820        let security = Arc::new(SecurityPolicy {
821            max_actions_per_hour: 0,
822            ..SecurityPolicy::default()
823        });
824        let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30, false);
825        let result = tool
826            .execute(json!({"url": "https://example.com"}))
827            .await
828            .unwrap();
829        assert!(!result.success);
830        assert!(result.error.unwrap().contains("rate limit"));
831    }
832
833    #[test]
834    fn truncate_response_within_limit() {
835        let tool = test_tool(vec!["example.com"]);
836        let text = "hello world";
837        assert_eq!(tool.truncate_response(text), "hello world");
838    }
839
840    #[test]
841    fn truncate_response_over_limit() {
842        let tool = HttpRequestTool::new(
843            Arc::new(SecurityPolicy::default()),
844            vec!["example.com".into()],
845            10,
846            30,
847            false,
848        );
849        let text = "hello world this is long";
850        let truncated = tool.truncate_response(text);
851        assert!(truncated.len() <= 10 + 60); // limit + message
852        assert!(truncated.contains("[Response truncated"));
853    }
854
855    #[test]
856    fn truncate_response_zero_means_unlimited() {
857        let tool = HttpRequestTool::new(
858            Arc::new(SecurityPolicy::default()),
859            vec!["example.com".into()],
860            0, // max_response_size = 0 means no limit
861            30,
862            false,
863        );
864        let text = "a".repeat(10_000_000);
865        assert_eq!(tool.truncate_response(&text), text);
866    }
867
868    #[test]
869    fn truncate_response_nonzero_still_truncates() {
870        let tool = HttpRequestTool::new(
871            Arc::new(SecurityPolicy::default()),
872            vec!["example.com".into()],
873            5,
874            30,
875            false,
876        );
877        let text = "hello world";
878        let truncated = tool.truncate_response(text);
879        assert!(truncated.starts_with("hello"));
880        assert!(truncated.contains("[Response truncated"));
881    }
882
883    #[test]
884    fn parse_headers_preserves_original_values() {
885        let tool = test_tool(vec!["example.com"]);
886        let headers = json!({
887            "Authorization": "Bearer secret",
888            "Content-Type": "application/json",
889            "X-API-Key": "my-key"
890        });
891        let parsed = tool.parse_headers(&headers);
892        assert_eq!(parsed.len(), 3);
893        assert!(
894            parsed
895                .iter()
896                .any(|(k, v)| k == "Authorization" && v == "Bearer secret")
897        );
898        assert!(
899            parsed
900                .iter()
901                .any(|(k, v)| k == "X-API-Key" && v == "my-key")
902        );
903        assert!(
904            parsed
905                .iter()
906                .any(|(k, v)| k == "Content-Type" && v == "application/json")
907        );
908    }
909
910    #[test]
911    fn redact_headers_for_display_redacts_sensitive() {
912        let headers = vec![
913            ("Authorization".into(), "Bearer secret".into()),
914            ("Content-Type".into(), "application/json".into()),
915            ("X-API-Key".into(), "my-key".into()),
916            ("X-Secret-Token".into(), "tok-123".into()),
917        ];
918        let redacted = HttpRequestTool::redact_headers_for_display(&headers);
919        assert_eq!(redacted.len(), 4);
920        assert!(
921            redacted
922                .iter()
923                .any(|(k, v)| k == "Authorization" && v == "***REDACTED***")
924        );
925        assert!(
926            redacted
927                .iter()
928                .any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***")
929        );
930        assert!(
931            redacted
932                .iter()
933                .any(|(k, v)| k == "X-Secret-Token" && v == "***REDACTED***")
934        );
935        assert!(
936            redacted
937                .iter()
938                .any(|(k, v)| k == "Content-Type" && v == "application/json")
939        );
940    }
941
942    #[test]
943    fn redact_headers_does_not_alter_original() {
944        let headers = vec![("Authorization".into(), "Bearer real-token".into())];
945        let _ = HttpRequestTool::redact_headers_for_display(&headers);
946        assert_eq!(headers[0].1, "Bearer real-token");
947    }
948
949    // ── SSRF: DNS-resolved IP must be global ────────────────────────
950    //
951    // Defense against DNS rebinding: a textual hostname that is not
952    // "localhost" or private-looking can still resolve to a non-global
953    // address (e.g. an attacker-controlled name pointing at
954    // 169.254.169.254 cloud metadata). `validate_resolved_ips_are_public`
955    // is what gates the request when DNS is actually consulted.
956
957    #[test]
958    fn ssrf_dns_resolves_to_metadata_ip_rejected() {
959        use std::net::{IpAddr, Ipv4Addr};
960        let ips = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))];
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_private_rfc1918_rejected() {
967        use std::net::{IpAddr, Ipv4Addr};
968        let ips = vec![IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))];
969        let err = validate_resolved_ips_are_public("evil.example", &ips).unwrap_err();
970        assert!(err.to_string().contains("non-global"));
971    }
972
973    #[test]
974    fn ssrf_dns_resolves_to_loopback_rejected() {
975        use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
976        let v4 = vec![IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))];
977        assert!(validate_resolved_ips_are_public("x", &v4).is_err());
978        let v6 = vec![IpAddr::V6(Ipv6Addr::LOCALHOST)];
979        assert!(validate_resolved_ips_are_public("x", &v6).is_err());
980    }
981
982    #[test]
983    fn ssrf_dns_resolves_to_public_ip_allowed() {
984        use std::net::{IpAddr, Ipv4Addr};
985        let ips = vec![IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))];
986        assert!(validate_resolved_ips_are_public("example.com", &ips).is_ok());
987    }
988
989    #[test]
990    fn ssrf_any_non_global_ip_in_set_rejects() {
991        use std::net::{IpAddr, Ipv4Addr};
992        // Mixed DNS response: public + private. Must still be rejected
993        // because an attacker can force the private IP at connect time.
994        let ips = vec![
995            IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
996            IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
997        ];
998        let err = validate_resolved_ips_are_public("evil.example", &ips).unwrap_err();
999        assert!(err.to_string().contains("non-global"));
1000    }
1001
1002    // ── SSRF: alternate IP notation bypass defense-in-depth ─────────
1003    //
1004    // Rust's IpAddr::parse() rejects non-standard notations (octal, hex,
1005    // decimal integer, zero-padded). These tests document that property
1006    // so regressions are caught if the parsing strategy ever changes.
1007
1008    #[test]
1009    fn ssrf_octal_loopback_not_parsed_as_ip() {
1010        // 0177.0.0.1 is octal for 127.0.0.1 in some languages, but
1011        // Rust's IpAddr rejects it — it falls through as a hostname.
1012        assert!(!is_private_or_local_host("0177.0.0.1"));
1013    }
1014
1015    #[test]
1016    fn ssrf_hex_loopback_not_parsed_as_ip() {
1017        // 0x7f000001 is hex for 127.0.0.1 in some languages.
1018        assert!(!is_private_or_local_host("0x7f000001"));
1019    }
1020
1021    #[test]
1022    fn ssrf_decimal_loopback_not_parsed_as_ip() {
1023        // 2130706433 is decimal for 127.0.0.1 in some languages.
1024        assert!(!is_private_or_local_host("2130706433"));
1025    }
1026
1027    #[test]
1028    fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
1029        // 127.000.000.001 uses zero-padded octets.
1030        assert!(!is_private_or_local_host("127.000.000.001"));
1031    }
1032
1033    #[tokio::test]
1034    async fn ssrf_alternate_notations_rejected_by_validate_url() {
1035        // Even if is_private_or_local_host doesn't flag these, they
1036        // fail the allowlist because they're treated as hostnames.
1037        let tool = test_tool(vec!["example.com"]);
1038        for notation in [
1039            "http://0177.0.0.1",
1040            "http://0x7f000001",
1041            "http://2130706433",
1042            "http://127.000.000.001",
1043        ] {
1044            let err = tool.validate_url(notation).await.unwrap_err().to_string();
1045            assert!(
1046                err.contains("allowed_domains"),
1047                "Expected allowlist rejection for {notation}, got: {err}"
1048            );
1049        }
1050    }
1051
1052    #[test]
1053    fn redirect_policy_is_none() {
1054        // Structural test: the tool should be buildable with redirect-safe config.
1055        // The actual Policy::none() enforcement is in execute_request's client builder.
1056        let tool = test_tool(vec!["example.com"]);
1057        assert_eq!(tool.name(), "http_request");
1058    }
1059
1060    // ── §1.4 DNS rebinding / SSRF defense-in-depth tests ─────
1061
1062    #[test]
1063    fn ssrf_blocks_loopback_127_range() {
1064        assert!(is_private_or_local_host("127.0.0.1"));
1065        assert!(is_private_or_local_host("127.0.0.2"));
1066        assert!(is_private_or_local_host("127.255.255.255"));
1067    }
1068
1069    #[test]
1070    fn ssrf_blocks_rfc1918_10_range() {
1071        assert!(is_private_or_local_host("10.0.0.1"));
1072        assert!(is_private_or_local_host("10.255.255.255"));
1073    }
1074
1075    #[test]
1076    fn ssrf_blocks_rfc1918_172_range() {
1077        assert!(is_private_or_local_host("172.16.0.1"));
1078        assert!(is_private_or_local_host("172.31.255.255"));
1079    }
1080
1081    #[test]
1082    fn ssrf_blocks_unspecified_address() {
1083        assert!(is_private_or_local_host("0.0.0.0"));
1084    }
1085
1086    #[test]
1087    fn ssrf_blocks_dot_localhost_subdomain() {
1088        assert!(is_private_or_local_host("evil.localhost"));
1089        assert!(is_private_or_local_host("a.b.localhost"));
1090    }
1091
1092    #[test]
1093    fn ssrf_blocks_dot_local_tld() {
1094        assert!(is_private_or_local_host("service.local"));
1095    }
1096
1097    #[test]
1098    fn ssrf_ipv6_unspecified() {
1099        assert!(is_private_or_local_host("::"));
1100    }
1101
1102    #[tokio::test]
1103    async fn validate_rejects_ftp_scheme() {
1104        let tool = test_tool(vec!["example.com"]);
1105        let err = tool
1106            .validate_url("ftp://example.com")
1107            .await
1108            .unwrap_err()
1109            .to_string();
1110        assert!(err.contains("http://") || err.contains("https://"));
1111    }
1112
1113    #[tokio::test]
1114    async fn validate_rejects_empty_url() {
1115        let tool = test_tool(vec!["example.com"]);
1116        let err = tool.validate_url("").await.unwrap_err().to_string();
1117        assert!(err.contains("empty"));
1118    }
1119
1120    #[tokio::test]
1121    async fn validate_rejects_ipv6_host() {
1122        let tool = test_tool(vec!["example.com"]);
1123        let err = tool
1124            .validate_url("http://[::1]:8080/path")
1125            .await
1126            .unwrap_err()
1127            .to_string();
1128        assert!(err.contains("IPv6"));
1129    }
1130
1131    // ── allow_private_hosts opt-in tests ────────────────────────
1132
1133    #[tokio::test]
1134    async fn default_blocks_private_hosts() {
1135        let tool = test_tool(vec!["localhost", "192.168.1.5", "*"]);
1136        assert!(
1137            tool.validate_url("https://localhost:8080")
1138                .await
1139                .unwrap_err()
1140                .to_string()
1141                .contains("local/private")
1142        );
1143        assert!(
1144            tool.validate_url("https://192.168.1.5")
1145                .await
1146                .unwrap_err()
1147                .to_string()
1148                .contains("local/private")
1149        );
1150        assert!(
1151            tool.validate_url("https://10.0.0.1")
1152                .await
1153                .unwrap_err()
1154                .to_string()
1155                .contains("local/private")
1156        );
1157    }
1158
1159    #[tokio::test]
1160    async fn allow_private_hosts_permits_localhost() {
1161        let tool = test_tool_with_private(vec!["localhost"], true);
1162        assert!(tool.validate_url("https://localhost:8080").await.is_ok());
1163    }
1164
1165    #[tokio::test]
1166    async fn allow_private_hosts_permits_private_ipv4() {
1167        let tool = test_tool_with_private(vec!["192.168.1.5"], true);
1168        assert!(tool.validate_url("https://192.168.1.5").await.is_ok());
1169    }
1170
1171    #[tokio::test]
1172    async fn allow_private_hosts_permits_rfc1918_with_wildcard() {
1173        let tool = test_tool_with_private(vec!["*"], true);
1174        assert!(tool.validate_url("https://10.0.0.1").await.is_ok());
1175        assert!(tool.validate_url("https://172.16.0.1").await.is_ok());
1176        assert!(tool.validate_url("https://192.168.1.1").await.is_ok());
1177        assert!(tool.validate_url("http://localhost:8123").await.is_ok());
1178    }
1179
1180    #[tokio::test]
1181    async fn allow_private_hosts_still_requires_allowlist() {
1182        let tool = test_tool_with_private(vec!["example.com"], true);
1183        let err = tool
1184            .validate_url("https://192.168.1.5")
1185            .await
1186            .unwrap_err()
1187            .to_string();
1188        assert!(
1189            err.contains("allowed_domains"),
1190            "Private host should still need allowlist match, got: {err}"
1191        );
1192    }
1193
1194    #[tokio::test]
1195    async fn allow_private_hosts_false_still_blocks() {
1196        let tool = test_tool_with_private(vec!["*"], false);
1197        assert!(
1198            tool.validate_url("https://localhost:8080")
1199                .await
1200                .unwrap_err()
1201                .to_string()
1202                .contains("local/private")
1203        );
1204    }
1205}