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}