1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
13
14use openmoat_core::{
15 Action, AtomicAction, CompiledPolicy, Decision, Defaults, Kind, MapPathResolver, Policy, Taint,
16 Verdict,
17};
18
19pub const RULE_ADDRESS: &str = "proxy-address";
21pub const RULE_SNI: &str = "proxy-sni";
23pub const RULE_REQUEST: &str = "proxy-request";
25pub const RULE_UPSTREAM: &str = "proxy-upstream";
27pub const RULE_AUDIT: &str = "proxy-audit";
29
30const METADATA_NAMES: &[&str] = &["metadata.google.internal", "metadata.goog"];
32const ALIBABA_METADATA: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 200);
33const AZURE_WIRESERVER: Ipv4Addr = Ipv4Addr::new(168, 63, 129, 16);
35const AWS_METADATA_V6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254);
36const NAT64: [u16; 6] = [0x64, 0xff9b, 0, 0, 0, 0];
38const NAT64_LOCAL: [u16; 3] = [0x64, 0xff9b, 1];
41const PRIVATE_V4: [(Ipv4Addr, u8, &str); 6] = [
43 (
44 Ipv4Addr::new(10, 0, 0, 0),
45 8,
46 "a private address (10.0.0.0/8)",
47 ),
48 (
49 Ipv4Addr::new(172, 16, 0, 0),
50 12,
51 "a private address (172.16.0.0/12)",
52 ),
53 (
54 Ipv4Addr::new(192, 168, 0, 0),
55 16,
56 "a private address (192.168.0.0/16)",
57 ),
58 (
59 Ipv4Addr::new(100, 64, 0, 0),
60 10,
61 "a shared CGNAT address (100.64.0.0/10)",
62 ),
63 (
64 Ipv4Addr::new(198, 18, 0, 0),
65 15,
66 "a benchmarking address (198.18.0.0/15)",
67 ),
68 (
69 Ipv4Addr::new(192, 0, 0, 0),
70 24,
71 "an IETF protocol address (192.0.0.0/24)",
72 ),
73];
74
75#[must_use]
78pub fn host(policy: &CompiledPolicy<'_>, host: &str) -> Decision {
79 if METADATA_NAMES
80 .iter()
81 .any(|m| host == *m || host.strip_suffix(m).is_some_and(|p| p.ends_with('.')))
82 {
83 return Decision::single(
84 Verdict::Deny,
85 RULE_ADDRESS,
86 format!("{host} is a cloud metadata service"),
87 );
88 }
89 let atom = AtomicAction::Fetch {
90 host: host.to_owned(),
91 };
92 let mut decision = policy.evaluate_atomic(&atom).unwrap_or_else(|| {
93 Decision::single(
94 Verdict::Deny,
95 RULE_REQUEST,
96 format!("no rule decided {host}"),
97 )
98 });
99 if matches!(decision.verdict, Verdict::Ask | Verdict::Defer) {
102 decision.verdict = Verdict::Deny;
103 decision
104 .reasons
105 .push("the proxy cannot ask, so a host the hook would ask for or leave to the agent is refused".to_owned());
106 }
107 decision
108}
109
110#[must_use]
116pub fn tainted(
117 policy: &CompiledPolicy<'_>,
118 host: &str,
119 port: u16,
120 taint: &Taint,
121) -> Option<Decision> {
122 let authority = if host.contains(':') {
123 format!("[{host}]:{port}")
124 } else {
125 format!("{host}:{port}")
126 };
127 let action = Action::Net {
128 url: format!("https://{authority}/"),
129 };
130 let mut decision = policy.with_taint(
131 Decision::new(Verdict::Allow),
132 &action,
133 &MapPathResolver::default(),
134 taint,
135 );
136 if decision.verdict == Verdict::Allow {
137 return None;
138 }
139 decision.verdict = Verdict::Deny;
140 decision.reasons.push(
141 "the proxy cannot ask, so a session that read secret material reaches only that \
142 secret's own host until it ends; `openmoat allow` does not answer a secret's taint, \
143 so start a new session"
144 .to_owned(),
145 );
146 Some(decision)
147}
148
149#[must_use]
153pub fn forbidden(ip: IpAddr) -> Option<Forbidden> {
154 match ip.to_canonical() {
155 IpAddr::V4(v4) => forbidden_v4(v4),
156 IpAddr::V6(v6) => forbidden_v6(v6),
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[non_exhaustive]
163pub enum Forbidden {
164 Loopback,
166 LinkLocal,
168 Metadata,
170 NotUnicast,
172 Private(&'static str),
176}
177
178impl Forbidden {
179 #[must_use]
181 pub fn describe(self) -> &'static str {
182 match self {
183 Self::Loopback => "a loopback address",
184 Self::LinkLocal => "a link-local address",
185 Self::Metadata => "a cloud metadata address",
186 Self::NotUnicast => "not a unicast address",
187 Self::Private(range) => range,
188 }
189 }
190}
191
192fn forbidden_v4(ip: Ipv4Addr) -> Option<Forbidden> {
193 if ip.is_loopback() {
194 Some(Forbidden::Loopback)
195 } else if ip.is_link_local() {
196 Some(Forbidden::LinkLocal)
197 } else if ip == ALIBABA_METADATA || ip == AZURE_WIRESERVER {
198 Some(Forbidden::Metadata)
199 } else if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || ip.octets()[0] == 0 {
200 Some(Forbidden::NotUnicast)
201 } else {
202 PRIVATE_V4
203 .iter()
204 .find(|(net, len, _)| u32::from(ip) >> (32 - len) == u32::from(*net) >> (32 - len))
205 .map(|(_, _, range)| Forbidden::Private(range))
206 }
207}
208
209fn forbidden_v6(ip: Ipv6Addr) -> Option<Forbidden> {
210 let seg = ip.segments();
211 if ip.is_loopback() {
212 Some(Forbidden::Loopback)
213 } else if ip.is_unspecified() || ip.is_multicast() {
214 Some(Forbidden::NotUnicast)
215 } else if seg[0] & 0xffc0 == 0xfe80 {
216 Some(Forbidden::LinkLocal)
217 } else if ip == AWS_METADATA_V6 {
218 Some(Forbidden::Metadata)
219 } else if let Some(v4) = carried_v4(ip) {
220 forbidden_v4(v4)
221 } else if let Some(why) = tunnelled_v4(ip).find_map(forbidden_v4) {
222 Some(why)
223 } else if seg[..3] == NAT64_LOCAL {
224 let [a, b] = seg[6].to_be_bytes();
227 let [c, d] = seg[7].to_be_bytes();
228 forbidden_v4(Ipv4Addr::new(a, b, c, d)).or(Some(Forbidden::Private(
229 "a local-use NAT64 address (64:ff9b:1::/48)",
230 )))
231 } else if seg[0] & 0xfe00 == 0xfc00 {
232 Some(Forbidden::Private("a unique local address (fc00::/7)"))
233 } else {
234 None
235 }
236}
237
238fn carried_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
241 let seg = ip.segments();
242 (seg[..6] == NAT64 || seg[..6] == [0; 6]).then(|| {
243 let [a, b] = seg[6].to_be_bytes();
244 let [c, d] = seg[7].to_be_bytes();
245 Ipv4Addr::new(a, b, c, d)
246 })
247}
248
249fn tunnelled_v4(ip: Ipv6Addr) -> impl Iterator<Item = Ipv4Addr> {
253 let o = ip.octets();
254 let six_to_four = (o[..2] == [0x20, 0x02]).then(|| Ipv4Addr::new(o[2], o[3], o[4], o[5]));
255 let teredo = o[..4] == [0x20, 0x01, 0, 0];
256 let client = teredo.then(|| Ipv4Addr::new(!o[12], !o[13], !o[14], !o[15]));
257 let server = teredo.then(|| Ipv4Addr::new(o[4], o[5], o[6], o[7]));
258 [six_to_four, client, server].into_iter().flatten()
259}
260
261#[must_use]
270pub fn address_policy(policy: &Policy) -> Policy {
271 let allow = policy
272 .allow
273 .iter()
274 .map(|g| {
275 let keep = |list: &[String]| {
276 list.iter()
277 .filter(|p| names_address(p))
278 .cloned()
279 .collect::<Vec<_>>()
280 };
281 openmoat_core::RuleGroup {
282 id: g.id.clone(),
283 reason: g.reason.clone(),
284 net: keep(g.patterns(Kind::Net)),
285 fetch: keep(g.patterns(Kind::Fetch)),
286 ..Default::default()
287 }
288 })
289 .collect();
290 Policy {
292 defaults: Defaults::All(Verdict::Ask),
293 deny: policy.deny.clone(),
294 allow,
295 ask: Vec::new(),
296 ..policy.clone()
297 }
298}
299
300fn names_address(pattern: &str) -> bool {
302 !pattern.starts_with(['*', '?', '[', '{'])
303 && (pattern.starts_with(|c: char| c.is_ascii_digit()) || pattern.contains(':'))
304 && pattern
305 .chars()
306 .all(|c| c.is_ascii_hexdigit() || ".:*?[]{},!-".contains(c))
307}
308
309#[must_use]
312pub fn named(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> bool {
313 let atom = AtomicAction::Fetch {
314 host: ip.to_canonical().to_string(),
315 };
316 addresses
317 .evaluate_atomic(&atom)
318 .is_some_and(|d| d.verdict == Verdict::Allow)
319}
320
321#[must_use]
328pub fn denied(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> Option<Decision> {
329 let ip = ip.to_canonical();
330 let carried: Vec<IpAddr> = match ip {
331 IpAddr::V6(v6) => carried_v4(v6)
332 .into_iter()
333 .chain(tunnelled_v4(v6))
334 .map(IpAddr::V4)
335 .collect(),
336 IpAddr::V4(_) => Vec::new(),
337 };
338 std::iter::once(ip).chain(carried).find_map(|ip| {
339 let atom = AtomicAction::Fetch {
340 host: ip.to_string(),
341 };
342 addresses
343 .evaluate_atomic(&atom)
344 .filter(|d| d.verdict == Verdict::Deny)
345 })
346}
347
348#[cfg(test)]
349mod tests;