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
118fn metadata(host: &str) -> Option<Decision> {
120 METADATA_NAMES
121 .iter()
122 .any(|m| host == *m || host.strip_suffix(m).is_some_and(|p| p.ends_with('.')))
123 .then(|| {
124 Decision::single(
125 Verdict::Deny,
126 RULE_ADDRESS,
127 format!("{host} is a cloud metadata service"),
128 )
129 })
130}
131
132#[must_use]
138pub fn tainted(
139 policy: &CompiledPolicy<'_>,
140 host: &str,
141 port: u16,
142 taint: &Taint,
143) -> Option<Decision> {
144 let authority = if host.contains(':') {
145 format!("[{host}]:{port}")
146 } else {
147 format!("{host}:{port}")
148 };
149 let action = Action::Net {
150 url: format!("https://{authority}/"),
151 };
152 let mut decision = policy.with_taint(
153 Decision::new(Verdict::Allow),
154 &action,
155 &MapPathResolver::default(),
156 taint,
157 );
158 if decision.verdict == Verdict::Allow {
159 return None;
160 }
161 decision.verdict = Verdict::Deny;
162 decision.reasons.push(
163 "the proxy cannot prompt, so a session that read secret material reaches only that \
164 secret's own host until it ends; `openmoat allow` does not answer a secret's taint, \
165 so start a new session"
166 .to_owned(),
167 );
168 Some(decision)
169}
170
171#[must_use]
175pub fn forbidden(ip: IpAddr) -> Option<Forbidden> {
176 match ip.to_canonical() {
177 IpAddr::V4(v4) => forbidden_v4(v4),
178 IpAddr::V6(v6) => forbidden_v6(v6),
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum Forbidden {
186 Loopback,
188 LinkLocal,
190 Metadata,
192 ThisMachine,
197 NotUnicast,
199 Private(&'static str),
204}
205
206impl Forbidden {
207 #[must_use]
209 pub fn describe(self) -> &'static str {
210 match self {
211 Self::Loopback => "a loopback address",
212 Self::LinkLocal => "a link-local address",
213 Self::Metadata => "a cloud metadata address",
214 Self::ThisMachine => "an address of this machine",
215 Self::NotUnicast => "not a unicast address",
216 Self::Private(range) => range,
217 }
218 }
219}
220
221fn forbidden_v4(ip: Ipv4Addr) -> Option<Forbidden> {
222 if ip.is_loopback() {
223 Some(Forbidden::Loopback)
224 } else if ip.is_link_local() {
225 Some(Forbidden::LinkLocal)
226 } else if ip == ALIBABA_METADATA || ip == AZURE_WIRESERVER {
227 Some(Forbidden::Metadata)
228 } else if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || ip.octets()[0] == 0 {
229 Some(Forbidden::NotUnicast)
230 } else {
231 PRIVATE_V4
232 .iter()
233 .find(|(net, len, _)| u32::from(ip) >> (32 - len) == u32::from(*net) >> (32 - len))
234 .map(|(_, _, range)| Forbidden::Private(range))
235 }
236}
237
238fn forbidden_v6(ip: Ipv6Addr) -> Option<Forbidden> {
239 let seg = ip.segments();
240 if ip.is_loopback() {
241 Some(Forbidden::Loopback)
242 } else if ip.is_unspecified() || ip.is_multicast() {
243 Some(Forbidden::NotUnicast)
244 } else if seg[0] & 0xffc0 == 0xfe80 {
245 Some(Forbidden::LinkLocal)
246 } else if ip == AWS_METADATA_V6 {
247 Some(Forbidden::Metadata)
248 } else if let Some(v4) = carried_v4(ip) {
249 forbidden_v4(v4)
250 } else if let Some(why) = tunnelled_v4(ip).find_map(forbidden_v4) {
251 Some(why)
252 } else if seg[..3] == NAT64_LOCAL {
253 let [a, b] = seg[6].to_be_bytes();
256 let [c, d] = seg[7].to_be_bytes();
257 forbidden_v4(Ipv4Addr::new(a, b, c, d)).or(Some(Forbidden::Private(
258 "a local-use NAT64 address (64:ff9b:1::/48)",
259 )))
260 } else if seg[0] & 0xfe00 == 0xfc00 {
261 Some(Forbidden::Private("a unique local address (fc00::/7)"))
262 } else if seg[0] & 0xffc0 == 0xfec0 {
263 Some(Forbidden::Private(
266 "a deprecated site-local address (fec0::/10)",
267 ))
268 } else {
269 None
270 }
271}
272
273fn carried_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
277 let seg = ip.segments();
278 (seg[..6] == NAT64 || seg[..6] == [0; 6] || seg[..6] == SIIT).then(|| {
279 let [a, b] = seg[6].to_be_bytes();
280 let [c, d] = seg[7].to_be_bytes();
281 Ipv4Addr::new(a, b, c, d)
282 })
283}
284
285fn tunnelled_v4(ip: Ipv6Addr) -> impl Iterator<Item = Ipv4Addr> {
289 let o = ip.octets();
290 let six_to_four = (o[..2] == [0x20, 0x02]).then(|| Ipv4Addr::new(o[2], o[3], o[4], o[5]));
291 let teredo = o[..4] == [0x20, 0x01, 0, 0];
292 let client = teredo.then(|| Ipv4Addr::new(!o[12], !o[13], !o[14], !o[15]));
293 let server = teredo.then(|| Ipv4Addr::new(o[4], o[5], o[6], o[7]));
294 [six_to_four, client, server].into_iter().flatten()
295}
296
297#[must_use]
309pub fn address_policy(policy: &Policy) -> Policy {
310 let allow = policy
311 .allow
312 .iter()
313 .map(|g| {
314 let keep = |list: &[String]| {
316 let kept: Vec<String> = list
317 .iter()
318 .filter(|p| p.starts_with('!') || names_address(p))
319 .cloned()
320 .collect();
321 ports::with_ports(&kept, false)
322 };
323 openmoat_core::RuleGroup {
324 id: g.id.clone(),
325 reason: g.reason.clone(),
326 net: keep(g.patterns(Kind::Net)),
327 fetch: keep(g.patterns(Kind::Fetch)),
328 ..Default::default()
329 }
330 })
331 .collect();
332 Policy {
334 defaults: Defaults::All(Verdict::Review),
335 deny: policy.deny.clone(),
336 allow,
337 review: Vec::new(),
338 ..policy.clone()
339 }
340}
341
342fn names_address(pattern: &str) -> bool {
350 let body = pattern.strip_prefix('!').unwrap_or(pattern);
351 let host = openmoat_core::port_pattern(body).map_or(body, |(host, _)| host);
352 !body.starts_with(['*', '?', '[', '{'])
353 && (host.starts_with(|c: char| c.is_ascii_digit()) || host.contains(':'))
354 && body
355 .chars()
356 .all(|c| c.is_ascii_hexdigit() || ".:*?[]{},-".contains(c))
357}
358
359#[must_use]
362pub fn named(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> bool {
363 let atom = AtomicAction::Fetch {
364 host: ip.to_canonical().to_string(),
365 };
366 addresses
367 .evaluate_atomic(&atom)
368 .is_some_and(|d| d.verdict == Verdict::Allow)
369}
370
371#[must_use]
374pub fn named_on(addresses: &CompiledPolicy<'_>, addr: SocketAddr) -> bool {
375 if named(addresses, addr.ip()) {
376 return true;
377 }
378 let IpAddr::V4(v4) = addr.ip().to_canonical() else {
379 return false;
380 };
381 let atom = AtomicAction::Fetch {
382 host: format!("{v4}:{}", addr.port()),
383 };
384 addresses
385 .evaluate_atomic(&atom)
386 .is_some_and(|d| d.verdict == Verdict::Allow)
387}
388
389#[must_use]
396pub fn denied(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> Option<Decision> {
397 let ip = ip.to_canonical();
398 let carried: Vec<IpAddr> = match ip {
399 IpAddr::V6(v6) => carried_v4(v6)
400 .into_iter()
401 .chain(tunnelled_v4(v6))
402 .map(IpAddr::V4)
403 .collect(),
404 IpAddr::V4(_) => Vec::new(),
405 };
406 std::iter::once(ip).chain(carried).find_map(|ip| {
407 let atom = AtomicAction::Fetch {
408 host: ip.to_string(),
409 };
410 addresses
411 .evaluate_atomic(&atom)
412 .filter(|d| d.verdict == Verdict::Deny)
413 })
414}
415
416#[cfg(test)]
417mod tests;