Skip to main content

openmoat_proxy/
upstream.rs

1//! Resolving and connecting to the destination.
2//!
3//! The proxy resolves names itself and checks every address it gets back
4//! before connecting to one of exactly those addresses, so the check and the
5//! connection cannot disagree (no second lookup an attacker could race).
6
7use std::io;
8use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs as _};
9use std::time::Duration;
10
11use openmoat_core::{CompiledPolicy, Decision, Verdict};
12
13use crate::decide::{self, Forbidden, RULE_ADDRESS, RULE_UPSTREAM};
14
15/// Name resolution, injectable so tests can map names without a DNS server.
16pub trait Resolve: Send + Sync {
17    /// Every address the DNS name `host` resolves to, with `port` attached.
18    /// IP literals never reach the resolver.
19    fn resolve(&self, host: &str, port: u16) -> io::Result<Vec<SocketAddr>>;
20}
21
22/// The operating system's resolver (`getaddrinfo` and its equivalents).
23#[derive(Debug, Clone, Copy, Default)]
24pub struct SystemResolver;
25
26impl Resolve for SystemResolver {
27    fn resolve(&self, host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
28        Ok((resolver_name(host).as_str(), port)
29            .to_socket_addrs()?
30            .collect())
31    }
32}
33
34/// The name the system resolver is given for `host` (normalised, without a
35/// trailing dot): absolute, `host.`, so the resolver never tries
36/// `host.<search domain>` first (with `ndots` above the name's dots, as in
37/// Kubernetes, a wildcard in a search domain would answer for the allowed
38/// name). glibc, musl and macOS's resolver read a trailing dot as a fully
39/// qualified name. On Windows the name is passed as it is: its DNS client
40/// already appends no suffix to a name with a dot in it, and how
41/// `getaddrinfo` there treats a trailing dot with the hosts file and other
42/// name services is not documented.
43#[cfg(not(windows))]
44pub(crate) fn resolver_name(host: &str) -> String {
45    format!("{host}.")
46}
47
48/// See the other platforms' `resolver_name`.
49#[cfg(windows)]
50pub(crate) fn resolver_name(host: &str) -> String {
51    host.to_owned()
52}
53
54/// Why no connection was made.
55#[derive(Debug)]
56#[non_exhaustive]
57pub enum Refusal {
58    /// A resolved address the proxy never connects to.
59    Address(SocketAddr, Forbidden),
60    /// A resolved address a policy deny rule matches, with that rule's decision.
61    Denied(SocketAddr, Decision),
62    /// The name did not resolve, or no address accepted the connection.
63    Unreachable(String),
64}
65
66impl Refusal {
67    /// The decision recorded for this refusal.
68    #[must_use]
69    pub fn decision(&self, host: &str) -> Decision {
70        match self {
71            Self::Address(addr, why) => Decision::single(
72                Verdict::Deny,
73                RULE_ADDRESS,
74                format!("{host} resolves to {}, {}", addr.ip(), why.describe()),
75            ),
76            Self::Denied(addr, denied) => {
77                let rules = denied
78                    .rules
79                    .iter()
80                    .zip(&denied.reasons)
81                    .map(|(rule, reason)| format!("`{rule}` ({reason})"))
82                    .collect::<Vec<_>>()
83                    .join(", ");
84                Decision::single(
85                    Verdict::Deny,
86                    RULE_ADDRESS,
87                    format!(
88                        "{host} resolves to {}, refused by deny rule {rules}",
89                        addr.ip()
90                    ),
91                )
92            }
93            Self::Unreachable(why) => Decision::single(Verdict::Deny, RULE_UPSTREAM, why.clone()),
94        }
95    }
96}
97
98/// Resolve `host`, refuse if any address is forbidden or matched by a deny
99/// rule of `addresses` (compiled from [`decide::address_policy`]), then
100/// connect to the first address that answers. A private address passes only
101/// when `addresses` names it, alone or with `port`.
102/// `loopback_ok` lists loopback addresses that may be reached anyway (tests);
103/// nothing exempts link-local or metadata.
104pub fn connect(
105    resolver: &dyn Resolve,
106    host: &str,
107    port: u16,
108    addresses: &CompiledPolicy<'_>,
109    loopback_ok: &[SocketAddr],
110    timeout: Duration,
111) -> Result<TcpStream, Refusal> {
112    let addrs = match host.parse::<IpAddr>() {
113        Ok(ip) => vec![SocketAddr::new(ip, port)],
114        Err(_) => resolver
115            .resolve(host, port)
116            .map_err(|e| Refusal::Unreachable(format!("cannot resolve {host}: {e}")))?,
117    };
118    if addrs.is_empty() {
119        return Err(Refusal::Unreachable(format!("{host} has no addresses")));
120    }
121    // One bad address refuses the name: an allowed name that also points at
122    // a metadata service is an attack, not a fallback.
123    for addr in &addrs {
124        match decide::forbidden(addr.ip()) {
125            Some(Forbidden::Loopback) if loopback_ok.contains(addr) => {}
126            Some(Forbidden::Private(_)) if decide::named_on(addresses, *addr) => {}
127            Some(why) => return Err(Refusal::Address(*addr, why)),
128            None => {}
129        }
130        // The host decision matched only the name; a deny naming one of its
131        // addresses still holds.
132        if let Some(denied) = decide::denied(addresses, addr.ip()) {
133            return Err(Refusal::Denied(*addr, denied));
134        }
135    }
136    let mut last = None;
137    for addr in &addrs {
138        match TcpStream::connect_timeout(addr, timeout) {
139            // A connection to this machine leaves from the address it goes
140            // to (RFC 6724 rule 1, and the local route on IPv4). Nothing has
141            // been sent yet; the stream is dropped, so it closes.
142            Ok(stream) if !loopback_ok.contains(addr) && to_this_machine(&stream, addr.ip()) => {
143                return Err(Refusal::Address(*addr, Forbidden::ThisMachine));
144            }
145            Ok(stream) => return Ok(stream),
146            Err(e) => last = Some(e),
147        }
148    }
149    Err(Refusal::Unreachable(match last {
150        Some(e) => format!("cannot connect to {host}:{port}: {e}"),
151        None => format!("cannot connect to {host}:{port}"),
152    }))
153}
154
155/// Whether `stream`, connected to `peer`, reached this machine: its own end
156/// has the same address. A stream whose own address cannot be read counts.
157fn to_this_machine(stream: &TcpStream, peer: IpAddr) -> bool {
158    stream
159        .local_addr()
160        .map_or(true, |own| own.ip().to_canonical() == peer.to_canonical())
161}