Skip to main content

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