Skip to main content

gglib_core/access/
host.rs

1use std::net::IpAddr;
2
3/// Whether `host` refers to the loopback interface.
4///
5/// Covers the literal `localhost` alongside any address that parses as an IP
6/// and is loopback, so `::1` and `127.0.0.2` are caught as well as `127.0.0.1`.
7///
8/// Accepts a bare host, with or without a port and with or without IPv6
9/// brackets — callers reach this both with a configured bind host and with a
10/// raw `Host` header, and the two are spelled differently.
11#[must_use]
12pub fn is_loopback_host(host: &str) -> bool {
13    let Some(host) = normalize_host(host) else {
14        return false;
15    };
16    host == "localhost" || host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
17}
18
19/// Whether `host` is a wildcard ("all interfaces") address.
20///
21/// True for both `0.0.0.0` and its IPv6 equivalent `::`, so the two are treated
22/// alike when deciding how to describe the bind and how to advertise it.
23#[must_use]
24pub fn is_wildcard_host(host: &str) -> bool {
25    let Some(host) = normalize_host(host) else {
26        return false;
27    };
28    host.parse::<IpAddr>().is_ok_and(|ip| ip.is_unspecified())
29}
30
31/// Reduce a host or authority to a bare, comparable host name.
32///
33/// Lowercases, strips a trailing `:port`, and unwraps IPv6 brackets, so the
34/// three spellings a single address arrives in — `[::1]:8080` from a browser,
35/// `::1` from a config file, `[::1]` from a URL — compare equal.
36///
37/// Returns `None` for anything that cannot be a host: an empty string, or a
38/// value carrying userinfo or a path. Those are rejected rather than
39/// sanitized, because a `Host` header containing them is malformed and the
40/// only safe reading of a malformed claim is no claim at all.
41#[must_use]
42pub fn normalize_host(host: &str) -> Option<String> {
43    let host = host.trim();
44    if host.is_empty() || host.contains('@') || host.contains('/') {
45        return None;
46    }
47
48    // Bracketed IPv6, with or without a port: `[::1]` / `[::1]:8080`.
49    let bare = if let Some(rest) = host.strip_prefix('[') {
50        let (inner, tail) = rest.split_once(']')?;
51        if !tail.is_empty() && !tail.starts_with(':') {
52            return None;
53        }
54        inner
55    } else {
56        // A single colon is a port separator. Several mean an unbracketed IPv6
57        // literal, which is malformed in a `Host` header but unambiguous as a
58        // configured value, so it is taken whole rather than truncated at the
59        // first colon.
60        match host.split_once(':') {
61            Some((h, port)) if !port.contains(':') => h,
62            _ => host,
63        }
64    };
65
66    if bare.is_empty() {
67        return None;
68    }
69    Some(bare.to_ascii_lowercase())
70}