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
9pub 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#[derive(Debug)]
22struct ValidatedRequest {
23 url: String,
24 host: String,
25 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 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 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 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 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 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
352fn 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 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 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 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
519fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
521 let [a, b, c, _] = v4.octets();
522 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.is_multicast() || (a == 100 && (64..=127).contains(&b)) || a >= 240 || (a == 192 && b == 0 && (c == 0 || c == 2)) || (a == 198 && b == 51) || (a == 203 && b == 0) || (a == 198 && (18..=19).contains(&b)) }
535
536fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
538 let segs = v6.segments();
539 v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() || (segs[0] & 0xfe00) == 0xfc00 || (segs[0] & 0xffc0) == 0xfe80 || (segs[0] == 0x2001 && segs[1] == 0x0db8) || v6.to_ipv4_mapped().is_some_and(is_non_global_v4)
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")); assert!(is_private_or_local_host("198.51.100.1")); assert!(is_private_or_local_host("203.0.113.1")); }
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")); assert!(!is_private_or_local_host("100.128.0.1")); }
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); 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, 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 #[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 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 #[test]
1001 fn ssrf_octal_loopback_not_parsed_as_ip() {
1002 assert!(!is_private_or_local_host("0177.0.0.1"));
1005 }
1006
1007 #[test]
1008 fn ssrf_hex_loopback_not_parsed_as_ip() {
1009 assert!(!is_private_or_local_host("0x7f000001"));
1011 }
1012
1013 #[test]
1014 fn ssrf_decimal_loopback_not_parsed_as_ip() {
1015 assert!(!is_private_or_local_host("2130706433"));
1017 }
1018
1019 #[test]
1020 fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
1021 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 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 let tool = test_tool(vec!["example.com"]);
1049 assert_eq!(tool.name(), "http_request");
1050 }
1051
1052 #[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 #[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}