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