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());
}
}