1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
14use std::time::Duration;
15use url::Url;
16
17const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum UrlValidationError {
24 InvalidUrl(String),
26 DisallowedScheme(String),
28 MissingHostname,
30 BlockedHost(String),
32}
33
34impl std::fmt::Display for UrlValidationError {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 Self::InvalidUrl(msg) => write!(f, "Invalid URL: {msg}"),
38 Self::DisallowedScheme(scheme) => {
39 write!(f, "Disallowed URL scheme: {scheme} (must be http or https)")
40 }
41 Self::MissingHostname => write!(f, "URL must have a hostname"),
42 Self::BlockedHost(host) => {
43 write!(f, "Blocked host: {host} (private/internal address)")
44 }
45 }
46 }
47}
48
49impl std::error::Error for UrlValidationError {}
50
51pub fn validate_safe_url(raw_url: &str) -> Result<Url, UrlValidationError> {
57 let url = Url::parse(raw_url).map_err(|e| UrlValidationError::InvalidUrl(e.to_string()))?;
58
59 match url.scheme() {
61 "http" | "https" => {}
62 other => return Err(UrlValidationError::DisallowedScheme(other.to_string())),
63 }
64
65 let host = url.host_str().ok_or(UrlValidationError::MissingHostname)?;
67
68 if is_blocked_host(host) {
70 return Err(UrlValidationError::BlockedHost(host.to_string()));
71 }
72
73 Ok(url)
74}
75
76pub async fn validate_url_dns_pinned(
90 raw_url: &str,
91) -> Result<(Url, Vec<SocketAddr>), UrlValidationError> {
92 validate_url_with_resolver(raw_url, default_dns_resolve).await
93}
94
95async fn validate_url_with_resolver<R, F>(
97 raw_url: &str,
98 resolve: R,
99) -> Result<(Url, Vec<SocketAddr>), UrlValidationError>
100where
101 R: Fn(String, u16) -> F,
102 F: std::future::Future<Output = Result<Vec<SocketAddr>, std::io::Error>>,
103{
104 let url = validate_safe_url(raw_url)?;
106 let host = url
107 .host_str()
108 .ok_or(UrlValidationError::MissingHostname)?
109 .to_string();
110
111 let bare = host
113 .strip_prefix('[')
114 .and_then(|s| s.strip_suffix(']'))
115 .unwrap_or(&host);
116 if bare.parse::<IpAddr>().is_ok() {
117 return Ok((url, Vec::new()));
118 }
119
120 let port = url.port_or_known_default().unwrap_or(443);
122 let addrs = resolve(host.clone(), port)
123 .await
124 .map_err(|_| UrlValidationError::BlockedHost(host.clone()))?;
125
126 if addrs.is_empty() {
127 return Err(UrlValidationError::BlockedHost(host.clone()));
128 }
129
130 for addr in &addrs {
131 if is_blocked_ip(addr.ip()) {
132 tracing::warn!(
133 host = %host,
134 resolved_ip = %addr.ip(),
135 "DNS rebinding check blocked: hostname resolves to private address"
136 );
137 return Err(UrlValidationError::BlockedHost(format!(
138 "{host} resolves to blocked address {}",
139 addr.ip()
140 )));
141 }
142 }
143
144 Ok((url, addrs))
145}
146
147async fn default_dns_resolve(host: String, port: u16) -> Result<Vec<SocketAddr>, std::io::Error> {
150 tokio::time::timeout(
151 DNS_LOOKUP_TIMEOUT,
152 tokio::net::lookup_host(format!("{host}:{port}")),
153 )
154 .await
155 .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "DNS lookup timed out"))?
156 .map(|iter| iter.collect())
157}
158
159fn is_blocked_host(host: &str) -> bool {
161 let host_lower = host.to_lowercase();
162
163 if host_lower == "localhost"
165 || host_lower == "localhost."
166 || host_lower.ends_with(".localhost")
167 || host_lower.ends_with(".localhost.")
168 {
169 return true;
170 }
171
172 let bare = host_lower
174 .strip_prefix('[')
175 .and_then(|s| s.strip_suffix(']'))
176 .unwrap_or(&host_lower);
177
178 if let Ok(ip) = bare.parse::<IpAddr>() {
180 return is_blocked_ip(ip);
181 }
182
183 if host_lower == "metadata.google.internal" || host_lower == "metadata.google.internal." {
185 return true;
186 }
187
188 false
189}
190
191pub fn is_blocked_ip(ip: IpAddr) -> bool {
201 is_blocked_ip_with_allowlist(ip, operator_allowed_cidrs())
202}
203
204pub const SSRF_ALLOW_CIDRS_ENV: &str = "EVERRUNS_SSRF_ALLOW_CIDRS";
207
208pub fn operator_allowed_cidrs() -> &'static [Cidr] {
219 static ALLOWED: std::sync::LazyLock<Vec<Cidr>> = std::sync::LazyLock::new(|| {
220 let raw = std::env::var(SSRF_ALLOW_CIDRS_ENV).unwrap_or_default();
221 let cidrs = parse_cidr_list(&raw);
222 for cidr in &cidrs {
223 tracing::warn!(
224 cidr = %cidr,
225 "SSRF protection: operator allowlisted a private range via {SSRF_ALLOW_CIDRS_ENV}"
226 );
227 }
228 cidrs
229 });
230 &ALLOWED
231}
232
233fn parse_cidr_list(raw: &str) -> Vec<Cidr> {
235 raw.split(',')
236 .map(str::trim)
237 .filter(|entry| !entry.is_empty())
238 .filter_map(|entry| match entry.parse::<Cidr>() {
239 Ok(cidr) => Some(cidr),
240 Err(()) => {
241 tracing::warn!(entry, "Ignoring invalid CIDR in {SSRF_ALLOW_CIDRS_ENV}");
242 None
243 }
244 })
245 .collect()
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct Cidr {
251 network: IpAddr,
252 prefix: u8,
253}
254
255impl std::fmt::Display for Cidr {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 write!(f, "{}/{}", self.network, self.prefix)
258 }
259}
260
261impl std::str::FromStr for Cidr {
262 type Err = ();
263
264 fn from_str(s: &str) -> Result<Self, ()> {
265 let (addr, prefix) = s.split_once('/').ok_or(())?;
266 let network: IpAddr = addr.trim().parse().map_err(|_| ())?;
267 let prefix: u8 = prefix.trim().parse().map_err(|_| ())?;
268 let max = match network {
269 IpAddr::V4(_) => 32,
270 IpAddr::V6(_) => 128,
271 };
272 if prefix > max {
273 return Err(());
274 }
275 Ok(Self { network, prefix })
276 }
277}
278
279impl Cidr {
280 fn contains(&self, ip: IpAddr) -> bool {
283 let ip = match ip {
284 IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
285 v4 => v4,
286 };
287 match (self.network, ip) {
288 (IpAddr::V4(net), IpAddr::V4(ip)) => {
289 let mask = u32::MAX
291 .checked_shl(32 - u32::from(self.prefix))
292 .unwrap_or(0);
293 (u32::from(net) & mask) == (u32::from(ip) & mask)
294 }
295 (IpAddr::V6(net), IpAddr::V6(ip)) => {
296 let mask = u128::MAX
297 .checked_shl(128 - u32::from(self.prefix))
298 .unwrap_or(0);
299 (u128::from(net) & mask) == (u128::from(ip) & mask)
300 }
301 _ => false,
302 }
303 }
304}
305
306fn is_blocked_ip_with_allowlist(ip: IpAddr, allowed: &[Cidr]) -> bool {
308 let blocked = match ip {
309 IpAddr::V4(v4) => is_blocked_ipv4(v4),
310 IpAddr::V6(v6) => is_blocked_ipv6(v6),
311 };
312 blocked && !allowed.iter().any(|cidr| cidr.contains(ip))
313}
314
315fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
316 let octets = ip.octets();
317
318 if octets[0] == 127 {
320 return true;
321 }
322
323 if ip.is_unspecified() {
325 return true;
326 }
327
328 if octets[0] == 10 {
330 return true;
331 }
332
333 if octets[0] == 172 && (16..=31).contains(&octets[1]) {
335 return true;
336 }
337
338 if octets[0] == 192 && octets[1] == 168 {
340 return true;
341 }
342
343 if octets[0] == 169 && octets[1] == 254 {
345 return true;
346 }
347
348 if octets[0] == 100 && (64..=127).contains(&octets[1]) {
350 return true;
351 }
352
353 if (octets[0] == 192 && octets[1] == 0 && octets[2] == 2)
355 || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100)
356 || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113)
357 {
358 return true;
359 }
360
361 false
362}
363
364fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
365 if ip.is_loopback() {
367 return true;
368 }
369
370 if ip.is_unspecified() {
372 return true;
373 }
374
375 let segments = ip.segments();
377 if segments[0] & 0xffc0 == 0xfe80 {
378 return true;
379 }
380
381 if segments[0] & 0xfe00 == 0xfc00 {
383 return true;
384 }
385
386 if let Some(v4) = ip.to_ipv4_mapped() {
388 return is_blocked_ipv4(v4);
389 }
390
391 false
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
401 fn accepts_https_public_url() {
402 assert!(validate_safe_url("https://mcp.example.com/v1/mcp").is_ok());
403 }
404
405 #[test]
406 fn accepts_http_public_url() {
407 assert!(validate_safe_url("http://mcp.example.com/v1/mcp").is_ok());
408 }
409
410 #[test]
411 fn accepts_url_with_port() {
412 assert!(validate_safe_url("https://mcp.example.com:8443/v1/mcp").is_ok());
413 }
414
415 #[test]
416 fn accepts_url_with_path_and_query() {
417 assert!(validate_safe_url("https://api.example.com/mcp?key=val").is_ok());
418 }
419
420 #[test]
423 fn rejects_ftp_scheme() {
424 let err = validate_safe_url("ftp://evil.com/file").unwrap_err();
425 assert!(matches!(err, UrlValidationError::DisallowedScheme(_)));
426 }
427
428 #[test]
429 fn rejects_file_scheme() {
430 let err = validate_safe_url("file:///etc/passwd").unwrap_err();
431 assert!(matches!(err, UrlValidationError::DisallowedScheme(_)));
432 }
433
434 #[test]
435 fn rejects_javascript_scheme() {
436 let err = validate_safe_url("javascript:alert(1)").unwrap_err();
437 assert!(
439 matches!(err, UrlValidationError::DisallowedScheme(_))
440 || matches!(err, UrlValidationError::MissingHostname)
441 );
442 }
443
444 #[test]
445 fn rejects_data_scheme() {
446 let err = validate_safe_url("data:text/plain,hello").unwrap_err();
447 assert!(
448 matches!(err, UrlValidationError::DisallowedScheme(_))
449 || matches!(err, UrlValidationError::MissingHostname)
450 );
451 }
452
453 #[test]
456 fn rejects_empty_string() {
457 assert!(validate_safe_url("").is_err());
458 }
459
460 #[test]
461 fn rejects_not_a_url() {
462 assert!(validate_safe_url("not a url").is_err());
463 }
464
465 #[test]
468 fn rejects_localhost() {
469 let err = validate_safe_url("http://localhost/path").unwrap_err();
470 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
471 }
472
473 #[test]
474 fn rejects_localhost_with_port() {
475 let err = validate_safe_url("http://localhost:8080/path").unwrap_err();
476 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
477 }
478
479 #[test]
480 fn rejects_subdomain_of_localhost() {
481 let err = validate_safe_url("http://foo.localhost/path").unwrap_err();
482 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
483 }
484
485 #[test]
488 fn rejects_127_0_0_1() {
489 let err = validate_safe_url("http://127.0.0.1/path").unwrap_err();
490 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
491 }
492
493 #[test]
494 fn rejects_127_x_x_x() {
495 let err = validate_safe_url("http://127.255.0.1/path").unwrap_err();
496 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
497 }
498
499 #[test]
500 fn rejects_ipv6_loopback() {
501 let err = validate_safe_url("http://[::1]/path").unwrap_err();
502 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
503 }
504
505 #[test]
508 fn rejects_10_x() {
509 let err = validate_safe_url("http://10.0.0.1/path").unwrap_err();
510 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
511 }
512
513 #[test]
514 fn rejects_172_16_x() {
515 let err = validate_safe_url("http://172.16.0.1/path").unwrap_err();
516 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
517 }
518
519 #[test]
520 fn rejects_172_31_x() {
521 let err = validate_safe_url("http://172.31.255.255/path").unwrap_err();
522 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
523 }
524
525 #[test]
526 fn accepts_172_32_x() {
527 assert!(validate_safe_url("http://172.32.0.1/path").is_ok());
529 }
530
531 #[test]
532 fn rejects_192_168_x() {
533 let err = validate_safe_url("http://192.168.1.1/path").unwrap_err();
534 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
535 }
536
537 #[test]
540 fn rejects_link_local() {
541 let err = validate_safe_url("http://169.254.1.1/path").unwrap_err();
542 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
543 }
544
545 #[test]
546 fn rejects_cloud_metadata_ip() {
547 let err = validate_safe_url("http://169.254.169.254/latest/meta-data/").unwrap_err();
548 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
549 }
550
551 #[test]
552 fn rejects_gce_metadata_hostname() {
553 let err =
554 validate_safe_url("http://metadata.google.internal/computeMetadata/v1/").unwrap_err();
555 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
556 }
557
558 #[test]
561 fn rejects_unspecified_v4() {
562 let err = validate_safe_url("http://0.0.0.0/path").unwrap_err();
563 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
564 }
565
566 #[test]
569 fn rejects_ipv6_unspecified() {
570 let err = validate_safe_url("http://[::]/path").unwrap_err();
571 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
572 }
573
574 #[test]
575 fn rejects_ipv6_link_local() {
576 let err = validate_safe_url("http://[fe80::1]/path").unwrap_err();
577 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
578 }
579
580 #[test]
581 fn rejects_ipv6_unique_local() {
582 let err = validate_safe_url("http://[fd00::1]/path").unwrap_err();
583 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
584 }
585
586 #[test]
587 fn rejects_ipv4_mapped_ipv6_private() {
588 let err = validate_safe_url("http://[::ffff:127.0.0.1]/path").unwrap_err();
589 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
590 }
591
592 #[test]
593 fn rejects_ipv4_mapped_ipv6_metadata() {
594 let err =
595 validate_safe_url("http://[::ffff:169.254.169.254]/latest/meta-data/").unwrap_err();
596 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
597 }
598
599 #[test]
602 fn rejects_cgnat() {
603 let err = validate_safe_url("http://100.64.0.1/path").unwrap_err();
604 assert!(matches!(err, UrlValidationError::BlockedHost(_)));
605 }
606
607 #[test]
610 fn error_display_messages() {
611 assert!(
612 UrlValidationError::BlockedHost("localhost".into())
613 .to_string()
614 .contains("private/internal")
615 );
616 assert!(
617 UrlValidationError::DisallowedScheme("ftp".into())
618 .to_string()
619 .contains("http or https")
620 );
621 }
622
623 fn cidrs(raw: &str) -> Vec<Cidr> {
626 parse_cidr_list(raw)
627 }
628
629 #[test]
630 fn allowlist_exempts_matching_private_ipv4() {
631 let allow = cidrs("10.42.0.0/16");
632 assert!(!is_blocked_ip_with_allowlist(
633 "10.42.7.1".parse().unwrap(),
634 &allow
635 ));
636 assert!(is_blocked_ip_with_allowlist(
638 "10.43.0.1".parse().unwrap(),
639 &allow
640 ));
641 assert!(is_blocked_ip_with_allowlist(
642 "192.168.1.1".parse().unwrap(),
643 &allow
644 ));
645 }
646
647 #[test]
648 fn allowlist_does_not_affect_public_ips() {
649 let allow = cidrs("10.0.0.0/8");
650 assert!(!is_blocked_ip_with_allowlist(
651 "1.1.1.1".parse().unwrap(),
652 &allow
653 ));
654 }
655
656 #[test]
657 fn allowlist_supports_multiple_entries_and_ipv6() {
658 let allow = cidrs("10.42.0.0/16, fd00::/8");
659 assert!(!is_blocked_ip_with_allowlist(
660 "fd00::1".parse().unwrap(),
661 &allow
662 ));
663 assert!(is_blocked_ip_with_allowlist(
664 "fe80::1".parse().unwrap(),
665 &allow
666 ));
667 }
668
669 #[test]
670 fn allowlist_matches_ipv4_mapped_ipv6_against_v4_cidr() {
671 let allow = cidrs("10.0.0.0/8");
672 assert!(!is_blocked_ip_with_allowlist(
673 "::ffff:10.0.0.1".parse().unwrap(),
674 &allow
675 ));
676 }
677
678 #[test]
679 fn allowlist_ignores_invalid_entries() {
680 let allow = cidrs("not-a-cidr, 10.0.0.0/33, 10.0.0.0, ,10.42.0.0/16");
681 assert_eq!(allow.len(), 1);
682 assert_eq!(allow[0].to_string(), "10.42.0.0/16");
683 }
684
685 #[test]
686 fn allowlist_prefix_zero_matches_family_wide() {
687 let allow = cidrs("0.0.0.0/0");
688 assert!(!is_blocked_ip_with_allowlist(
689 "10.0.0.1".parse().unwrap(),
690 &allow
691 ));
692 assert!(is_blocked_ip_with_allowlist(
694 "fd00::1".parse().unwrap(),
695 &allow
696 ));
697 }
698
699 #[test]
700 fn allowlist_never_unblocks_localhost_hostname() {
701 assert!(is_blocked_host("localhost"));
703 assert!(is_blocked_host("metadata.google.internal"));
704 }
705
706 #[test]
707 fn empty_allowlist_blocks_all_private() {
708 assert!(is_blocked_ip_with_allowlist(
709 "10.0.0.1".parse().unwrap(),
710 &[]
711 ));
712 }
713
714 #[tokio::test]
717 async fn dns_pinned_rejects_private_ip_literal() {
718 let result = validate_url_dns_pinned("http://10.0.0.1/mcp").await;
719 assert!(matches!(result, Err(UrlValidationError::BlockedHost(_))));
720 }
721
722 #[tokio::test]
723 async fn dns_pinned_rejects_loopback_ip_literal() {
724 let result = validate_url_dns_pinned("http://127.0.0.1/mcp").await;
725 assert!(matches!(result, Err(UrlValidationError::BlockedHost(_))));
726 }
727
728 #[tokio::test]
729 async fn dns_pinned_rejects_metadata_ip_literal() {
730 let result = validate_url_dns_pinned("http://169.254.169.254/latest/meta-data/").await;
731 assert!(matches!(result, Err(UrlValidationError::BlockedHost(_))));
732 }
733
734 #[tokio::test]
735 async fn dns_pinned_rejects_localhost_hostname() {
736 let result = validate_url_dns_pinned("http://localhost:8080/mcp").await;
738 assert!(matches!(result, Err(UrlValidationError::BlockedHost(_))));
739 }
740
741 #[tokio::test]
742 async fn dns_pinned_rejects_bad_scheme() {
743 let result = validate_url_dns_pinned("ftp://example.com/mcp").await;
744 assert!(matches!(
745 result,
746 Err(UrlValidationError::DisallowedScheme(_))
747 ));
748 }
749
750 async fn private_ip_resolver(
754 _host: String,
755 _port: u16,
756 ) -> Result<Vec<SocketAddr>, std::io::Error> {
757 Ok(vec!["10.0.0.1:80".parse().unwrap()])
758 }
759
760 async fn public_ip_resolver(
762 _host: String,
763 _port: u16,
764 ) -> Result<Vec<SocketAddr>, std::io::Error> {
765 Ok(vec!["1.1.1.1:443".parse().unwrap()])
766 }
767
768 async fn failing_resolver(
770 _host: String,
771 _port: u16,
772 ) -> Result<Vec<SocketAddr>, std::io::Error> {
773 Err(std::io::Error::new(
774 std::io::ErrorKind::TimedOut,
775 "DNS lookup timed out",
776 ))
777 }
778
779 async fn empty_resolver(_host: String, _port: u16) -> Result<Vec<SocketAddr>, std::io::Error> {
781 Ok(vec![])
782 }
783
784 #[tokio::test]
785 async fn dns_resolver_blocks_hostname_resolving_to_private_ip() {
786 let result =
788 validate_url_with_resolver("http://evil.example.com/mcp", private_ip_resolver).await;
789 assert!(
790 matches!(result, Err(UrlValidationError::BlockedHost(_))),
791 "expected BlockedHost, got {result:?}"
792 );
793 }
794
795 #[tokio::test]
796 async fn dns_resolver_allows_hostname_resolving_to_public_ip() {
797 let (url, addrs) =
798 validate_url_with_resolver("https://mcp.example.com/v1/mcp", public_ip_resolver)
799 .await
800 .expect("should succeed");
801 assert_eq!(url.host_str(), Some("mcp.example.com"));
802 assert_eq!(addrs.len(), 1);
804 }
805
806 #[tokio::test]
807 async fn dns_resolver_blocks_on_lookup_failure() {
808 let result = validate_url_with_resolver("http://example.com/mcp", failing_resolver).await;
809 assert!(matches!(result, Err(UrlValidationError::BlockedHost(_))));
810 }
811
812 #[tokio::test]
813 async fn dns_resolver_blocks_empty_response() {
814 let result = validate_url_with_resolver("http://example.com/mcp", empty_resolver).await;
815 assert!(matches!(result, Err(UrlValidationError::BlockedHost(_))));
816 }
817
818 #[tokio::test]
819 async fn dns_resolver_returns_addrs_for_connection_pinning() {
820 let (_url, addrs) =
821 validate_url_with_resolver("https://mcp.example.com/v1/mcp", public_ip_resolver)
822 .await
823 .unwrap();
824 assert!(!addrs.is_empty());
825 }
826}