Skip to main content

openmoat_proxy/
decide.rs

1//! Which hosts and addresses the proxy may reach.
2//!
3//! A host is decided exactly as `moat guard` decides a fetch tool's URL: one
4//! `fetch` atom through openmoat-core's [`CompiledPolicy`], so `fetch` and `net`
5//! lists both apply and deny rules come first. The proxy cannot prompt, so an
6//! `ask` is refused. Cloud metadata and link-local addresses are refused
7//! whatever the policy says, by name and again after DNS resolution. Private
8//! and CGNAT addresses are refused unless an allow rule names the address.
9
10use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
11
12use openmoat_core::{AtomicAction, CompiledPolicy, Decision, Defaults, Kind, Policy, Verdict};
13
14/// Rule id for a destination refused whatever the policy says.
15pub const RULE_ADDRESS: &str = "proxy-address";
16/// Rule id for a tunnel whose TLS SNI is missing or names another host.
17pub const RULE_SNI: &str = "proxy-sni";
18/// Rule id for a request the proxy could not parse or does not serve.
19pub const RULE_REQUEST: &str = "proxy-request";
20/// Rule id for a destination that could not be resolved or reached.
21pub const RULE_UPSTREAM: &str = "proxy-upstream";
22/// Rule id for a connection refused because it could not be recorded.
23pub const RULE_AUDIT: &str = "proxy-audit";
24
25/// Metadata services reachable by name on cloud instances.
26const METADATA_NAMES: &[&str] = &["metadata.google.internal", "metadata.goog"];
27const ALIBABA_METADATA: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 200);
28/// Azure's `WireServer`, which serves instance configuration and keys to the VM.
29const AZURE_WIRESERVER: Ipv4Addr = Ipv4Addr::new(168, 63, 129, 16);
30const AWS_METADATA_V6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254);
31/// RFC 6052 well-known NAT64 prefix: `64:ff9b::a.b.c.d` reaches `a.b.c.d`.
32const NAT64: [u16; 6] = [0x64, 0xff9b, 0, 0, 0, 0];
33/// RFC 8215 local-use NAT64 prefix `64:ff9b:1::/48`. The operator picks how the
34/// IPv4 address is embedded inside it, so its addresses count as local network.
35const NAT64_LOCAL: [u16; 3] = [0x64, 0xff9b, 1];
36/// Ranges on the local network (or the carrier's), each with its description.
37const PRIVATE_V4: [(Ipv4Addr, u8, &str); 6] = [
38    (
39        Ipv4Addr::new(10, 0, 0, 0),
40        8,
41        "a private address (10.0.0.0/8)",
42    ),
43    (
44        Ipv4Addr::new(172, 16, 0, 0),
45        12,
46        "a private address (172.16.0.0/12)",
47    ),
48    (
49        Ipv4Addr::new(192, 168, 0, 0),
50        16,
51        "a private address (192.168.0.0/16)",
52    ),
53    (
54        Ipv4Addr::new(100, 64, 0, 0),
55        10,
56        "a shared CGNAT address (100.64.0.0/10)",
57    ),
58    (
59        Ipv4Addr::new(198, 18, 0, 0),
60        15,
61        "a benchmarking address (198.18.0.0/15)",
62    ),
63    (
64        Ipv4Addr::new(192, 0, 0, 0),
65        24,
66        "an IETF protocol address (192.0.0.0/24)",
67    ),
68];
69
70/// Decide whether `host`, normalised as [`Request::host`](crate::Request::host)
71/// reports it, may be reached. Only an `allow` lets the connection proceed.
72#[must_use]
73pub fn host(policy: &CompiledPolicy<'_>, host: &str) -> Decision {
74    if METADATA_NAMES
75        .iter()
76        .any(|m| host == *m || host.strip_suffix(m).is_some_and(|p| p.ends_with('.')))
77    {
78        return Decision::single(
79            Verdict::Deny,
80            RULE_ADDRESS,
81            format!("{host} is a cloud metadata service"),
82        );
83    }
84    let atom = AtomicAction::Fetch {
85        host: host.to_owned(),
86    };
87    let mut decision = policy.evaluate_atomic(&atom).unwrap_or_else(|| {
88        Decision::single(
89            Verdict::Deny,
90            RULE_REQUEST,
91            format!("no rule decided {host}"),
92        )
93    });
94    // A deferred host (ADR-025) is refused too: the agent's permission flow never sees
95    // the proxy's connections.
96    if matches!(decision.verdict, Verdict::Ask | Verdict::Defer) {
97        decision.verdict = Verdict::Deny;
98        decision
99            .reasons
100            .push("the proxy cannot ask, so a host the hook would ask for or leave to the agent is refused".to_owned());
101    }
102    decision
103}
104
105/// Why the proxy never connects to `ip`, if it does not. Checked on every
106/// resolved address, so a name the policy allows cannot be pointed at a local
107/// or metadata service (DNS rebinding).
108#[must_use]
109pub fn forbidden(ip: IpAddr) -> Option<Forbidden> {
110    match ip.to_canonical() {
111        IpAddr::V4(v4) => forbidden_v4(v4),
112        IpAddr::V6(v6) => forbidden_v6(v6),
113    }
114}
115
116/// The classes of address the proxy refuses.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118#[non_exhaustive]
119pub enum Forbidden {
120    /// This machine (`127.0.0.0/8`, `::1`), including the proxy itself.
121    Loopback,
122    /// Link-local (`169.254.0.0/16`, `fe80::/10`): where cloud metadata lives.
123    LinkLocal,
124    /// A cloud metadata address outside the link-local ranges.
125    Metadata,
126    /// Unspecified, broadcast, multicast or `0.0.0.0/8`: never a server.
127    NotUnicast,
128    /// Private, CGNAT, benchmarking or unique-local (`fc00::/7`): reachable
129    /// only when an allow rule names the address ([`named`]). Carries the
130    /// description naming the range.
131    Private(&'static str),
132}
133
134impl Forbidden {
135    /// A short phrase for reasons and the audit log.
136    #[must_use]
137    pub fn describe(self) -> &'static str {
138        match self {
139            Self::Loopback => "a loopback address",
140            Self::LinkLocal => "a link-local address",
141            Self::Metadata => "a cloud metadata address",
142            Self::NotUnicast => "not a unicast address",
143            Self::Private(range) => range,
144        }
145    }
146}
147
148fn forbidden_v4(ip: Ipv4Addr) -> Option<Forbidden> {
149    if ip.is_loopback() {
150        Some(Forbidden::Loopback)
151    } else if ip.is_link_local() {
152        Some(Forbidden::LinkLocal)
153    } else if ip == ALIBABA_METADATA || ip == AZURE_WIRESERVER {
154        Some(Forbidden::Metadata)
155    } else if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || ip.octets()[0] == 0 {
156        Some(Forbidden::NotUnicast)
157    } else {
158        PRIVATE_V4
159            .iter()
160            .find(|(net, len, _)| u32::from(ip) >> (32 - len) == u32::from(*net) >> (32 - len))
161            .map(|(_, _, range)| Forbidden::Private(range))
162    }
163}
164
165fn forbidden_v6(ip: Ipv6Addr) -> Option<Forbidden> {
166    let seg = ip.segments();
167    if ip.is_loopback() {
168        Some(Forbidden::Loopback)
169    } else if ip.is_unspecified() || ip.is_multicast() {
170        Some(Forbidden::NotUnicast)
171    } else if seg[0] & 0xffc0 == 0xfe80 {
172        Some(Forbidden::LinkLocal)
173    } else if ip == AWS_METADATA_V6 {
174        Some(Forbidden::Metadata)
175    } else if seg[..6] == NAT64 || seg[..6] == [0; 6] {
176        // NAT64 and the deprecated IPv4-compatible form carry an IPv4 address.
177        let [a, b] = seg[6].to_be_bytes();
178        let [c, d] = seg[7].to_be_bytes();
179        forbidden_v4(Ipv4Addr::new(a, b, c, d))
180    } else if seg[..3] == NAT64_LOCAL {
181        // The common /96 layout first, so a metadata or loopback address in it
182        // is named as such; any other layout is local network all the same.
183        let [a, b] = seg[6].to_be_bytes();
184        let [c, d] = seg[7].to_be_bytes();
185        forbidden_v4(Ipv4Addr::new(a, b, c, d)).or(Some(Forbidden::Private(
186            "a local-use NAT64 address (64:ff9b:1::/48)",
187        )))
188    } else if seg[0] & 0xfe00 == 0xfc00 {
189        Some(Forbidden::Private("a unique local address (fc00::/7)"))
190    } else {
191        None
192    }
193}
194
195/// The part of `policy` that can open a [`Forbidden::Private`] address: its
196/// deny rules, and the allow rules' `net` and `fetch` patterns that name an
197/// address: a pattern starting with a digit or containing `:`, not starting
198/// with a glob metacharacter, and made only of what an address's text and a
199/// glob use. Everything else is denied, so a wildcard such as `*` or
200/// `*.example`, or a name such as `db.internal:5432`, never reaches the
201/// local network by DNS.
202#[must_use]
203pub fn address_policy(policy: &Policy) -> Policy {
204    let allow = policy
205        .allow
206        .iter()
207        .map(|g| {
208            let keep = |list: &[String]| {
209                list.iter()
210                    .filter(|p| names_address(p))
211                    .cloned()
212                    .collect::<Vec<_>>()
213            };
214            openmoat_core::RuleGroup {
215                id: g.id.clone(),
216                reason: g.reason.clone(),
217                net: keep(g.patterns(Kind::Net)),
218                fetch: keep(g.patterns(Kind::Fetch)),
219                ..Default::default()
220            }
221        })
222        .collect();
223    Policy {
224        defaults: Defaults::All(Verdict::Deny),
225        deny: policy.deny.clone(),
226        allow,
227        ask: Vec::new(),
228        ..policy.clone()
229    }
230}
231
232/// Whether a host pattern names an address (see [`address_policy`]).
233fn names_address(pattern: &str) -> bool {
234    !pattern.starts_with(['*', '?', '[', '{'])
235        && (pattern.starts_with(|c: char| c.is_ascii_digit()) || pattern.contains(':'))
236        && pattern
237            .chars()
238            .all(|c| c.is_ascii_hexdigit() || ".:*?[]{},!-".contains(c))
239}
240
241/// Whether `addresses` (compiled from [`address_policy`]) allows `ip`, matched
242/// in its canonical text form (`::ffff:10.0.0.5` as `10.0.0.5`).
243#[must_use]
244pub fn named(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> bool {
245    let atom = AtomicAction::Fetch {
246        host: ip.to_canonical().to_string(),
247    };
248    addresses
249        .evaluate_atomic(&atom)
250        .is_some_and(|d| d.verdict == Verdict::Allow)
251}
252
253#[cfg(test)]
254mod tests;