Skip to main content

rustauth_core/utils/
host.rs

1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2
3/// Network classification used for security-sensitive host checks.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum HostKind {
6    Public,
7    Localhost,
8    Loopback,
9    Private,
10    LinkLocal,
11    CloudMetadata,
12    Documentation,
13    SharedAddressSpace,
14    Benchmarking,
15    Multicast,
16    Broadcast,
17    Unspecified,
18    Reserved,
19}
20
21/// Literal shape of the normalized host.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum HostLiteral {
24    Ipv4,
25    Ipv6,
26    Fqdn,
27}
28
29/// Result of normalizing and classifying a host value.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct HostClassification {
32    pub original: String,
33    pub canonical: String,
34    pub literal: HostLiteral,
35    pub kind: HostKind,
36}
37
38/// Normalize and classify a host or host:port string.
39pub fn classify_host(host: &str) -> HostClassification {
40    let original = host.to_owned();
41    let canonical = normalize_host(host);
42
43    if canonical.is_empty() {
44        return HostClassification {
45            original,
46            canonical,
47            literal: HostLiteral::Fqdn,
48            kind: HostKind::Reserved,
49        };
50    }
51
52    if let Ok(ip) = canonical.parse::<IpAddr>() {
53        return classify_ip(original, ip);
54    }
55
56    let kind = classify_domain(&canonical);
57    HostClassification {
58        original,
59        canonical,
60        literal: HostLiteral::Fqdn,
61        kind,
62    }
63}
64
65/// Returns true only for loopback IP literals.
66pub fn is_loopback_ip(host: &str) -> bool {
67    let classification = classify_host(host);
68    matches!(
69        classification.literal,
70        HostLiteral::Ipv4 | HostLiteral::Ipv6
71    ) && classification.kind == HostKind::Loopback
72}
73
74/// Returns true for localhost names and loopback IP literals.
75pub fn is_loopback_host(host: &str) -> bool {
76    matches!(
77        classify_host(host).kind,
78        HostKind::Localhost | HostKind::Loopback
79    )
80}
81
82/// Returns true only when the host is structurally valid and publicly routable.
83pub fn is_public_routable_host(host: &str) -> bool {
84    classify_host(host).kind == HostKind::Public
85}
86
87fn normalize_host(host: &str) -> String {
88    let mut value = host.trim().to_ascii_lowercase();
89    if value.is_empty() {
90        return value;
91    }
92
93    if let Some(stripped) = value
94        .strip_prefix('[')
95        .and_then(|rest| rest.split_once(']'))
96    {
97        value = stripped.0.to_owned();
98    } else if value.matches(':').count() == 1 {
99        if let Some((without_port, port)) = value.rsplit_once(':') {
100            if !without_port.is_empty() && port.chars().all(|character| character.is_ascii_digit())
101            {
102                value = without_port.to_owned();
103            }
104        }
105    }
106
107    if let Some((without_zone, _)) = value.split_once('%') {
108        value = without_zone.to_owned();
109    }
110
111    value.trim_end_matches('.').to_owned()
112}
113
114fn classify_ip(original: String, ip: IpAddr) -> HostClassification {
115    match ip {
116        IpAddr::V4(ip) => HostClassification {
117            original,
118            canonical: ip.to_string(),
119            literal: HostLiteral::Ipv4,
120            kind: classify_ipv4(ip),
121        },
122        IpAddr::V6(ip) => HostClassification {
123            original,
124            canonical: ip.to_string(),
125            literal: HostLiteral::Ipv6,
126            kind: classify_ipv6(ip),
127        },
128    }
129}
130
131fn classify_ipv4(ip: Ipv4Addr) -> HostKind {
132    let octets = ip.octets();
133
134    if ip.is_loopback() {
135        HostKind::Loopback
136    } else if ip.is_unspecified() {
137        HostKind::Unspecified
138    } else if ip == Ipv4Addr::BROADCAST {
139        HostKind::Broadcast
140    } else if ip.is_private() {
141        HostKind::Private
142    } else if ip.is_link_local() {
143        HostKind::LinkLocal
144    } else if ip.is_documentation() {
145        HostKind::Documentation
146    } else if ip.is_multicast() {
147        HostKind::Multicast
148    } else if octets[0] == 100 && (64..=127).contains(&octets[1]) {
149        HostKind::SharedAddressSpace
150    } else if octets[0] == 198 && matches!(octets[1], 18 | 19) {
151        HostKind::Benchmarking
152    } else if octets[0] == 0 || octets[0] >= 240 {
153        HostKind::Reserved
154    } else {
155        HostKind::Public
156    }
157}
158
159fn classify_ipv6(ip: Ipv6Addr) -> HostKind {
160    let segments = ip.segments();
161    let first_segment = segments[0];
162
163    if ip.is_loopback() {
164        HostKind::Loopback
165    } else if ip.is_unspecified() {
166        HostKind::Unspecified
167    } else if ip.is_multicast() {
168        HostKind::Multicast
169    } else if (first_segment & 0xfe00) == 0xfc00 {
170        HostKind::Private
171    } else if (first_segment & 0xffc0) == 0xfe80 {
172        HostKind::LinkLocal
173    } else if first_segment == 0x2001 && segments[1] == 0x0db8 {
174        HostKind::Documentation
175    } else {
176        HostKind::Public
177    }
178}
179
180fn classify_domain(host: &str) -> HostKind {
181    if host == "localhost" || host.ends_with(".localhost") {
182        HostKind::Localhost
183    } else if matches!(
184        host,
185        "metadata.google.internal"
186            | "metadata.goog"
187            | "instance-data.ec2.internal"
188            | "169.254.169.254"
189    ) {
190        HostKind::CloudMetadata
191    } else {
192        HostKind::Public
193    }
194}