use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use openmoat_core::{
Action, AtomicAction, CompiledPolicy, Decision, Defaults, Kind, MapPathResolver, Policy, Taint,
Verdict,
};
pub const RULE_ADDRESS: &str = "proxy-address";
pub const RULE_SNI: &str = "proxy-sni";
pub const RULE_REQUEST: &str = "proxy-request";
pub const RULE_UPSTREAM: &str = "proxy-upstream";
pub const RULE_AUDIT: &str = "proxy-audit";
const METADATA_NAMES: &[&str] = &["metadata.google.internal", "metadata.goog"];
const ALIBABA_METADATA: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 200);
const AZURE_WIRESERVER: Ipv4Addr = Ipv4Addr::new(168, 63, 129, 16);
const AWS_METADATA_V6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254);
const NAT64: [u16; 6] = [0x64, 0xff9b, 0, 0, 0, 0];
const NAT64_LOCAL: [u16; 3] = [0x64, 0xff9b, 1];
const PRIVATE_V4: [(Ipv4Addr, u8, &str); 6] = [
(
Ipv4Addr::new(10, 0, 0, 0),
8,
"a private address (10.0.0.0/8)",
),
(
Ipv4Addr::new(172, 16, 0, 0),
12,
"a private address (172.16.0.0/12)",
),
(
Ipv4Addr::new(192, 168, 0, 0),
16,
"a private address (192.168.0.0/16)",
),
(
Ipv4Addr::new(100, 64, 0, 0),
10,
"a shared CGNAT address (100.64.0.0/10)",
),
(
Ipv4Addr::new(198, 18, 0, 0),
15,
"a benchmarking address (198.18.0.0/15)",
),
(
Ipv4Addr::new(192, 0, 0, 0),
24,
"an IETF protocol address (192.0.0.0/24)",
),
];
#[must_use]
pub fn host(policy: &CompiledPolicy<'_>, host: &str) -> Decision {
if METADATA_NAMES
.iter()
.any(|m| host == *m || host.strip_suffix(m).is_some_and(|p| p.ends_with('.')))
{
return Decision::single(
Verdict::Deny,
RULE_ADDRESS,
format!("{host} is a cloud metadata service"),
);
}
let atom = AtomicAction::Fetch {
host: host.to_owned(),
};
let mut decision = policy.evaluate_atomic(&atom).unwrap_or_else(|| {
Decision::single(
Verdict::Deny,
RULE_REQUEST,
format!("no rule decided {host}"),
)
});
if matches!(decision.verdict, Verdict::Ask | Verdict::Defer) {
decision.verdict = Verdict::Deny;
decision
.reasons
.push("the proxy cannot ask, so a host the hook would ask for or leave to the agent is refused".to_owned());
}
decision
}
#[must_use]
pub fn tainted(
policy: &CompiledPolicy<'_>,
host: &str,
port: u16,
taint: &Taint,
) -> Option<Decision> {
let authority = if host.contains(':') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
};
let action = Action::Net {
url: format!("https://{authority}/"),
};
let mut decision = policy.with_taint(
Decision::new(Verdict::Allow),
&action,
&MapPathResolver::default(),
taint,
);
if decision.verdict == Verdict::Allow {
return None;
}
decision.verdict = Verdict::Deny;
decision.reasons.push(
"the proxy cannot ask, so a session that read secret material reaches only that \
secret's own host until it ends; `openmoat allow` does not answer a secret's taint, \
so start a new session"
.to_owned(),
);
Some(decision)
}
#[must_use]
pub fn forbidden(ip: IpAddr) -> Option<Forbidden> {
match ip.to_canonical() {
IpAddr::V4(v4) => forbidden_v4(v4),
IpAddr::V6(v6) => forbidden_v6(v6),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Forbidden {
Loopback,
LinkLocal,
Metadata,
NotUnicast,
Private(&'static str),
}
impl Forbidden {
#[must_use]
pub fn describe(self) -> &'static str {
match self {
Self::Loopback => "a loopback address",
Self::LinkLocal => "a link-local address",
Self::Metadata => "a cloud metadata address",
Self::NotUnicast => "not a unicast address",
Self::Private(range) => range,
}
}
}
fn forbidden_v4(ip: Ipv4Addr) -> Option<Forbidden> {
if ip.is_loopback() {
Some(Forbidden::Loopback)
} else if ip.is_link_local() {
Some(Forbidden::LinkLocal)
} else if ip == ALIBABA_METADATA || ip == AZURE_WIRESERVER {
Some(Forbidden::Metadata)
} else if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || ip.octets()[0] == 0 {
Some(Forbidden::NotUnicast)
} else {
PRIVATE_V4
.iter()
.find(|(net, len, _)| u32::from(ip) >> (32 - len) == u32::from(*net) >> (32 - len))
.map(|(_, _, range)| Forbidden::Private(range))
}
}
fn forbidden_v6(ip: Ipv6Addr) -> Option<Forbidden> {
let seg = ip.segments();
if ip.is_loopback() {
Some(Forbidden::Loopback)
} else if ip.is_unspecified() || ip.is_multicast() {
Some(Forbidden::NotUnicast)
} else if seg[0] & 0xffc0 == 0xfe80 {
Some(Forbidden::LinkLocal)
} else if ip == AWS_METADATA_V6 {
Some(Forbidden::Metadata)
} else if let Some(v4) = carried_v4(ip) {
forbidden_v4(v4)
} else if let Some(why) = tunnelled_v4(ip).find_map(forbidden_v4) {
Some(why)
} else if seg[..3] == NAT64_LOCAL {
let [a, b] = seg[6].to_be_bytes();
let [c, d] = seg[7].to_be_bytes();
forbidden_v4(Ipv4Addr::new(a, b, c, d)).or(Some(Forbidden::Private(
"a local-use NAT64 address (64:ff9b:1::/48)",
)))
} else if seg[0] & 0xfe00 == 0xfc00 {
Some(Forbidden::Private("a unique local address (fc00::/7)"))
} else {
None
}
}
fn carried_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
let seg = ip.segments();
(seg[..6] == NAT64 || seg[..6] == [0; 6]).then(|| {
let [a, b] = seg[6].to_be_bytes();
let [c, d] = seg[7].to_be_bytes();
Ipv4Addr::new(a, b, c, d)
})
}
fn tunnelled_v4(ip: Ipv6Addr) -> impl Iterator<Item = Ipv4Addr> {
let o = ip.octets();
let six_to_four = (o[..2] == [0x20, 0x02]).then(|| Ipv4Addr::new(o[2], o[3], o[4], o[5]));
let teredo = o[..4] == [0x20, 0x01, 0, 0];
let client = teredo.then(|| Ipv4Addr::new(!o[12], !o[13], !o[14], !o[15]));
let server = teredo.then(|| Ipv4Addr::new(o[4], o[5], o[6], o[7]));
[six_to_four, client, server].into_iter().flatten()
}
#[must_use]
pub fn address_policy(policy: &Policy) -> Policy {
let allow = policy
.allow
.iter()
.map(|g| {
let keep = |list: &[String]| {
list.iter()
.filter(|p| names_address(p))
.cloned()
.collect::<Vec<_>>()
};
openmoat_core::RuleGroup {
id: g.id.clone(),
reason: g.reason.clone(),
net: keep(g.patterns(Kind::Net)),
fetch: keep(g.patterns(Kind::Fetch)),
..Default::default()
}
})
.collect();
Policy {
defaults: Defaults::All(Verdict::Ask),
deny: policy.deny.clone(),
allow,
ask: Vec::new(),
..policy.clone()
}
}
fn names_address(pattern: &str) -> bool {
!pattern.starts_with(['*', '?', '[', '{'])
&& (pattern.starts_with(|c: char| c.is_ascii_digit()) || pattern.contains(':'))
&& pattern
.chars()
.all(|c| c.is_ascii_hexdigit() || ".:*?[]{},!-".contains(c))
}
#[must_use]
pub fn named(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> bool {
let atom = AtomicAction::Fetch {
host: ip.to_canonical().to_string(),
};
addresses
.evaluate_atomic(&atom)
.is_some_and(|d| d.verdict == Verdict::Allow)
}
#[must_use]
pub fn denied(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> Option<Decision> {
let ip = ip.to_canonical();
let carried: Vec<IpAddr> = match ip {
IpAddr::V6(v6) => carried_v4(v6)
.into_iter()
.chain(tunnelled_v4(v6))
.map(IpAddr::V4)
.collect(),
IpAddr::V4(_) => Vec::new(),
};
std::iter::once(ip).chain(carried).find_map(|ip| {
let atom = AtomicAction::Fetch {
host: ip.to_string(),
};
addresses
.evaluate_atomic(&atom)
.filter(|d| d.verdict == Verdict::Deny)
})
}
#[cfg(test)]
mod tests;