Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity-sso/src/guard.rs

//! **Where an identity provider may be** (moved from Gunnar's `sso/mod.rs`,
//! A34 M-4): an account admin types the issuer and metadata URLs, so the box
//! must never be made to probe its own network. `https` to a named host or a
//! public address; never a loopback, private, link-local or unspecified one —
//! unless the operator listed the private network ([`PrivateNets`]) or turned
//! on the loopback test door. An [`crate::egress::Egress`] applies the same
//! rule again to every address a name resolves to.

/// **The operator's internal networks an IdP may live on** (owner 2026-10-06:
/// a private gunnar whose company IdP is on the company's own network).
/// Set per ESTATE by its operator (the installer's feed,
/// `sso_private_idp_cidrs`), never by an account admin; empty — today's guard
/// — unless set. Only private ranges may be named (RFC 1918, carrier-grade
/// NAT, unique-local IPv6): loopback, link-local (the cloud's metadata
/// service), multicast, documentation and public ranges are refused when the
/// list is read, so no list can open them. An address inside a listed network
/// passes the egress guard; every other address is judged as before, and the
/// pinned resolver still connects to exactly what was checked.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PrivateNets(Vec<(std::net::IpAddr, u8)>);

impl PrivateNets {
    /// Read a list (`10.0.2.100/32, fd00::/8`; commas or white space).
    pub fn parse(text: &str) -> Result<PrivateNets, String> {
        use std::net::IpAddr;
        let mut nets = Vec::new();
        for word in text
            .split(|c: char| c == ',' || c.is_whitespace())
            .filter(|w| !w.is_empty())
        {
            let (addr, len) = word.split_once('/').ok_or_else(|| {
                format!("{word:?} is not a network: name it as address/length (10.0.2.100/32)")
            })?;
            let ip: IpAddr = addr
                .parse()
                .map_err(|_| format!("{word:?}: {addr:?} is not an address"))?;
            let len: u8 = len
                .parse()
                .map_err(|_| format!("{word:?}: {len:?} is not a prefix length"))?;
            let private = match ip {
                IpAddr::V4(v4) => {
                    len <= 32
                        && PRIVATE_V4
                            .iter()
                            .any(|(net, plen)| len >= *plen && v4_in(v4, *net, *plen))
                }
                IpAddr::V6(v6) => {
                    // An IPv4 network is named as itself, not smuggled in IPv6.
                    v6.to_ipv4_mapped().is_none()
                        && (7..=128).contains(&len)
                        && v6.segments()[0] & 0xfe00 == 0xfc00
                }
            };
            if !private {
                return Err(format!(
                    "{word:?} is not inside a private network: an operator may list RFC 1918 (10/8, 172.16/12, \
                     192.168/16), carrier-grade NAT (100.64/10) or unique-local IPv6 (fc00::/7) ranges only — never \
                     loopback, link-local (the cloud's metadata service), documentation or public ones"
                ));
            }
            nets.push((ip, len));
        }
        Ok(PrivateNets(nets))
    }

    /// No network is listed.
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// Is `ip` inside a listed network? (An IPv4 address carried in IPv6 is
    /// judged as the IPv4 address.)
    pub fn contains(&self, ip: std::net::IpAddr) -> bool {
        use std::net::IpAddr;
        let ip = match ip {
            IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
            v4 => v4,
        };
        self.0.iter().any(|(net, len)| match (ip, net) {
            (IpAddr::V4(a), IpAddr::V4(n)) => v4_in(a, *n, *len),
            (IpAddr::V6(a), IpAddr::V6(n)) => {
                let mask = if *len == 0 {
                    0
                } else {
                    u128::MAX << (128 - u32::from(*len))
                };
                u128::from(a) & mask == u128::from(*n) & mask
            }
            _ => false,
        })
    }
}

/// The IPv4 ranges an operator may list: RFC 1918 and carrier-grade NAT.
const PRIVATE_V4: [(std::net::Ipv4Addr, u8); 4] = [
    (std::net::Ipv4Addr::new(10, 0, 0, 0), 8),
    (std::net::Ipv4Addr::new(172, 16, 0, 0), 12),
    (std::net::Ipv4Addr::new(192, 168, 0, 0), 16),
    (std::net::Ipv4Addr::new(100, 64, 0, 0), 10),
];

fn v4_in(ip: std::net::Ipv4Addr, net: std::net::Ipv4Addr, len: u8) -> bool {
    let mask = if len == 0 {
        0
    } else {
        u32::MAX << (32 - u32::from(len))
    };
    u32::from(ip) & mask == u32::from(net) & mask
}

impl std::fmt::Display for PrivateNets {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let words: Vec<String> = self
            .0
            .iter()
            .map(|(ip, len)| format!("{ip}/{len}"))
            .collect();
        f.write_str(&words.join(","))
    }
}

/// **May gunnar call this URL?** `https` to a named host or a public
/// address; never a loopback, private, link-local or unspecified literal —
/// an account admin types these URLs, and the box must not be made to probe
/// its own network. `allow_loopback_http` (the operator's test door) admits
/// `http://127.0.0.1|localhost|[::1]` and nothing else.
pub fn url_allowed(url: &str, allow_loopback_http: bool) -> Result<(), String> {
    url_allowed_in(url, allow_loopback_http, &PrivateNets::default())
}

/// `http://` to the loopback host only (`127.0.0.1`, `localhost`, `[::1]`, any
/// port): the operator's test door. The rule `nornir_auth_oidc::rp::endpoint_ok`
/// applies, here so it holds without the `sso` feature too.
fn loopback_http(url: &str) -> bool {
    let lower = url.trim().to_ascii_lowercase();
    let Some(rest) = lower.strip_prefix("http://") else {
        return false;
    };
    let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
    let host = match host.rsplit_once(':') {
        Some((h, port)) if port.bytes().all(|b| b.is_ascii_digit()) => h,
        _ => host,
    };
    matches!(host, "127.0.0.1" | "localhost" | "[::1]")
}

/// [`url_allowed`] on an estate whose operator listed internal networks an
/// IdP may live on ([`PrivateNets`]): an https literal inside one passes.
pub fn url_allowed_in(
    url: &str,
    allow_loopback_http: bool,
    nets: &PrivateNets,
) -> Result<(), String> {
    if allow_loopback_http && loopback_http(url) && url.to_ascii_lowercase().starts_with("http://")
    {
        return Ok(());
    }
    let lower = url.trim().to_ascii_lowercase();
    let Some(rest) = lower.strip_prefix("https://") else {
        return Err(format!("{url:?} is not an https URL"));
    };
    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
    if authority.contains('@') {
        return Err(format!("{url:?} carries credentials"));
    }
    let host = if let Some(v6) = authority.strip_prefix('[') {
        v6.split(']').next().unwrap_or_default().to_owned()
    } else {
        authority
            .rsplit_once(':')
            .map_or(authority, |(h, _)| h)
            .to_owned()
    };
    if host.is_empty() {
        return Err(format!("{url:?} names no host"));
    }
    if host == "localhost"
        || host.ends_with(".localhost")
        || host.ends_with(".internal")
        || host.ends_with(".local")
    {
        return Err(format!("{url:?} is a local name"));
    }
    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
        if let Some(why) = address_forbidden_in(ip, false, nets) {
            return Err(format!("{url:?} is a {why} address"));
        }
    }
    Ok(())
}

/// **Why an address is no IdP's**, or `None` when it may be dialled: a
/// loopback, private, link-local (the cloud metadata service's
/// 169.254.169.254 among them), carrier-grade NAT, unspecified, broadcast,
/// multicast, documentation or reserved address — and the same inside an
/// IPv6 address that carries an IPv4 one (`::ffff:a.b.c.d`, NAT64
/// `64:ff9b::/96`). `allow_loopback` (the operator's test door) admits
/// loopback alone.
pub fn address_forbidden(ip: std::net::IpAddr, allow_loopback: bool) -> Option<&'static str> {
    use std::net::{IpAddr, Ipv4Addr};
    let v6 = match ip {
        IpAddr::V4(v4) => return v4_forbidden(v4, allow_loopback),
        IpAddr::V6(v6) => v6,
    };
    if v6.is_loopback() {
        return (!allow_loopback).then_some("loopback");
    }
    if v6.is_unspecified() {
        return Some("unspecified");
    }
    // An IPv4 address carried inside IPv6 is judged as the IPv4 address it
    // is: mapped (`::ffff:a.b.c.d`), compatible (`::a.b.c.d`), NAT64
    // (`64:ff9b::/96`).
    if let Some(v4) = v6.to_ipv4_mapped() {
        return v4_forbidden(v4, allow_loopback);
    }
    let s = v6.segments();
    let embedded = Ipv4Addr::from((u32::from(s[6]) << 16) | u32::from(s[7]));
    if s[..6] == [0; 6] || (s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0; 4]) {
        return v4_forbidden(embedded, allow_loopback);
    }
    match s[0] {
        x if x & 0xfe00 == 0xfc00 => Some("unique-local (private)"),
        x if x & 0xffc0 == 0xfe80 => Some("link-local"),
        x if x & 0xffc0 == 0xfec0 => Some("site-local"),
        x if x & 0xff00 == 0xff00 => Some("multicast"),
        0x2001 if s[1] == 0x0db8 => Some("documentation"),
        _ => None,
    }
}

fn v4_forbidden(v4: std::net::Ipv4Addr, allow_loopback: bool) -> Option<&'static str> {
    let o = v4.octets();
    if v4.is_loopback() {
        return (!allow_loopback).then_some("loopback");
    }
    if v4.is_private() {
        return Some("private");
    }
    if v4.is_link_local() {
        return Some("link-local — the cloud's metadata service lives here");
    }
    if v4.is_unspecified() || o[0] == 0 {
        return Some("unspecified");
    }
    if v4.is_broadcast() {
        return Some("broadcast");
    }
    if v4.is_multicast() {
        return Some("multicast");
    }
    if o[0] == 100 && (64..=127).contains(&o[1]) {
        return Some("carrier-grade NAT (shared, private)");
    }
    if v4.is_documentation() {
        return Some("documentation");
    }
    if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
        return Some("benchmarking");
    }
    if o[0] == 192 && o[1] == 0 && o[2] == 0 {
        return Some("IETF protocol assignments");
    }
    if o[0] >= 240 {
        return Some("reserved");
    }
    None
}

/// **Every address a host name resolved to must be one an IdP may have**
/// ([`address_forbidden`]) — one bad answer among good ones refuses the
/// name, because the connection may land on any of them. No answer at all is
/// a refusal too.
pub fn addresses_allowed(
    host: &str,
    addrs: &[std::net::SocketAddr],
    allow_loopback: bool,
) -> Result<(), String> {
    addresses_allowed_in(host, addrs, allow_loopback, &PrivateNets::default())
}

/// [`address_forbidden`], with the operator's internal networks admitted.
pub fn address_forbidden_in(
    ip: std::net::IpAddr,
    allow_loopback: bool,
    nets: &PrivateNets,
) -> Option<&'static str> {
    if nets.contains(ip) {
        return None;
    }
    address_forbidden(ip, allow_loopback)
}

/// [`addresses_allowed`], with the operator's internal networks admitted.
pub fn addresses_allowed_in(
    host: &str,
    addrs: &[std::net::SocketAddr],
    allow_loopback: bool,
    nets: &PrivateNets,
) -> Result<(), String> {
    if addrs.is_empty() {
        return Err(format!("{host} resolves to no address"));
    }
    for addr in addrs {
        if let Some(why) = address_forbidden_in(addr.ip(), allow_loopback, nets) {
            return Err(format!("{host} resolves to {} ({why}): an identity provider is never on this box's own network", addr.ip()));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn urls_an_admin_types_cannot_reach_inside() {
        assert!(url_allowed("https://login.example.com/x", false).is_ok());
        for bad in [
            "http://login.example.com/",
            "https://127.0.0.1/",
            "https://10.0.0.5:8443/",
            "https://169.254.169.254/latest/meta-data",
            "https://[::1]/",
            "https://[fd00::1]/",
            "https://localhost/",
            "https://user:pw@login.example.com/",
            "https://100.64.1.1/",
            "ftp://login.example.com/",
        ] {
            assert!(url_allowed(bad, false).is_err(), "{bad} was allowed");
        }
        assert!(url_allowed("http://127.0.0.1:9000/x", true).is_ok());
        assert!(url_allowed("http://10.0.0.5/x", true).is_err());
        assert!(
            url_allowed("https://127.0.0.1/", true).is_err(),
            "the test door is http loopback only"
        );
    }

    /// ★ **A name that resolves inside the box is no IdP** — loopback,
    /// private, link-local (the metadata service), CGNAT, and the same
    /// smuggled inside IPv6; a public address is one.
    #[test]
    fn addresses_inside_the_box_are_refused() {
        use std::net::IpAddr;
        for bad in [
            "127.0.0.1",
            "127.8.9.10",
            "10.1.2.3",
            "172.16.0.1",
            "192.168.1.1",
            "169.254.169.254",
            "100.64.0.1",
            "0.0.0.0",
            "255.255.255.255",
            "224.0.0.1",
            "192.0.2.10",
            "198.18.0.1",
            "240.0.0.1",
            "::1",
            "::",
            "fd00::1",
            "fe80::1",
            "ff02::1",
            "2001:db8::1",
            "::ffff:127.0.0.1",
            "::ffff:10.0.0.1",
            "::ffff:169.254.169.254",
            "64:ff9b::a00:1",
            "fec0::1",
        ] {
            let ip: IpAddr = bad.parse().unwrap();
            assert!(address_forbidden(ip, false).is_some(), "{bad} was allowed");
        }
        for good in [
            "93.184.216.34",
            "1.1.1.1",
            "2606:4700:4700::1111",
            "2a00:1450:4001::200e",
        ] {
            let ip: IpAddr = good.parse().unwrap();
            assert_eq!(address_forbidden(ip, false), None, "{good} was refused");
        }
        // The test door admits loopback and nothing else private.
        assert_eq!(address_forbidden("127.0.0.1".parse().unwrap(), true), None);
        assert_eq!(address_forbidden("::1".parse().unwrap(), true), None);
        assert!(address_forbidden("10.0.0.1".parse().unwrap(), true).is_some());
        assert!(address_forbidden("169.254.169.254".parse().unwrap(), true).is_some());
    }

    #[test]
    fn every_resolved_address_must_pass() {
        let a = |s: &str| -> std::net::SocketAddr { s.parse().unwrap() };
        assert!(addresses_allowed("idp.example.test", &[a("93.184.216.34:443")], false).is_ok());
        let mixed = addresses_allowed(
            "idp.example.test",
            &[a("93.184.216.34:443"), a("10.0.0.7:443")],
            false,
        );
        assert!(
            mixed.unwrap_err().contains("10.0.0.7"),
            "one private answer among public ones passed"
        );
        assert!(addresses_allowed("idp.example.test", &[], false).is_err());
    }

    /// ★ **The operator's internal networks** (owner 2026-10-06): only
    /// private ranges may be listed — never loopback, link-local (the
    /// metadata service), multicast, documentation, public or a range that
    /// reaches outside a private one.
    #[test]
    fn only_private_networks_may_be_listed() {
        let nets = PrivateNets::parse(
            "10.0.2.100/32, 172.16.0.0/12 192.168.7.0/24,100.64.0.0/10\nfd00::/8",
        )
        .expect("private ranges");
        assert_eq!(
            nets.to_string(),
            "10.0.2.100/32,172.16.0.0/12,192.168.7.0/24,100.64.0.0/10,fd00::/8"
        );
        assert_eq!(
            PrivateNets::parse("").expect("empty"),
            PrivateNets::default()
        );
        assert_eq!(
            PrivateNets::parse("  ").expect("blank"),
            PrivateNets::default()
        );
        for bad in [
            "127.0.0.0/8",
            "127.0.0.1/32",
            "169.254.169.254/32",
            "169.254.0.0/16",
            "0.0.0.0/0",
            "8.8.8.0/24",
            "10.0.0.0/7",
            "192.0.2.0/24",
            "224.0.0.0/4",
            "::1/128",
            "fe80::/10",
            "::/0",
            "2001:db8::/32",
            "10.0.0.1",
            "10.0.0.0/33",
            "fd00::/129",
            "not-a-net",
            "::ffff:10.0.0.0/104",
        ] {
            assert!(PrivateNets::parse(bad).is_err(), "{bad} was accepted");
        }
    }

    /// ★ **An address inside a listed network passes; nothing else changes.**
    #[test]
    fn a_listed_network_admits_its_addresses_and_no_other() {
        use std::net::IpAddr;
        let nets = PrivateNets::parse("10.0.2.100/32,fd00:aa::/32").expect("nets");
        let ip = |s: &str| -> IpAddr { s.parse().unwrap() };
        assert!(nets.contains(ip("10.0.2.100")));
        assert!(
            nets.contains(ip("::ffff:10.0.2.100")),
            "v4-in-v6 is judged as v4"
        );
        assert!(nets.contains(ip("fd00:aa::7")));
        assert!(!nets.contains(ip("10.0.2.101")));
        assert_eq!(address_forbidden_in(ip("10.0.2.100"), false, &nets), None);
        assert!(address_forbidden_in(ip("10.0.2.101"), false, &nets).is_some());
        assert!(address_forbidden_in(ip("169.254.169.254"), false, &nets).is_some());
        assert!(address_forbidden_in(ip("127.0.0.1"), false, &nets).is_some());
        assert_eq!(
            address_forbidden_in(ip("93.184.216.34"), false, &nets),
            None,
            "public stays public"
        );
        assert!(url_allowed_in("https://10.0.2.100:8443/realms/cov", false, &nets).is_ok());
        assert!(url_allowed_in("https://10.0.2.101:8443/realms/cov", false, &nets).is_err());
        assert!(
            url_allowed_in("http://10.0.2.100:8443/realms/cov", false, &nets).is_err(),
            "still https only"
        );
        assert!(
            url_allowed("https://10.0.2.100:8443/realms/cov", false).is_err(),
            "no list: today's guard"
        );
        let a = |s: &str| -> std::net::SocketAddr { s.parse().unwrap() };
        assert!(addresses_allowed_in(
            "idp.corp.internal.example",
            &[a("10.0.2.100:8443")],
            false,
            &nets
        )
        .is_ok());
        assert!(addresses_allowed_in(
            "idp.corp.internal.example",
            &[a("10.0.2.100:8443"), a("10.9.9.9:8443")],
            false,
            &nets
        )
        .is_err());
    }
}