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