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): `localhost` is classified as
10//! loopback, while any other unresolved name is treated as [`HostClass::Public`]
11//! because a no-DNS check 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    /// Unspecified, documentation, benchmarking, multicast, transition, and
26    /// otherwise reserved/special-purpose address space.
27    Unspecified,
28    /// Routable, or an unresolved DNS name.
29    Public,
30}
31
32impl HostClass {
33    /// True for any non-public host. Used by the web-fetch SSRF blocklist
34    /// (block everything that isn't clearly routable).
35    pub fn is_internal(self) -> bool {
36        !matches!(self, HostClass::Public)
37    }
38
39    /// True only for loopback. Used by the provider `base_url` gate: plaintext
40    /// `http` is acceptable to loopback (no network exposure), but sending an
41    /// API key over `http` to any other host — even a LAN/private one — leaks
42    /// it in cleartext.
43    pub fn is_loopback(self) -> bool {
44        matches!(self, HostClass::Loopback)
45    }
46}
47
48/// Classify a URL host (hostname or IP literal, with optional `[]` around an
49/// IPv6 literal and an optional trailing FQDN dot).
50pub fn classify_host(host: &str) -> HostClass {
51    let h = host
52        .trim_start_matches('[')
53        .trim_end_matches(']')
54        .trim_end_matches('.')
55        .to_ascii_lowercase();
56    if h == "localhost" || h.ends_with(".localhost") {
57        return HostClass::Loopback;
58    }
59    if let Ok(ip) = h.parse::<Ipv4Addr>() {
60        return classify_ipv4(ip);
61    }
62    if let Ok(ip) = h.parse::<Ipv6Addr>() {
63        // IPv4-mapped (`::ffff:a.b.c.d`): classify the embedded address so
64        // `[::ffff:127.0.0.1]` / `[::ffff:169.254.169.254]` aren't treated as
65        // an opaque (and thus "public") IPv6 literal.
66        if let Some(v4) = ip.to_ipv4_mapped() {
67            return classify_ipv4(v4);
68        }
69        if ip.is_loopback() {
70            return HostClass::Loopback;
71        }
72        if ip.is_unspecified() {
73            return HostClass::Unspecified;
74        }
75        if (ip.segments()[0] & 0xfe00) == 0xfc00 {
76            return HostClass::Private; // ULA fc00::/7
77        }
78        if (ip.segments()[0] & 0xffc0) == 0xfe80 {
79            return HostClass::LinkLocal; // fe80::/10
80        }
81        return if is_global_ipv6(ip) {
82            HostClass::Public
83        } else {
84            HostClass::Unspecified
85        };
86    }
87    HostClass::Public
88}
89
90fn classify_ipv4(ip: Ipv4Addr) -> HostClass {
91    if ip.is_loopback() {
92        return HostClass::Loopback;
93    }
94    if ip.is_unspecified() || ip.is_broadcast() {
95        return HostClass::Unspecified;
96    }
97    if ip.is_link_local() {
98        return HostClass::LinkLocal;
99    }
100    if ip.is_private() {
101        return HostClass::Private;
102    }
103    let o = ip.octets();
104    if o[0] == 100 && (64..=127).contains(&o[1]) {
105        return HostClass::Cgnat; // 100.64.0.0/10
106    }
107    if is_global_ipv4(ip) {
108        HostClass::Public
109    } else {
110        HostClass::Unspecified
111    }
112}
113
114fn is_global_ipv4(ip: Ipv4Addr) -> bool {
115    let [a, b, c, d] = ip.octets();
116
117    // RFC 7723 and RFC 8155 anycast services are the two globally reachable
118    // exceptions inside the IETF protocol-assignment block.
119    if [a, b, c, d] == [192, 0, 0, 9] || [a, b, c, d] == [192, 0, 0, 10] {
120        return true;
121    }
122
123    !(a == 0 // "this network" 0.0.0.0/8
124        || a == 10
125        || a == 127
126        || (a == 100 && (64..=127).contains(&b))
127        || (a == 169 && b == 254)
128        || (a == 172 && (16..=31).contains(&b))
129        || (a == 192 && b == 0 && c == 0) // IETF protocol assignments
130        || (a == 192 && b == 0 && c == 2) // TEST-NET-1
131        || (a == 192 && b == 88 && c == 99) // deprecated 6to4 relay anycast
132        || (a == 192 && b == 168)
133        || (a == 198 && (b == 18 || b == 19)) // benchmarking
134        || (a == 198 && b == 51 && c == 100) // TEST-NET-2
135        || (a == 203 && b == 0 && c == 113) // TEST-NET-3
136        || a >= 224) // multicast and reserved 224.0.0.0/4 + 240.0.0.0/4
137}
138
139fn is_global_ipv6(ip: Ipv6Addr) -> bool {
140    let value = u128::from(ip);
141    let globally_reachable_protocol_assignment = ip == Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 1)
142        || ip == Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 2)
143        || ip == Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 3)
144        || in_ipv6_prefix(
145            value,
146            u128::from(Ipv6Addr::new(0x2001, 3, 0, 0, 0, 0, 0, 0)),
147            32,
148        )
149        || in_ipv6_prefix(
150            value,
151            u128::from(Ipv6Addr::new(0x2001, 4, 0x0112, 0, 0, 0, 0, 0)),
152            48,
153        )
154        || in_ipv6_prefix(
155            value,
156            u128::from(Ipv6Addr::new(0x2001, 0x20, 0, 0, 0, 0, 0, 0)),
157            28,
158        )
159        || in_ipv6_prefix(
160            value,
161            u128::from(Ipv6Addr::new(0x2001, 0x30, 0, 0, 0, 0, 0, 0)),
162            28,
163        );
164
165    // Public IPv6 unicast allocations currently live in 2000::/3. Reject
166    // transition/local-use prefixes outside it (for example NAT64), as well as
167    // special-purpose sub-ranges inside it. The 2001::/23 protocol block is
168    // denied except for the assignments IANA explicitly marks globally
169    // reachable; its other tunnelling and benchmarking mechanisms can have an
170    // effective endpoint different from the literal address being authorized.
171    globally_reachable_protocol_assignment
172        || (in_ipv6_prefix(
173            value,
174            u128::from(Ipv6Addr::new(0x2000, 0, 0, 0, 0, 0, 0, 0)),
175            3,
176        ) && !in_ipv6_prefix(
177            value,
178            u128::from(Ipv6Addr::new(0x2001, 0, 0, 0, 0, 0, 0, 0)),
179            23,
180        ) && !in_ipv6_prefix(
181            value,
182            u128::from(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0)),
183            32,
184        ) && !in_ipv6_prefix(
185            value,
186            u128::from(Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0)),
187            16,
188        ) && !in_ipv6_prefix(
189            value,
190            u128::from(Ipv6Addr::new(0x3fff, 0, 0, 0, 0, 0, 0, 0)),
191            20,
192        ))
193}
194
195fn in_ipv6_prefix(value: u128, network: u128, prefix_len: u32) -> bool {
196    let mask = u128::MAX << (128 - prefix_len);
197    value & mask == network & mask
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn loopback_forms() {
206        for h in [
207            "localhost",
208            "localhost.",
209            "127.0.0.1",
210            "127.1.2.3",
211            "[::1]",
212            "[::ffff:127.0.0.1]",
213            "app.localhost",
214        ] {
215            assert_eq!(classify_host(h), HostClass::Loopback, "{h}");
216            assert!(classify_host(h).is_internal());
217            assert!(classify_host(h).is_loopback());
218        }
219    }
220
221    #[test]
222    fn internal_but_not_loopback() {
223        // These must be blocked by the SSRF list but NOT exempted from https.
224        for h in [
225            "10.0.0.5",
226            "192.168.1.1",
227            "172.16.0.1",
228            "169.254.169.254",
229            "[::ffff:169.254.169.254]", // IPv4-mapped link-local (old IPv6 hole)
230            "[fc00::1]",                // ULA (old IPv6 hole)
231            "[fe80::1]",                // link-local IPv6 (old IPv6 hole)
232            "100.100.100.200",          // CGNAT / Alibaba metadata (old IPv4 hole)
233            "0.0.0.0",
234            "0.1.2.3",           // this-network block
235            "192.0.0.1",         // IETF protocol assignments
236            "192.0.2.1",         // documentation
237            "198.18.0.1",        // benchmarking
238            "198.51.100.1",      // documentation
239            "203.0.113.1",       // documentation
240            "224.0.0.1",         // multicast
241            "240.0.0.1",         // reserved
242            "[64:ff9b::7f00:1]", // NAT64 transition prefix
243            "[2001:db8::1]",     // documentation
244            "[2002:7f00:1::]",   // 6to4 transition address
245            "[3fff::1]",         // documentation
246            "[ff02::1]",         // multicast
247        ] {
248            assert!(classify_host(h).is_internal(), "{h} should be internal");
249            assert!(!classify_host(h).is_loopback(), "{h} must not be loopback");
250        }
251    }
252
253    #[test]
254    fn public_hosts() {
255        for h in [
256            "example.com",
257            "8.8.8.8",
258            "1.1.1.1",
259            "192.0.0.9",
260            "192.0.0.10",
261            "192.31.196.1",
262            "192.52.193.1",
263            "192.175.48.1",
264            "[2606:4700:4700::1111]",
265            "[2001:4860:4860::8888]",
266            "[2001:1::1]",
267            "[2001:1::2]",
268            "[2001:1::3]",
269            "[2001:3::1]",
270            "[2001:4:112::1]",
271            "[2001:20::1]",
272            "[2001:30::1]",
273            "api.openai.com",
274        ] {
275            assert_eq!(classify_host(h), HostClass::Public, "{h}");
276            assert!(!classify_host(h).is_internal(), "{h}");
277        }
278    }
279
280    #[test]
281    fn special_purpose_literals_are_never_public() {
282        for host in [
283            "0.1.2.3",
284            "10.0.0.1",
285            "198.18.1.1",
286            "224.0.0.1",
287            "64:ff9b::7f00:1",
288            "2001:db8::1",
289            "2002:7f00:1::",
290        ] {
291            assert!(classify_host(host).is_internal(), "{host}");
292        }
293        for host in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
294            assert_eq!(classify_host(host), HostClass::Public, "{host}");
295        }
296    }
297
298    #[test]
299    fn generated_ipv4_special_purpose_ranges_are_never_public() {
300        // Exercise interior points, not only the familiar first address from
301        // each IANA special-purpose block. The deterministic generator keeps
302        // the test cheap while covering host bits throughout large prefixes.
303        let ranges = [
304            (Ipv4Addr::new(0, 0, 0, 0), 8),
305            (Ipv4Addr::new(10, 0, 0, 0), 8),
306            (Ipv4Addr::new(100, 64, 0, 0), 10),
307            (Ipv4Addr::new(127, 0, 0, 0), 8),
308            (Ipv4Addr::new(169, 254, 0, 0), 16),
309            (Ipv4Addr::new(172, 16, 0, 0), 12),
310            (Ipv4Addr::new(192, 0, 0, 0), 24),
311            (Ipv4Addr::new(192, 0, 2, 0), 24),
312            (Ipv4Addr::new(192, 88, 99, 0), 24),
313            (Ipv4Addr::new(192, 168, 0, 0), 16),
314            (Ipv4Addr::new(198, 18, 0, 0), 15),
315            (Ipv4Addr::new(198, 51, 100, 0), 24),
316            (Ipv4Addr::new(203, 0, 113, 0), 24),
317            (Ipv4Addr::new(224, 0, 0, 0), 4),
318            (Ipv4Addr::new(240, 0, 0, 0), 4),
319        ];
320        let globally_reachable_exceptions = [
321            u32::from(Ipv4Addr::new(192, 0, 0, 9)),
322            u32::from(Ipv4Addr::new(192, 0, 0, 10)),
323        ];
324
325        let mut state = 0x9e37_79b9_u32;
326        for (network, prefix_len) in ranges {
327            let mask = u32::MAX << (32 - prefix_len);
328            let network = u32::from(network) & mask;
329            for _ in 0..2048 {
330                state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
331                let candidate = network | (state & !mask);
332                if globally_reachable_exceptions.contains(&candidate) {
333                    continue;
334                }
335                let host = Ipv4Addr::from(candidate).to_string();
336                assert!(
337                    classify_host(&host).is_internal(),
338                    "special-purpose IPv4 escaped policy: {host}/{prefix_len}"
339                );
340            }
341        }
342    }
343
344    #[test]
345    fn generated_ipv6_special_purpose_ranges_are_never_public() {
346        // These are the non-global or endpoint-transforming IPv6 allocations
347        // relevant to outbound URL authorization. NAT64 is deliberately
348        // denied even where the registry calls it globally reachable: the
349        // embedded IPv4 endpoint can otherwise bypass the IPv4 policy.
350        let ranges = [
351            (Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0, 0), 96),
352            (Ipv6Addr::new(0x0064, 0xff9b, 1, 0, 0, 0, 0, 0), 48),
353            (Ipv6Addr::new(0x0100, 0, 0, 0, 0, 0, 0, 0), 64),
354            (Ipv6Addr::new(0x0100, 0, 0, 1, 0, 0, 0, 0), 64),
355            (Ipv6Addr::new(0x2001, 0, 0, 0, 0, 0, 0, 0), 32),
356            (Ipv6Addr::new(0x2001, 2, 0, 0, 0, 0, 0, 0), 48),
357            (Ipv6Addr::new(0x2001, 0x10, 0, 0, 0, 0, 0, 0), 28),
358            (Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0), 32),
359            (Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), 16),
360            (Ipv6Addr::new(0x3fff, 0, 0, 0, 0, 0, 0, 0), 20),
361            (Ipv6Addr::new(0x5f00, 0, 0, 0, 0, 0, 0, 0), 16),
362            (Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7),
363            (Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10),
364            (Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8),
365        ];
366
367        let mut state = 0x6a09_e667_f3bc_c909_bb67_ae85_84ca_a73b_u128;
368        for (network, prefix_len) in ranges {
369            let mask = u128::MAX << (128 - prefix_len);
370            let network = u128::from(network) & mask;
371            for _ in 0..2048 {
372                state = state
373                    .wrapping_mul(0x2360_ed05_1fc6_5da4_4385_df64_9fcc_f645)
374                    .wrapping_add(0x9e37_79b9_7f4a_7c15_6a09_e667_f3bc_c909);
375                let host = Ipv6Addr::from(network | (state & !mask)).to_string();
376                assert!(
377                    classify_host(&host).is_internal(),
378                    "special-purpose IPv6 escaped policy: {host}/{prefix_len}"
379                );
380            }
381        }
382    }
383}