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!(
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")); assert!(is_private_or_local_host("198.51.100.1")); assert!(is_private_or_local_host("203.0.113.1")); }
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")); assert!(!is_private_or_local_host("100.128.0.1")); }
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); 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, 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 #[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 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 #[test]
1009 fn ssrf_octal_loopback_not_parsed_as_ip() {
1010 assert!(!is_private_or_local_host("0177.0.0.1"));
1013 }
1014
1015 #[test]
1016 fn ssrf_hex_loopback_not_parsed_as_ip() {
1017 assert!(!is_private_or_local_host("0x7f000001"));
1019 }
1020
1021 #[test]
1022 fn ssrf_decimal_loopback_not_parsed_as_ip() {
1023 assert!(!is_private_or_local_host("2130706433"));
1025 }
1026
1027 #[test]
1028 fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
1029 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 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 let tool = test_tool(vec!["example.com"]);
1057 assert_eq!(tool.name(), "http_request");
1058 }
1059
1060 #[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 #[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}