crates/gunnar-server/tests/sso_egress.rs
//! **The IdP egress resolves names itself, checks every answer, and connects
//! to exactly what it checked** (company SSO). An account admin types the
//! issuer; `https://idp.attacker.example/` that resolves to the box's own
//! loopback, its private network, the cloud's metadata service or `[::1]` is
//! refused before a socket is opened — and the connection is pinned to the
//! checked answer, so a name that answers differently a moment later (DNS
//! rebinding) is never looked up a second time.
//!
//! No real DNS: every lookup here is a test resolver hook.
#![cfg(feature = "sso-egress")] // A32-18: the airgap default carries no HTTP client; the appliance rows and CI name `sso-egress`.
use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use gunnar_grpc::sso::Egress as _;
use gunnar_server::sso_egress::{GuardedResolver, Lookup, UreqEgress};
/// A hook answering `addrs` for every name, counting its calls.
fn hook(addrs: &[&str]) -> (Lookup, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&calls);
let answer: Vec<String> = addrs.iter().map(|s| s.to_string()).collect();
let lookup: Lookup = Arc::new(move |_host: &str, port: u16| {
counted.fetch_add(1, Ordering::SeqCst);
Ok(answer
.iter()
.map(|ip| SocketAddr::new(ip.parse().expect("an address"), port))
.collect())
});
(lookup, calls)
}
/// ★ A NAME resolving to 127.0.0.1, 10.x, 169.254.169.254 or ::1 is refused —
/// at the resolver and through the egress — and nothing is dialled.
#[test]
fn a_name_that_resolves_inside_the_box_is_refused() {
for inside in ["127.0.0.1", "10.20.30.40", "169.254.169.254", "::1"] {
let (lookup, calls) = hook(&[inside]);
let resolver = GuardedResolver::new(Arc::clone(&lookup), false);
let err = resolver
.resolve_checked("idp.attacker.example", 443)
.expect_err(inside);
assert!(
err.contains(inside.trim_start_matches("::ffff:")),
"{inside}: {err}"
);
let egress = UreqEgress::with_lookup(false, lookup);
let err = egress
.get("https://idp.attacker.example/.well-known/openid-configuration")
.expect_err(inside);
assert!(
err.contains("resolves to"),
"{inside}: the egress said {err}"
);
let err = egress
.post_form(
"https://idp.attacker.example/token",
"",
"grant_type=authorization_code",
)
.expect_err(inside);
assert!(
err.contains("resolves to"),
"{inside}: the token request said {err}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"{inside}: one lookup per request, and no other"
);
}
}
/// One private answer among public ones refuses the name: the connection
/// could land on any of them.
#[test]
fn one_private_answer_among_public_ones_refuses_the_name() {
let (lookup, _) = hook(&["93.184.216.34", "10.0.0.7"]);
let err = GuardedResolver::new(lookup, false)
.resolve_checked("idp.example.test", 443)
.unwrap_err();
assert!(err.contains("10.0.0.7"), "{err}");
let (lookup, _) = hook(&["2606:4700:4700::1111", "::ffff:127.0.0.1"]);
assert!(GuardedResolver::new(lookup, false)
.resolve_checked("idp.example.test", 443)
.is_err());
}
/// A public answer is allowed, and it is exactly what the connector gets.
#[test]
fn a_public_name_is_allowed() {
let (lookup, calls) = hook(&["93.184.216.34", "2606:4700:4700::1111"]);
let got = GuardedResolver::new(lookup, false)
.resolve_checked("idp.example.test", 443)
.expect("a public IdP");
assert_eq!(
got,
vec![
"93.184.216.34:443".parse::<SocketAddr>().unwrap(),
"[2606:4700:4700::1111]:443".parse().unwrap()
]
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
/// ★ **Pinned**: the request connects to the address the check saw. The
/// hook answers the checked loopback address once and a metadata address on
/// any later call; the request succeeds and the hook was asked exactly
/// once — nothing re-resolved between the check and the connect.
#[test]
fn the_connection_is_pinned_to_the_checked_answer() {
let server = TcpListener::bind("127.0.0.1:0").unwrap();
let port = server.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = server.accept() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
}
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"
);
}
});
let calls = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&calls);
let lookup: Lookup = Arc::new(move |_host: &str, port: u16| {
let n = counted.fetch_add(1, Ordering::SeqCst);
let ip = if n == 0 {
"127.0.0.1"
} else {
"169.254.169.254"
};
Ok(vec![SocketAddr::new(ip.parse().unwrap(), port)])
});
// The operator's test door: an IdP on this box's loopback, over http.
let egress = UreqEgress::with_lookup(true, lookup);
let (status, body) = egress
.get(&format!("http://localhost:{port}/x"))
.expect("the pinned request");
assert_eq!((status, body.as_str()), (200, "ok"));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"the name was looked up again after the check"
);
}
/// The test door admits loopback only: a name answering a private address
/// is refused with the door open too.
#[test]
fn the_test_door_admits_loopback_and_nothing_else() {
let (lookup, _) = hook(&["10.0.2.100"]);
let err = UreqEgress::with_lookup(true, lookup)
.get("http://localhost:9/x")
.unwrap_err();
assert!(err.contains("10.0.2.100"), "{err}");
}