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

nordisk/gunnar

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