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