1mod 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
23pub const RULE_ADDRESS: &str = "proxy-address";
25pub const RULE_SNI: &str = "proxy-sni";
27pub const RULE_REQUEST: &str = "proxy-request";
29pub const RULE_UPSTREAM: &str = "proxy-upstream";
31pub const RULE_AUDIT: &str = "proxy-audit";
33
34const METADATA_NAMES: &[&str] = &["metadata.google.internal", "metadata.goog"];
36const ALIBABA_METADATA: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 200);
37const AZURE_WIRESERVER: Ipv4Addr = Ipv4Addr::new(168, 63, 129, 16);
39const AWS_METADATA_V6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254);
40const NAT64: [u16; 6] = [0x64, 0xff9b, 0, 0, 0, 0];
42const NAT64_LOCAL: [u16; 3] = [0x64, 0xff9b, 1];
45const SIIT: [u16; 6] = [0, 0, 0, 0, 0xffff, 0];
47const 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 (
83 Ipv4Addr::new(240, 0, 0, 0),
84 4,
85 "a reserved address (240.0.0.0/4)",
86 ),
87];
88
89#[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 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
118pub(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
125fn 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188#[non_exhaustive]
189pub enum Forbidden {
190 Loopback,
192 LinkLocal,
194 Metadata,
196 ThisMachine,
201 NotUnicast,
203 Private(&'static str),
208}
209
210impl Forbidden {
211 #[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 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 Some(Forbidden::Private(
270 "a deprecated site-local address (fec0::/10)",
271 ))
272 } else {
273 None
274 }
275}
276
277fn 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
289fn 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#[must_use]
313pub fn address_policy(policy: &Policy) -> Policy {
314 let allow = policy
315 .allow
316 .iter()
317 .map(|g| {
318 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 Policy {
338 defaults: Defaults::All(Verdict::Review),
339 deny: policy.deny.clone(),
340 allow,
341 review: Vec::new(),
342 ..policy.clone()
343 }
344}
345
346fn 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#[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#[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#[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;