1use argentor_core::{ArgentorError, ArgentorResult, ToolCall, ToolResult};
2use argentor_security::{Capability, PermissionSet};
3use argentor_skills::skill::{Skill, SkillDescriptor};
4use async_trait::async_trait;
5use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
6use std::time::Duration;
7use tracing::{info, warn};
8
9const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; const BLOCKED_HOSTNAMES: &[&str] = &[
15 "metadata.google.internal",
16 "metadata.aws.internal",
17 "metadata.goog",
18];
19
20fn is_private_ip(ip: IpAddr) -> bool {
34 match ip {
35 IpAddr::V4(v4) => is_private_ipv4(v4),
36 IpAddr::V6(v6) => is_private_ipv6(v6),
37 }
38}
39
40fn is_private_ipv4(ip: Ipv4Addr) -> bool {
41 let octets = ip.octets();
42
43 if ip.is_unspecified() {
45 return true;
46 }
47 if ip.is_loopback() {
49 return true;
50 }
51 if octets[0] == 10 {
53 return true;
54 }
55 if octets[0] == 172 && (16..=31).contains(&octets[1]) {
57 return true;
58 }
59 if octets[0] == 192 && octets[1] == 168 {
61 return true;
62 }
63 if octets[0] == 169 && octets[1] == 254 {
65 return true;
66 }
67 if ip == Ipv4Addr::BROADCAST {
69 return true;
70 }
71 if octets[0] == 100 && (64..=127).contains(&octets[1]) {
73 return true;
74 }
75 if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
77 return true;
78 }
79 if octets[0] == 192 && octets[1] == 0 && octets[2] == 2 {
81 return true;
82 }
83 if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 {
85 return true;
86 }
87 if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 {
89 return true;
90 }
91 if octets[0] == 198 && (18..=19).contains(&octets[1]) {
93 return true;
94 }
95 if octets[0] >= 240 {
97 return true;
98 }
99
100 false
101}
102
103fn is_private_ipv6(ip: Ipv6Addr) -> bool {
104 if ip.is_unspecified() {
106 return true;
107 }
108 if ip.is_loopback() {
110 return true;
111 }
112 let segments = ip.segments();
113 if segments[0] & 0xffc0 == 0xfe80 {
115 return true;
116 }
117 if segments[0] & 0xfe00 == 0xfc00 {
119 return true;
120 }
121 if let Some(v4) = ip.to_ipv4_mapped() {
123 return is_private_ipv4(v4);
124 }
125
126 false
127}
128
129fn is_blocked_hostname(host: &str) -> bool {
136 let lower = host.to_lowercase();
137
138 if lower == "localhost" {
139 return true;
140 }
141
142 BLOCKED_HOSTNAMES
143 .iter()
144 .any(|blocked| lower == *blocked || lower.ends_with(&format!(".{blocked}")))
145}
146
147async fn resolve_host(host: &str, port: u16) -> Result<Vec<IpAddr>, String> {
150 let addr = format!("{host}:{port}");
151 let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&addr)
152 .await
153 .map_err(|e| format!("DNS resolution failed for '{host}': {e}"))?
154 .collect();
155
156 if addrs.is_empty() {
157 return Err(format!("DNS resolution returned no addresses for '{host}'"));
158 }
159
160 Ok(addrs.into_iter().map(|sa| sa.ip()).collect())
161}
162
163fn check_resolved_ips(host: &str, ips: &[IpAddr]) -> Result<(), String> {
166 for ip in ips {
167 if is_private_ip(*ip) {
168 return Err(format!(
169 "Access denied: '{host}' resolves to private/internal address {ip}"
170 ));
171 }
172 }
173 Ok(())
174}
175
176async fn validate_url_ssrf(parsed_url: &reqwest::Url) -> Result<(), String> {
179 let host = parsed_url
180 .host_str()
181 .ok_or_else(|| "URL has no host".to_string())?;
182
183 if is_blocked_hostname(host) {
185 return Err(format!("Access denied: '{host}' is a blocked hostname"));
186 }
187
188 if let Ok(ip) = host.parse::<IpAddr>() {
190 if is_private_ip(ip) {
191 return Err(format!(
192 "Access denied: '{host}' is a private/internal address"
193 ));
194 }
195 return Ok(());
196 }
197
198 let trimmed = host.trim_start_matches('[').trim_end_matches(']');
200 if let Ok(ip) = trimmed.parse::<IpAddr>() {
201 if is_private_ip(ip) {
202 return Err(format!(
203 "Access denied: '{host}' is a private/internal address"
204 ));
205 }
206 return Ok(());
207 }
208
209 let port = parsed_url.port_or_known_default().unwrap_or(80);
211 let ips = resolve_host(host, port).await?;
212 check_resolved_ips(host, &ips)?;
213
214 Ok(())
215}
216
217pub struct HttpFetchSkill {
229 descriptor: SkillDescriptor,
230 client: reqwest::Client,
231}
232
233impl HttpFetchSkill {
234 pub fn new() -> Self {
236 let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
238 let redirect_count = attempt.previous().len();
240 if redirect_count >= 10 {
241 return attempt.error(format!("too many redirects ({redirect_count})"));
242 }
243
244 let url = attempt.url().clone();
245
246 match url.scheme() {
248 "http" | "https" => {}
249 scheme => {
250 return attempt.error(format!("redirect to unsupported scheme '{scheme}'"));
251 }
252 }
253
254 if let Some(host) = url.host_str() {
255 if is_blocked_hostname(host) {
257 return attempt.error(format!("redirect to blocked hostname '{host}'"));
258 }
259
260 if let Ok(ip) = host.parse::<IpAddr>() {
262 if is_private_ip(ip) {
263 return attempt.error(format!("redirect to private IP {ip}"));
264 }
265 }
266
267 let trimmed = host.trim_start_matches('[').trim_end_matches(']');
269 if let Ok(ip) = trimmed.parse::<IpAddr>() {
270 if is_private_ip(ip) {
271 return attempt.error(format!("redirect to private IP {ip}"));
272 }
273 }
274 }
275
276 attempt.follow()
284 });
285
286 #[allow(clippy::expect_used)]
291 let client = reqwest::Client::builder()
292 .timeout(Duration::from_secs(30))
293 .redirect(redirect_policy)
294 .build()
295 .expect("Failed to create HTTP client -- TLS backend unavailable");
296
297 Self {
298 descriptor: SkillDescriptor {
299 name: "http_fetch".to_string(),
300 description: "Fetch content from a URL via HTTP GET or POST.".to_string(),
301 parameters_schema: serde_json::json!({
302 "type": "object",
303 "properties": {
304 "url": {
305 "type": "string",
306 "description": "The URL to fetch"
307 },
308 "method": {
309 "type": "string",
310 "enum": ["GET", "POST"],
311 "description": "HTTP method (default: GET)"
312 },
313 "headers": {
314 "type": "object",
315 "description": "Optional HTTP headers as key-value pairs"
316 },
317 "body": {
318 "type": "string",
319 "description": "Optional request body (for POST)"
320 }
321 },
322 "required": ["url"]
323 }),
324 required_capabilities: vec![Capability::NetworkAccess {
325 allowed_hosts: vec![], }],
327 requires_approval: false,
328 },
329 client,
330 }
331 }
332}
333
334impl Default for HttpFetchSkill {
335 fn default() -> Self {
336 Self::new()
337 }
338}
339
340#[async_trait]
341impl Skill for HttpFetchSkill {
342 fn descriptor(&self) -> &SkillDescriptor {
343 &self.descriptor
344 }
345
346 fn validate_arguments(
347 &self,
348 call: &ToolCall,
349 permissions: &PermissionSet,
350 ) -> ArgentorResult<()> {
351 let url_str = call.arguments["url"].as_str().unwrap_or_default();
352
353 if url_str.is_empty() {
354 return Ok(()); }
356
357 let parsed_url = match reqwest::Url::parse(url_str) {
358 Ok(u) => u,
359 Err(_) => return Ok(()), };
361
362 if let Some(host) = parsed_url.host_str() {
363 if !permissions.check_network(host) {
364 return Err(ArgentorError::Security(format!(
365 "network access not permitted for host '{host}'"
366 )));
367 }
368 }
369
370 Ok(())
371 }
372
373 async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
374 let url = call.arguments["url"]
375 .as_str()
376 .unwrap_or_default()
377 .to_string();
378
379 if url.is_empty() {
380 return Ok(ToolResult::error(&call.id, "Empty URL"));
381 }
382
383 let parsed_url = match reqwest::Url::parse(&url) {
385 Ok(u) => u,
386 Err(e) => {
387 return Ok(ToolResult::error(
388 &call.id,
389 format!("Invalid URL '{url}': {e}"),
390 ));
391 }
392 };
393
394 match parsed_url.scheme() {
396 "http" | "https" => {}
397 scheme => {
398 return Ok(ToolResult::error(
399 &call.id,
400 format!("Unsupported scheme '{scheme}'. Only http/https allowed."),
401 ));
402 }
403 }
404
405 if let Err(msg) = validate_url_ssrf(&parsed_url).await {
407 warn!(url = %url, reason = %msg, "SSRF protection blocked request");
408 return Ok(ToolResult::error(&call.id, msg));
409 }
410
411 let method = call.arguments["method"]
412 .as_str()
413 .unwrap_or("GET")
414 .to_uppercase();
415
416 info!(url = %url, method = %method, "HTTP fetch");
417
418 let mut request = match method.as_str() {
419 "GET" => self.client.get(&url),
420 "POST" => self.client.post(&url),
421 _ => {
422 return Ok(ToolResult::error(
423 &call.id,
424 format!("Unsupported method '{method}'. Use GET or POST."),
425 ));
426 }
427 };
428
429 if let Some(headers) = call.arguments["headers"].as_object() {
431 for (key, value) in headers {
432 if let Some(v) = value.as_str() {
433 request = request.header(key.as_str(), v);
434 }
435 }
436 }
437
438 if method == "POST" {
440 if let Some(body) = call.arguments["body"].as_str() {
441 request = request.body(body.to_string());
442 }
443 }
444
445 let response = match request.send().await {
446 Ok(r) => r,
447 Err(e) => {
448 return Ok(ToolResult::error(
449 &call.id,
450 format!("HTTP request failed: {e}"),
451 ));
452 }
453 };
454
455 let status = response.status().as_u16();
456 let headers: serde_json::Map<String, serde_json::Value> = response
457 .headers()
458 .iter()
459 .filter_map(|(k, v)| {
460 v.to_str()
461 .ok()
462 .map(|val| (k.to_string(), serde_json::Value::String(val.to_string())))
463 })
464 .collect();
465
466 let content_type = response
467 .headers()
468 .get("content-type")
469 .and_then(|v| v.to_str().ok())
470 .unwrap_or("")
471 .to_string();
472
473 let body_bytes = match response.bytes().await {
474 Ok(b) => b,
475 Err(e) => {
476 return Ok(ToolResult::error(
477 &call.id,
478 format!("Failed to read response body: {e}"),
479 ));
480 }
481 };
482
483 if body_bytes.len() > MAX_RESPONSE_SIZE {
484 return Ok(ToolResult::error(
485 &call.id,
486 format!(
487 "Response too large: {} bytes (max: {} bytes)",
488 body_bytes.len(),
489 MAX_RESPONSE_SIZE
490 ),
491 ));
492 }
493
494 let body = String::from_utf8_lossy(&body_bytes);
495
496 let result = serde_json::json!({
497 "status": status,
498 "headers": headers,
499 "content_type": content_type,
500 "body": body,
501 "size": body_bytes.len(),
502 });
503
504 if (200..400).contains(&status) {
505 Ok(ToolResult::success(&call.id, result.to_string()))
506 } else {
507 Ok(ToolResult::error(&call.id, result.to_string()))
508 }
509 }
510}
511
512#[cfg(test)]
517#[allow(clippy::unwrap_used, clippy::expect_used)]
518mod tests {
519 use super::*;
520
521 #[test]
524 fn test_is_private_ip_comprehensive() {
525 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
527 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255))));
528
529 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
531 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(10, 255, 255, 255))));
532
533 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
535 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255))));
536 assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 15, 0, 1))));
538 assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 32, 0, 1))));
539
540 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))));
542 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 255, 255))));
543
544 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
546 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
547
548 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
550
551 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::BROADCAST)));
553
554 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
556 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(100, 127, 255, 255))));
557 assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(100, 63, 255, 255))));
558
559 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
561 assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(255, 255, 255, 254))));
562
563 assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
565 assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
566 assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))));
567
568 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
570
571 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
573
574 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
576 0xfe80, 0, 0, 0, 0, 0, 0, 1
577 ))));
578 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
579 0xfebf, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
580 ))));
581
582 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
584 0xfc00, 0, 0, 0, 0, 0, 0, 1
585 ))));
586 assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
587 0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
588 ))));
589
590 assert!(!is_private_ip(IpAddr::V6(Ipv6Addr::new(
592 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
593 ))));
594 }
595
596 #[test]
599 fn test_blocked_hostnames() {
600 assert!(is_blocked_hostname("localhost"));
601 assert!(is_blocked_hostname("LOCALHOST"));
602 assert!(is_blocked_hostname("metadata.google.internal"));
603 assert!(is_blocked_hostname("foo.metadata.google.internal"));
604 assert!(is_blocked_hostname("metadata.aws.internal"));
605
606 assert!(!is_blocked_hostname("google.com"));
607 assert!(!is_blocked_hostname("api.anthropic.com"));
608 assert!(!is_blocked_hostname("example.com"));
609 }
610
611 #[test]
614 fn test_private_host_detection() {
615 assert!(is_blocked_hostname("localhost"));
618 assert!(is_private_ip("127.0.0.1".parse().unwrap()));
619 assert!(is_private_ip("192.168.1.1".parse().unwrap()));
620 assert!(is_private_ip("10.0.0.1".parse().unwrap()));
621 assert!(is_private_ip("169.254.169.254".parse().unwrap()));
622 assert!(is_blocked_hostname("metadata.google.internal"));
623 assert!(!is_private_ip("93.184.216.34".parse().unwrap())); assert!(!is_blocked_hostname("api.anthropic.com"));
625 }
626
627 #[tokio::test]
628 async fn test_http_fetch_invalid_url() {
629 let skill = HttpFetchSkill::new();
630 let call = ToolCall {
631 id: "test_1".to_string(),
632 name: "http_fetch".to_string(),
633 arguments: serde_json::json!({"url": "not a url"}),
634 };
635 let result = skill.execute(call).await.unwrap();
636 assert!(result.is_error);
637 }
638
639 #[tokio::test]
640 async fn test_http_fetch_blocks_ssrf() {
641 let skill = HttpFetchSkill::new();
642 let call = ToolCall {
643 id: "test_2".to_string(),
644 name: "http_fetch".to_string(),
645 arguments: serde_json::json!({"url": "http://169.254.169.254/latest/meta-data/"}),
646 };
647 let result = skill.execute(call).await.unwrap();
648 assert!(result.is_error);
649 assert!(result.content.contains("private") || result.content.contains("Access denied"));
650 }
651
652 #[tokio::test]
653 async fn test_http_fetch_blocks_localhost() {
654 let skill = HttpFetchSkill::new();
655 let call = ToolCall {
656 id: "test_3".to_string(),
657 name: "http_fetch".to_string(),
658 arguments: serde_json::json!({"url": "http://localhost:8080/admin"}),
659 };
660 let result = skill.execute(call).await.unwrap();
661 assert!(result.is_error);
662 }
663
664 #[tokio::test]
665 async fn test_http_fetch_blocks_bad_scheme() {
666 let skill = HttpFetchSkill::new();
667 let call = ToolCall {
668 id: "test_4".to_string(),
669 name: "http_fetch".to_string(),
670 arguments: serde_json::json!({"url": "file:///etc/passwd"}),
671 };
672 let result = skill.execute(call).await.unwrap();
673 assert!(result.is_error);
674 }
675
676 #[tokio::test]
679 async fn test_blocks_ipv6_loopback() {
680 let skill = HttpFetchSkill::new();
681 let call = ToolCall {
682 id: "test_ipv6_lo".to_string(),
683 name: "http_fetch".to_string(),
684 arguments: serde_json::json!({"url": "http://[::1]:8080/"}),
685 };
686 let result = skill.execute(call).await.unwrap();
687 assert!(result.is_error, "IPv6 loopback must be blocked");
688 assert!(
689 result.content.contains("private") || result.content.contains("Access denied"),
690 "Error should mention private/access denied, got: {}",
691 result.content
692 );
693 }
694
695 #[tokio::test]
696 async fn test_blocks_zero_address() {
697 let skill = HttpFetchSkill::new();
698 let call = ToolCall {
699 id: "test_zero".to_string(),
700 name: "http_fetch".to_string(),
701 arguments: serde_json::json!({"url": "http://0.0.0.0:8080/"}),
702 };
703 let result = skill.execute(call).await.unwrap();
704 assert!(result.is_error, "0.0.0.0 must be blocked");
705 assert!(
706 result.content.contains("private") || result.content.contains("Access denied"),
707 "Error should mention private/access denied, got: {}",
708 result.content
709 );
710 }
711
712 #[tokio::test]
713 async fn test_blocks_metadata_variants() {
714 let skill = HttpFetchSkill::new();
715
716 let call = ToolCall {
718 id: "test_meta_ip".to_string(),
719 name: "http_fetch".to_string(),
720 arguments: serde_json::json!({"url": "http://169.254.169.254/"}),
721 };
722 let result = skill.execute(call).await.unwrap();
723 assert!(result.is_error, "169.254.169.254 must be blocked");
724
725 let call = ToolCall {
727 id: "test_meta_gcp".to_string(),
728 name: "http_fetch".to_string(),
729 arguments: serde_json::json!({"url": "http://metadata.google.internal/"}),
730 };
731 let result = skill.execute(call).await.unwrap();
732 assert!(result.is_error, "metadata.google.internal must be blocked");
733 }
734
735 #[tokio::test]
736 async fn test_allows_public_host() {
737 let skill = HttpFetchSkill::new();
738 let call = ToolCall {
739 id: "test_public".to_string(),
740 name: "http_fetch".to_string(),
741 arguments: serde_json::json!({"url": "http://example.com/"}),
742 };
743 let result = skill.execute(call).await.unwrap();
744 let blocked =
748 result.content.contains("Access denied") || result.content.contains("blocked hostname");
749 assert!(
750 !blocked,
751 "Public host example.com should not be blocked by SSRF protection, got: {}",
752 result.content
753 );
754 }
755
756 #[test]
759 fn test_validate_arguments_denies_disallowed_host() {
760 let skill = HttpFetchSkill::new();
761 let mut perms = PermissionSet::new();
762 perms.grant(Capability::NetworkAccess {
763 allowed_hosts: vec!["api.anthropic.com".to_string()],
764 });
765
766 let call = ToolCall {
767 id: "test_va_1".to_string(),
768 name: "http_fetch".to_string(),
769 arguments: serde_json::json!({"url": "http://evil.com/payload"}),
770 };
771 let result = skill.validate_arguments(&call, &perms);
772 assert!(result.is_err());
773 }
774
775 #[test]
776 fn test_validate_arguments_allows_permitted_host() {
777 let skill = HttpFetchSkill::new();
778 let mut perms = PermissionSet::new();
779 perms.grant(Capability::NetworkAccess {
780 allowed_hosts: vec!["api.anthropic.com".to_string()],
781 });
782
783 let call = ToolCall {
784 id: "test_va_2".to_string(),
785 name: "http_fetch".to_string(),
786 arguments: serde_json::json!({"url": "https://api.anthropic.com/v1/messages"}),
787 };
788 let result = skill.validate_arguments(&call, &perms);
789 assert!(result.is_ok());
790 }
791
792 #[test]
793 fn test_validate_arguments_wildcard_allows_all() {
794 let skill = HttpFetchSkill::new();
795 let mut perms = PermissionSet::new();
796 perms.grant(Capability::NetworkAccess {
797 allowed_hosts: vec!["*".to_string()],
798 });
799
800 let call = ToolCall {
801 id: "test_va_3".to_string(),
802 name: "http_fetch".to_string(),
803 arguments: serde_json::json!({"url": "https://any-host.example.com/path"}),
804 };
805 let result = skill.validate_arguments(&call, &perms);
806 assert!(result.is_ok());
807 }
808}