Skip to main content

everruns_provider/
url_validation.rs

1// Shared URL validation for SSRF prevention
2//
3// THREAT[TM-API-008]: Blocks requests to internal/private networks.
4// Used by MCP server registration (create/update) and execution-time checks.
5//
6// Validates:
7// - Scheme restricted to https/http
8// - Hostname not localhost, loopback, or private IP
9// - Blocks link-local (169.254.x.x), cloud metadata (169.254.169.254)
10// - Blocks RFC1918 (10.x, 172.16-31.x, 192.168.x)
11// - Blocks IPv6 loopback (::1) and link-local (fe80::)
12
13use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
14use std::time::Duration;
15use url::Url;
16
17/// DNS resolution is capped at this duration to prevent a stuck resolver from
18/// stalling MCP tool execution/discovery past the outbound HTTP timeout.
19const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
20
21/// Errors from URL safety validation.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum UrlValidationError {
24    /// URL could not be parsed.
25    InvalidUrl(String),
26    /// Scheme not allowed (must be http or https).
27    DisallowedScheme(String),
28    /// Hostname is missing.
29    MissingHostname,
30    /// Hostname resolves to or is a blocked target.
31    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
51/// Validate a URL is safe for server-side requests (anti-SSRF).
52///
53/// Checks scheme, hostname patterns, and IP address ranges.
54/// Does NOT perform DNS resolution — this is a static check suitable for
55/// write-time validation. Complement with runtime DNS-pinned checks where possible.
56pub 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    // Scheme check
60    match url.scheme() {
61        "http" | "https" => {}
62        other => return Err(UrlValidationError::DisallowedScheme(other.to_string())),
63    }
64
65    // Must have a host
66    let host = url.host_str().ok_or(UrlValidationError::MissingHostname)?;
67
68    // Check hostname patterns
69    if is_blocked_host(host) {
70        return Err(UrlValidationError::BlockedHost(host.to_string()));
71    }
72
73    Ok(url)
74}
75
76/// Validate a URL is safe for server-side requests, including DNS resolution
77/// to catch DNS-rebinding attacks (TM-TOOL-018).
78///
79/// Performs static checks first (`validate_safe_url`), then resolves the
80/// hostname (with a 5-second timeout) and verifies that every returned IP is
81/// in an allowed range.  Returns the parsed `Url` and the **resolved socket
82/// addresses** so callers can pin the subsequent connection to those exact IPs
83/// via `reqwest::ClientBuilder::resolve_to_addrs`, closing the TOCTOU window.
84///
85/// For IP-literal URLs the returned `Vec<SocketAddr>` is empty — the static
86/// check already validated the IP.  For write-time registration checks,
87/// `validate_safe_url` is sufficient; use this function at execution time
88/// (i.e. just before each outbound HTTP call).
89pub 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
95/// Inner implementation with an injectable resolver for unit testing.
96async 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    // Static checks first — fast path for obviously bad URLs / IP literals.
105    let url = validate_safe_url(raw_url)?;
106    let host = url
107        .host_str()
108        .ok_or(UrlValidationError::MissingHostname)?
109        .to_string();
110
111    // IP literals were already validated by the static check; no DNS needed.
112    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    // Resolve the hostname and reject if any address falls in a blocked range.
121    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
147/// Default resolver used in production: `tokio::net::lookup_host` with a
148/// 5-second timeout so a stuck DNS server cannot stall MCP execution.
149async 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
159/// Check if a hostname string is blocked (localhost, private IPs, metadata endpoints).
160fn is_blocked_host(host: &str) -> bool {
161    let host_lower = host.to_lowercase();
162
163    // Localhost variants
164    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    // Strip brackets from IPv6
173    let bare = host_lower
174        .strip_prefix('[')
175        .and_then(|s| s.strip_suffix(']'))
176        .unwrap_or(&host_lower);
177
178    // Try parsing as IP
179    if let Ok(ip) = bare.parse::<IpAddr>() {
180        return is_blocked_ip(ip);
181    }
182
183    // Cloud metadata hostname
184    if host_lower == "metadata.google.internal" || host_lower == "metadata.google.internal." {
185        return true;
186    }
187
188    false
189}
190
191/// Check if an IP address is in a blocked range (loopback, private, link-local,
192/// CGNAT, documentation, IPv4-mapped IPv6 variants of any of those).
193///
194/// Use this for runtime DNS-pinned checks where the caller has already resolved
195/// a hostname to its actual address. Static URL/hostname checks should use
196/// [`validate_safe_url`] instead.
197///
198/// Self-hosted deployments can exempt specific ranges via the
199/// [`SSRF_ALLOW_CIDRS_ENV`] environment variable; see [`operator_allowed_cidrs`].
200pub fn is_blocked_ip(ip: IpAddr) -> bool {
201    is_blocked_ip_with_allowlist(ip, operator_allowed_cidrs())
202}
203
204/// Operator allowlist env var: comma-separated CIDRs (e.g.
205/// `10.42.0.0/16,10.43.0.0/16`) exempted from the private-range block.
206pub const SSRF_ALLOW_CIDRS_ENV: &str = "EVERRUNS_SSRF_ALLOW_CIDRS";
207
208/// CIDR ranges the operator explicitly exempted from SSRF blocking, parsed
209/// once from [`SSRF_ALLOW_CIDRS_ENV`]. Empty (block everything private) unless
210/// the deployment sets the variable.
211///
212/// This is a single-tenant/self-hosted escape hatch: it lets in-cluster
213/// service URLs (e.g. `http://tools.ns.svc.cluster.local:8100/mcp`) pass
214/// validation without exposing the service publicly. It applies to every
215/// consumer of this module — MCP servers, web fetch egress, plugin and OAuth
216/// fetches — so keep the ranges as narrow as possible. Hostname-pattern blocks
217/// (`localhost`, `metadata.google.internal`) are unaffected.
218pub 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
233/// Parse a comma-separated CIDR list, skipping (and logging) invalid entries.
234fn 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/// An IP network in CIDR notation (`10.42.0.0/16`, `fd00::/8`).
249#[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    /// Whether `ip` falls inside this network. IPv4-mapped IPv6 addresses are
281    /// compared as their embedded IPv4 address against IPv4 networks.
282    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                // checked_shl(32) (prefix 0) yields None -> mask 0 -> match-all.
290                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
306/// [`is_blocked_ip`] with an explicit allowlist (unit-testable without env).
307fn 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    // Loopback 127.0.0.0/8
319    if octets[0] == 127 {
320        return true;
321    }
322
323    // 0.0.0.0
324    if ip.is_unspecified() {
325        return true;
326    }
327
328    // RFC1918: 10.0.0.0/8
329    if octets[0] == 10 {
330        return true;
331    }
332
333    // RFC1918: 172.16.0.0/12
334    if octets[0] == 172 && (16..=31).contains(&octets[1]) {
335        return true;
336    }
337
338    // RFC1918: 192.168.0.0/16
339    if octets[0] == 192 && octets[1] == 168 {
340        return true;
341    }
342
343    // Link-local 169.254.0.0/16 (includes cloud metadata 169.254.169.254)
344    if octets[0] == 169 && octets[1] == 254 {
345        return true;
346    }
347
348    // RFC6598 Carrier-grade NAT: 100.64.0.0/10
349    if octets[0] == 100 && (64..=127).contains(&octets[1]) {
350        return true;
351    }
352
353    // RFC5737 Documentation: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24
354    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    // Loopback ::1
366    if ip.is_loopback() {
367        return true;
368    }
369
370    // Unspecified ::
371    if ip.is_unspecified() {
372        return true;
373    }
374
375    // Link-local fe80::/10
376    let segments = ip.segments();
377    if segments[0] & 0xffc0 == 0xfe80 {
378        return true;
379    }
380
381    // Unique local fc00::/7
382    if segments[0] & 0xfe00 == 0xfc00 {
383        return true;
384    }
385
386    // IPv4-mapped IPv6 (::ffff:x.x.x.x) — check the embedded v4
387    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    // --- Positive: valid public URLs ---
399
400    #[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    // --- Negative: blocked schemes ---
421
422    #[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        // url crate may parse this as opaque or as scheme error
438        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    // --- Negative: invalid URLs ---
454
455    #[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    // --- Negative: localhost ---
466
467    #[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    // --- Negative: loopback IPs ---
486
487    #[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    // --- Negative: private RFC1918 ---
506
507    #[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        // 172.32.x.x is NOT private
528        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    // --- Negative: link-local / cloud metadata ---
538
539    #[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    // --- Negative: 0.0.0.0 ---
559
560    #[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    // --- Negative: IPv6 special ---
567
568    #[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    // --- Negative: carrier-grade NAT ---
600
601    #[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    // --- Display ---
608
609    #[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    // --- Operator CIDR allowlist (EVERRUNS_SSRF_ALLOW_CIDRS) ---
624
625    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        // Other private ranges stay blocked.
637        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        // /0 v4 must not match v6 addresses.
693        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        // Hostname-pattern blocks are independent of the IP allowlist.
702        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    // --- validate_url_dns_pinned: static pre-check path (IP literals) ---
715
716    #[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        // "localhost" is caught by the static pre-check; resolver is never called.
737        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    // --- validate_url_with_resolver: DNS resolution path (injectable resolver) ---
751
752    // Simulates DNS rebinding: public URL resolves to a private IP.
753    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    // Simulates a well-behaved response: public URL resolves to a public IP.
761    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    // Simulates a DNS failure / timeout.
769    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    // Simulates empty DNS response (NXDOMAIN / no records).
780    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        // Simulates the DNS rebinding attack: public URL resolves to private IP.
787        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        // Caller pins the connection to these addrs via resolve_to_addrs.
803        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}