Skip to main content

openmoat_proxy/decide/
ports.rs

1//! Which ports of a host the proxy may reach.
2//!
3//! A `net` or `fetch` pattern without a port allows the hosts it matches on
4//! [`DEFAULT_PORTS`] only. A pattern `host:port` ([`port_pattern`]) allows the
5//! hosts its host part matches on that one port, whether or not a pattern
6//! without a port names them, as `example.com` allows `example.com` on 443.
7//! Deny rules hold on every port, and a host an allow list excludes without a
8//! port is excluded on every port. IPv6 literals take no port: they are
9//! reached on the default ports only.
10
11use openmoat_core::{
12    AtomicAction, CompiledPolicy, Decision, Defaults, Kind, Policy, RuleGroup, Verdict,
13    port_pattern,
14};
15
16/// Rule id for a host the policy allows, refused on a port no rule names.
17pub const RULE_PORT: &str = "proxy-port";
18
19/// The ports a host pattern without a port allows: HTTPS and HTTP.
20pub const DEFAULT_PORTS: [u16; 2] = [443, 80];
21
22/// The part of `policy` that decides a host and its port, as
23/// [`host_port`] uses it: every deny and repository ask rule as written, and
24/// the allow rules' `host:port` patterns only, so that a pattern without a
25/// port (`*`, `example.com`) never opens another port. Every exclusion
26/// without a port (`!evil.example.com`) also excludes the host on every port.
27/// Anything else is left to the caller (`defer`).
28#[must_use]
29pub fn port_policy(policy: &Policy) -> Policy {
30    let lists = |groups: &[RuleGroup], ported_only: bool| -> Vec<RuleGroup> {
31        groups
32            .iter()
33            .map(|g| RuleGroup {
34                id: g.id.clone(),
35                reason: g.reason.clone(),
36                net: with_ports(g.patterns(Kind::Net), ported_only),
37                fetch: with_ports(g.patterns(Kind::Fetch), ported_only),
38                ..Default::default()
39            })
40            .collect()
41    };
42    Policy {
43        defaults: Defaults::All(Verdict::Defer),
44        deny: lists(&policy.deny, false),
45        repo_ask: lists(&policy.repo_ask, false),
46        allow: lists(&policy.allow, true),
47        ask: Vec::new(),
48        defer: Vec::new(),
49        ..policy.clone()
50    }
51}
52
53/// `list` (only its `host:port` patterns when `ported_only`), and `!host:*`
54/// for each exclusion `!host` without a port. An IPv6 exclusion gets none: it
55/// takes no port, and its `:` would make `!host:*` match other addresses.
56pub(super) fn with_ports(list: &[String], ported_only: bool) -> Vec<String> {
57    let kept = list
58        .iter()
59        .filter(|p| !ported_only || port_pattern(p.strip_prefix('!').unwrap_or(p)).is_some());
60    let excluded = list
61        .iter()
62        .filter_map(|p| p.strip_prefix('!'))
63        .filter(|host| !host.contains(':'))
64        .map(|host| format!("!{host}:*"));
65    kept.cloned().chain(excluded).collect()
66}
67
68/// `host:port` as patterns name it; `None` for an IPv6 address.
69fn ported(host: &str, port: u16) -> Option<String> {
70    (!host.contains(':')).then(|| format!("{host}:{port}"))
71}
72
73/// Decide whether `host`, normalised as [`Request::host`](crate::Request::host)
74/// reports it, may be reached on `port`. `ports` is compiled from
75/// [`port_policy`] of `policy`'s policy. Only an `allow` lets the connection
76/// proceed:
77///
78/// - a deny or repository ask rule naming the host, or `host:port`, refuses;
79/// - an allow rule naming `host:port` allows;
80/// - otherwise the host is decided as [`host`](super::host) decides it, and an
81///   allowed host passes on [`DEFAULT_PORTS`] only: any other port is refused
82///   with [`RULE_PORT`].
83#[must_use]
84pub fn host_port(
85    policy: &CompiledPolicy<'_>,
86    ports: &CompiledPolicy<'_>,
87    host: &str,
88    port: u16,
89) -> Decision {
90    let decide = |subject: String| ports.evaluate_atomic(&AtomicAction::Fetch { host: subject });
91    let refused = |d: &Decision| matches!(d.verdict, Verdict::Deny | Verdict::Ask);
92    if super::metadata(host).is_some() || decide(host.to_owned()).is_some_and(|d| refused(&d)) {
93        return super::host(policy, host);
94    }
95    match ported(host, port).and_then(decide) {
96        Some(d) if d.verdict == Verdict::Allow => return d,
97        Some(mut d) if refused(&d) => {
98            if d.verdict == Verdict::Ask {
99                d.verdict = Verdict::Deny;
100                d.reasons.push(
101                    "the proxy cannot ask, so a host and port it would ask for is refused"
102                        .to_owned(),
103                );
104            }
105            return d;
106        }
107        _ => {}
108    }
109    let decision = super::host(policy, host);
110    if decision.verdict != Verdict::Allow || DEFAULT_PORTS.contains(&port) {
111        return decision;
112    }
113    let how = if host.contains(':') {
114        "an IPv6 address is reached on those ports only".to_owned()
115    } else {
116        format!("a `net` rule `{host}:{port}` allows this port")
117    };
118    Decision::single(
119        Verdict::Deny,
120        RULE_PORT,
121        format!("{host} is allowed on ports 443 and 80 only, not {port}; {how}"),
122    )
123}
124
125#[cfg(test)]
126mod tests;