crates/gunnar-server/src/sso_egress.rs
//! **How gunnar reaches a company's identity provider** — the one
//! [`gunnar_grpc::sso::Egress`]: the blocking `ureq` this crate already links
//! (the mirror fetch's), with no redirect followed, no proxy from the
//! environment, ten seconds a call, a megabyte an answer, and every URL put to
//! [`gunnar_grpc::sso::url_allowed`] again here — an account admin typed it.
//!
//! **The name is resolved HERE, every answer checked, and the connection made
//! to exactly those answers** ([`GuardedResolver`]). `url_allowed` sees only
//! the URL's text, so `https://idp.attacker.example/` that resolves to
//! `127.0.0.1`, `10.x`, `169.254.169.254` (the cloud's metadata service) or
//! `[::1]` would have passed it. ureq's own resolver is replaced, not
//! wrapped: the addresses checked are the addresses ureq connects to — no
//! second lookup, so a DNS answer that changes between the check and the
//! connect (rebinding) has nothing to change.
//!
//! The answer's status is returned, never turned into an error: an OAuth
//! error body on a 400 carries the words the admin needs.
//!
//! **A box behind its front** (the lean estate, A19 row 14: no public IPv4, fed the front's
//! egress door, `nordisk_pairing::egress::proxy`) makes each call through that door: the URL
//! is still put to `url_allowed` here, the door names the host and refuses any address inside
//! a private, loopback, link-local or shared network ([`DoorOnly`] resolves the door alone).
//! An IdP on the operator's own internal network is then out of reach by design: the door is
//! a way out, never in.
use std::io::Read as _;
use std::net::{SocketAddr, ToSocketAddrs as _};
use std::sync::Arc;
use std::time::Duration;
use std::sync::RwLock;
use gunnar_grpc::sso::{addresses_allowed_in, url_allowed_in, Egress, PrivateNets, MAX_IDP_BODY};
const TIMEOUT: Duration = Duration::from_secs(10);
/// A name lookup: `(host, port)` → its addresses. The system's by default
/// ([`system_lookup`]); a test hands in its own (no real DNS in a test).
pub type Lookup = Arc<dyn Fn(&str, u16) -> std::io::Result<Vec<SocketAddr>> + Send + Sync>;
/// The system resolver (`getaddrinfo`, through `ToSocketAddrs`).
pub fn system_lookup() -> Lookup {
Arc::new(|host: &str, port: u16| (host, port).to_socket_addrs().map(|a| a.collect()))
}
/// **ureq's resolver, replaced**: the lookup, then EVERY answer checked
/// ([`addresses_allowed`]), then exactly those answers handed to the
/// connector. One lookup per connection; nothing re-resolves.
#[derive(Clone)]
pub struct GuardedResolver {
lookup: Lookup,
allow_loopback: bool,
/// The operator's internal networks an IdP may live on, read per lookup.
nets: Arc<RwLock<PrivateNets>>,
}
impl std::fmt::Debug for GuardedResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GuardedResolver")
.field("allow_loopback", &self.allow_loopback)
.finish_non_exhaustive()
}
}
impl GuardedResolver {
pub fn new(lookup: Lookup, allow_loopback: bool) -> Self {
Self::with_nets(lookup, allow_loopback, Arc::default())
}
/// The same, admitting the operator's internal networks (`nets`, shared
/// with `gunnar_grpc::sso::SsoState`: a re-fed list takes effect live).
pub fn with_nets(lookup: Lookup, allow_loopback: bool, nets: Arc<RwLock<PrivateNets>>) -> Self {
GuardedResolver {
lookup,
allow_loopback,
nets,
}
}
/// **Resolve `host` and keep the answer only if every address is one an
/// IdP may have.** What the connection is then made to.
pub fn resolve_checked(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, String> {
// A bracketed IPv6 literal is an address, not a name.
let bare = host.trim_start_matches('[').trim_end_matches(']');
let addrs =
(self.lookup)(bare, port).map_err(|e| format!("{bare} does not resolve: {e}"))?;
let nets = self.nets.read().unwrap_or_else(|p| p.into_inner()).clone();
addresses_allowed_in(bare, &addrs, self.allow_loopback, &nets)?;
Ok(addrs)
}
}
impl ureq::unversioned::resolver::Resolver for GuardedResolver {
fn resolve(
&self,
uri: &ureq::http::Uri,
_config: &ureq::config::Config,
_timeout: ureq::unversioned::transport::NextTimeout,
) -> Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
let host = uri
.host()
.ok_or_else(|| ureq::Error::BadUri(format!("{uri} names no host")))?;
let port = uri.port_u16().unwrap_or(match uri.scheme_str() {
Some("http") => 80,
_ => 443,
});
let addrs = self.resolve_checked(host, port).map_err(|why| {
ureq::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
why,
))
})?;
let mut out = self.empty();
for addr in addrs.into_iter().take(16) {
out.push(addr);
}
Ok(out)
}
}
/// **The resolver of a call through the front's egress door**: ureq resolves the door's
/// address alone (the door names the destination), and nothing else.
#[derive(Debug, Clone, Copy)]
pub struct DoorOnly(pub SocketAddr);
impl ureq::unversioned::resolver::Resolver for DoorOnly {
fn resolve(
&self,
uri: &ureq::http::Uri,
_config: &ureq::config::Config,
_timeout: ureq::unversioned::transport::NextTimeout,
) -> Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
let at = (
uri.host().map(|h| {
h.trim_start_matches('[')
.trim_end_matches(']')
.parse::<std::net::IpAddr>()
.ok()
}),
uri.port_u16(),
);
if at != (Some(Some(self.0.ip())), Some(self.0.port())) {
return Err(ureq::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"{uri}: a box behind its front resolves its egress door alone ({})",
self.0
),
)));
}
let mut out = self.empty();
out.push(self.0);
Ok(out)
}
}
/// The `ureq` egress.
pub struct UreqEgress {
agent: ureq::Agent,
/// The resolver every agent of this egress connects through (the account
/// CA's per-call agent too: the guard is the same).
resolver: GuardedResolver,
allow_loopback_http: bool,
nets: Arc<RwLock<PrivateNets>>,
}
/// The one configuration every call of this egress is made with; `proxy` is the front's
/// egress door of a box behind its front, else none (never one from the environment).
fn config(tls: Option<ureq::tls::TlsConfig>, proxy: Option<ureq::Proxy>) -> ureq::config::Config {
let builder = ureq::Agent::config_builder()
.redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
.max_redirects(0)
.timeout_connect(Some(TIMEOUT))
.timeout_global(Some(TIMEOUT))
.proxy(proxy)
.http_status_as_error(false);
match tls {
Some(tls) => builder.tls_config(tls).build(),
None => builder.build(),
}
}
impl UreqEgress {
/// `allow_loopback_http`: the operator's test door
/// (`GUNNAR_SSO_ALLOW_LOOPBACK_HTTP=1`) — an IdP on this box's loopback.
pub fn new(allow_loopback_http: bool) -> Self {
Self::with_lookup(allow_loopback_http, system_lookup())
}
/// The same, resolving names with `lookup` (a test's).
pub fn with_lookup(allow_loopback_http: bool, lookup: Lookup) -> Self {
Self::with_nets(allow_loopback_http, lookup, Arc::default())
}
/// The same, admitting the operator's internal networks (`nets`, the
/// handle `gunnar_grpc::sso::SsoState::private_nets_handle` hands out: a
/// re-fed list takes effect on the next call).
pub fn with_nets(
allow_loopback_http: bool,
lookup: Lookup,
nets: Arc<RwLock<PrivateNets>>,
) -> Self {
let resolver = GuardedResolver::with_nets(lookup, allow_loopback_http, Arc::clone(&nets));
let agent = ureq::Agent::with_parts(
config(None, None),
ureq::unversioned::transport::DefaultConnector::default(),
resolver.clone(),
);
UreqEgress {
agent,
resolver,
allow_loopback_http,
nets,
}
}
fn nets(&self) -> PrivateNets {
self.nets.read().unwrap_or_else(|p| p.into_inner()).clone()
}
/// **An agent for ONE call that trusts `ca` beside the public roots** —
/// an account's own IdP CA. Built per call and dropped after it: the CA is
/// never a root of any other call, of this account or another.
fn agent_trusting(&self, ca: &[Vec<u8>]) -> ureq::Agent {
let tls = Self::tls_trusting(ca);
ureq::Agent::with_parts(
config(Some(tls), None),
ureq::unversioned::transport::DefaultConnector::default(),
self.resolver.clone(),
)
}
/// The public roots and `ca` beside them.
fn tls_trusting(ca: &[Vec<u8>]) -> ureq::tls::TlsConfig {
let mut roots: Vec<ureq::tls::Certificate<'static>> =
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned())
.collect();
roots.extend(
ca.iter()
.map(|der| ureq::tls::Certificate::from_der(der).to_owned()),
);
ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::Specific(Arc::new(roots)))
.build()
}
/// **The agent of one call of a box behind its front**: through the fed egress door
/// (`nordisk_pairing::egress::proxy`), resolving the door alone ([`DoorOnly`]); `None` on a
/// box with its own way out.
fn through_the_door(ca: &[Vec<u8>]) -> Option<ureq::Agent> {
let door = nordisk_pairing::egress::proxy()?;
let at = nordisk_pairing::egress::door_addr(door).ok()?;
let proxy = ureq::Proxy::new(door).ok()?;
let tls = (!ca.is_empty()).then(|| Self::tls_trusting(ca));
Some(ureq::Agent::with_parts(
config(tls, Some(proxy)),
ureq::unversioned::transport::DefaultConnector::default(),
DoorOnly(at),
))
}
fn get_with(&self, agent: &ureq::Agent, url: &str) -> Result<(u16, String), String> {
url_allowed_in(url, self.allow_loopback_http, &self.nets())?;
let response = agent
.get(url)
.header(
"Accept",
"application/json, application/samlmetadata+xml, application/xml, text/xml",
)
.call()
.map_err(|e| format!("{e}"))?;
Self::read(response)
}
fn post_with(
&self,
agent: &ureq::Agent,
url: &str,
authorization: &str,
body: &str,
) -> Result<(u16, String), String> {
url_allowed_in(url, self.allow_loopback_http, &self.nets())?;
let mut request = agent
.post(url)
.header("Accept", "application/json")
.header("Content-Type", "application/x-www-form-urlencoded");
if !authorization.is_empty() {
request = request.header("Authorization", authorization);
}
// `{e}` of a transport error never carries the body (the secret is in
// the header and the code in the body; neither is in an error string).
let response = request.send(body).map_err(|e| format!("{e}"))?;
Self::read(response)
}
fn read(response: ureq::http::Response<ureq::Body>) -> Result<(u16, String), String> {
let status = response.status().as_u16();
let mut body = Vec::new();
response
.into_body()
.into_reader()
.take(MAX_IDP_BODY as u64 + 1)
.read_to_end(&mut body)
.map_err(|e| format!("reading the answer: {e}"))?;
if body.len() > MAX_IDP_BODY {
return Err(format!("the answer is larger than {MAX_IDP_BODY} bytes"));
}
Ok((status, String::from_utf8_lossy(&body).into_owned()))
}
}
impl Egress for UreqEgress {
fn get(&self, url: &str) -> Result<(u16, String), String> {
match Self::through_the_door(&[]) {
Some(agent) => self.get_with(&agent, url),
None => self.get_with(&self.agent, url),
}
}
fn post_form(
&self,
url: &str,
authorization: &str,
body: &str,
) -> Result<(u16, String), String> {
match Self::through_the_door(&[]) {
Some(agent) => self.post_with(&agent, url, authorization, body),
None => self.post_with(&self.agent, url, authorization, body),
}
}
fn get_trusting(&self, url: &str, ca: &[Vec<u8>]) -> Result<(u16, String), String> {
if ca.is_empty() {
return self.get(url);
}
match Self::through_the_door(ca) {
Some(agent) => self.get_with(&agent, url),
None => self.get_with(&self.agent_trusting(ca), url),
}
}
fn post_form_trusting(
&self,
url: &str,
authorization: &str,
body: &str,
ca: &[Vec<u8>],
) -> Result<(u16, String), String> {
if ca.is_empty() {
return self.post_form(url, authorization, body);
}
match Self::through_the_door(ca) {
Some(agent) => self.post_with(&agent, url, authorization, body),
None => self.post_with(&self.agent_trusting(ca), url, authorization, body),
}
}
}