Skip to main content

mermaid_cli/utils/
net.rs

1//! Canonical host classification, shared by the web-fetch SSRF blocklist and
2//! the provider `base_url` plaintext-http gate.
3//!
4//! Both used to hand-roll their own IPv4-centric checks that disagreed on IPv6
5//! (one missed IPv4-mapped / ULA / link-local / CGNAT, the other was too strict
6//! and refused legitimate ULA local servers). This is the one place host
7//! routing class is decided.
8//!
9//! Classification is purely lexical (no DNS): an unresolved name that isn't
10//! `localhost` is treated as [`HostClass::Public`], because a no-DNS check
11//! can't see where a name resolves.
12
13use std::net::{Ipv4Addr, Ipv6Addr};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum HostClass {
17    /// `127.0.0.0/8`, `::1`, `localhost` / `*.localhost`.
18    Loopback,
19    /// `169.254.0.0/16` (incl. cloud metadata `169.254.169.254`), `fe80::/10`.
20    LinkLocal,
21    /// RFC-1918 (`10/8`, `172.16/12`, `192.168/16`) and IPv6 ULA `fc00::/7`.
22    Private,
23    /// Carrier-grade NAT `100.64.0.0/10` (also some cloud metadata fronts).
24    Cgnat,
25    /// `0.0.0.0`, `::`, IPv4 broadcast.
26    Unspecified,
27    /// Routable, or an unresolved DNS name.
28    Public,
29}
30
31impl HostClass {
32    /// True for any non-public host. Used by the web-fetch SSRF blocklist
33    /// (block everything that isn't clearly routable).
34    pub fn is_internal(self) -> bool {
35        !matches!(self, HostClass::Public)
36    }
37
38    /// True only for loopback. Used by the provider `base_url` gate: plaintext
39    /// `http` is acceptable to loopback (no network exposure), but sending an
40    /// API key over `http` to any other host — even a LAN/private one — leaks
41    /// it in cleartext.
42    pub fn is_loopback(self) -> bool {
43        matches!(self, HostClass::Loopback)
44    }
45}
46
47/// Classify a URL host (hostname or IP literal, with optional `[]` around an
48/// IPv6 literal and an optional trailing FQDN dot).
49pub fn classify_host(host: &str) -> HostClass {
50    let h = host
51        .trim_start_matches('[')
52        .trim_end_matches(']')
53        .trim_end_matches('.')
54        .to_ascii_lowercase();
55    if h == "localhost" || h.ends_with(".localhost") {
56        return HostClass::Loopback;
57    }
58    if let Ok(ip) = h.parse::<Ipv4Addr>() {
59        return classify_ipv4(ip);
60    }
61    if let Ok(ip) = h.parse::<Ipv6Addr>() {
62        // IPv4-mapped (`::ffff:a.b.c.d`): classify the embedded address so
63        // `[::ffff:127.0.0.1]` / `[::ffff:169.254.169.254]` aren't treated as
64        // an opaque (and thus "public") IPv6 literal.
65        if let Some(v4) = ip.to_ipv4_mapped() {
66            return classify_ipv4(v4);
67        }
68        if ip.is_loopback() {
69            return HostClass::Loopback;
70        }
71        if ip.is_unspecified() {
72            return HostClass::Unspecified;
73        }
74        if (ip.segments()[0] & 0xfe00) == 0xfc00 {
75            return HostClass::Private; // ULA fc00::/7
76        }
77        if (ip.segments()[0] & 0xffc0) == 0xfe80 {
78            return HostClass::LinkLocal; // fe80::/10
79        }
80        return HostClass::Public;
81    }
82    HostClass::Public
83}
84
85fn classify_ipv4(ip: Ipv4Addr) -> HostClass {
86    if ip.is_loopback() {
87        return HostClass::Loopback;
88    }
89    if ip.is_unspecified() || ip.is_broadcast() {
90        return HostClass::Unspecified;
91    }
92    if ip.is_link_local() {
93        return HostClass::LinkLocal;
94    }
95    if ip.is_private() {
96        return HostClass::Private;
97    }
98    let o = ip.octets();
99    if o[0] == 100 && (64..=127).contains(&o[1]) {
100        return HostClass::Cgnat; // 100.64.0.0/10
101    }
102    HostClass::Public
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn loopback_forms() {
111        for h in [
112            "localhost",
113            "localhost.",
114            "127.0.0.1",
115            "127.1.2.3",
116            "[::1]",
117            "[::ffff:127.0.0.1]",
118            "app.localhost",
119        ] {
120            assert_eq!(classify_host(h), HostClass::Loopback, "{h}");
121            assert!(classify_host(h).is_internal());
122            assert!(classify_host(h).is_loopback());
123        }
124    }
125
126    #[test]
127    fn internal_but_not_loopback() {
128        // These must be blocked by the SSRF list but NOT exempted from https.
129        for h in [
130            "10.0.0.5",
131            "192.168.1.1",
132            "172.16.0.1",
133            "169.254.169.254",
134            "[::ffff:169.254.169.254]", // IPv4-mapped link-local (old IPv6 hole)
135            "[fc00::1]",                // ULA (old IPv6 hole)
136            "[fe80::1]",                // link-local IPv6 (old IPv6 hole)
137            "100.100.100.200",          // CGNAT / Alibaba metadata (old IPv4 hole)
138            "0.0.0.0",
139        ] {
140            assert!(classify_host(h).is_internal(), "{h} should be internal");
141            assert!(!classify_host(h).is_loopback(), "{h} must not be loopback");
142        }
143    }
144
145    #[test]
146    fn public_hosts() {
147        for h in [
148            "example.com",
149            "8.8.8.8",
150            "1.1.1.1",
151            "[2606:4700:4700::1111]",
152            "api.openai.com",
153        ] {
154            assert_eq!(classify_host(h), HostClass::Public, "{h}");
155            assert!(!classify_host(h).is_internal(), "{h}");
156        }
157    }
158}